refactor(service,session): apply rustfmt formatting

This commit is contained in:
ZhenYi 2026-05-14 10:02:29 +08:00
parent 18ea3cc355
commit 06c08148cb
57 changed files with 802 additions and 384 deletions

View File

@ -41,9 +41,7 @@ impl AppService {
.await? .await?
{ {
BillingResult::Success(record) => Ok(record), BillingResult::Success(record) => Ok(record),
BillingResult::InsufficientBalance { message } => { BillingResult::InsufficientBalance { message } => Err(AppError::BadRequest(message)),
Err(AppError::BadRequest(message))
}
} }
} }
} }

View File

@ -403,7 +403,16 @@ async fn call_ai_model(
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())]; let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
agent::call_with_params(&messages, model_name, &client_config, 0.2, 8192, None, None, None) agent::call_with_params(
&messages,
model_name,
&client_config,
0.2,
8192,
None,
None,
None,
)
.await .await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e))) .map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
} }

View File

@ -106,8 +106,7 @@ async fn call_ai_for_triage(
.ai_basic_url() .ai_basic_url()
.unwrap_or_else(|_| "https://api.openai.com".into()); .unwrap_or_else(|_| "https://api.openai.com".into());
let client_config = let client_config = ::agent::AiClientConfig::new(api_key).with_base_url(base_url);
::agent::AiClientConfig::new(api_key).with_base_url(base_url);
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())]; let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
@ -122,9 +121,7 @@ async fn call_ai_for_triage(
None, None,
) )
.await .await
.map_err(|e| { .map_err(|e| AppError::InternalServerError(format!("AI triage call failed: {}", e)))?;
AppError::InternalServerError(format!("AI triage call failed: {}", e))
})?;
Ok(response) Ok(response)
} }
@ -137,7 +134,9 @@ impl AppService {
project_name: String, project_name: String,
issue_number: i64, issue_number: i64,
) -> Result<IssueTriageResponse, AppError> { ) -> Result<IssueTriageResponse, AppError> {
let project = self.utils_find_project_by_name(project_name.clone()).await?; let project = self
.utils_find_project_by_name(project_name.clone())
.await?;
let issue_model = issue::Entity::find() let issue_model = issue::Entity::find()
.filter(issue::Column::Project.eq(project.id)) .filter(issue::Column::Project.eq(project.id))
@ -168,8 +167,11 @@ impl AppService {
} }
}; };
let prompt = let prompt = build_triage_prompt(
build_triage_prompt(&issue_model.title, issue_model.body.as_deref(), &existing_labels); &issue_model.title,
issue_model.body.as_deref(),
&existing_labels,
);
let ai_response = match call_ai_for_triage(&model.name, &prompt, &self.config).await { let ai_response = match call_ai_for_triage(&model.name, &prompt, &self.config).await {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {

View File

@ -5,7 +5,10 @@ use crate::error::AppError;
use session::Session; use session::Session;
use uuid::Uuid; use uuid::Uuid;
pub use agent::model::model_entry::{CreateModelRequest, ModelListResponse, ModelResponse, UpdateModelRequest, ModelWithPricingResponse}; pub use agent::model::model_entry::{
CreateModelRequest, ModelListResponse, ModelResponse, ModelWithPricingResponse,
UpdateModelRequest,
};
impl AppService { impl AppService {
pub async fn agent_model_list( pub async fn agent_model_list(
@ -30,7 +33,8 @@ impl AppService {
search.as_deref(), search.as_deref(),
page, page,
per_page, per_page,
).await?) )
.await?)
} }
pub async fn agent_model_get( pub async fn agent_model_get(
@ -60,11 +64,7 @@ impl AppService {
Ok(agent::model::model_entry::update_model(&self.db, id, request).await?) Ok(agent::model::model_entry::update_model(&self.db, id, request).await?)
} }
pub async fn agent_model_delete( pub async fn agent_model_delete(&self, id: Uuid, ctx: &Session) -> Result<(), AppError> {
&self,
id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
super::provider::require_system_caller(ctx)?; super::provider::require_system_caller(ctx)?;
Ok(agent::model::model_entry::delete_model(&self.db, id).await?) Ok(agent::model::model_entry::delete_model(&self.db, id).await?)
} }

View File

@ -4,7 +4,9 @@ use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use session::Session; use session::Session;
pub use agent::model::capability::{CreateModelCapabilityRequest, ModelCapabilityResponse, UpdateModelCapabilityRequest}; pub use agent::model::capability::{
CreateModelCapabilityRequest, ModelCapabilityResponse, UpdateModelCapabilityRequest,
};
impl AppService { impl AppService {
pub async fn agent_model_capability_list( pub async fn agent_model_capability_list(

View File

@ -5,7 +5,10 @@ use crate::error::AppError;
use session::Session; use session::Session;
use uuid::Uuid; use uuid::Uuid;
pub use agent::model::parameter_profile::{CreateModelParameterProfileRequest, ModelParameterProfileResponse, UpdateModelParameterProfileRequest}; pub use agent::model::parameter_profile::{
CreateModelParameterProfileRequest, ModelParameterProfileResponse,
UpdateModelParameterProfileRequest,
};
impl AppService { impl AppService {
pub async fn agent_model_parameter_profile_list( pub async fn agent_model_parameter_profile_list(
@ -13,7 +16,10 @@ impl AppService {
model_version_id: Uuid, model_version_id: Uuid,
_ctx: &Session, _ctx: &Session,
) -> Result<Vec<agent::model::parameter_profile::ModelParameterProfileResponse>, AppError> { ) -> Result<Vec<agent::model::parameter_profile::ModelParameterProfileResponse>, AppError> {
Ok(agent::model::parameter_profile::list_parameter_profiles(&self.db, model_version_id).await?) Ok(
agent::model::parameter_profile::list_parameter_profiles(&self.db, model_version_id)
.await?,
)
} }
pub async fn agent_model_parameter_profile_get( pub async fn agent_model_parameter_profile_get(
@ -40,7 +46,10 @@ impl AppService {
ctx: &Session, ctx: &Session,
) -> Result<agent::model::parameter_profile::ModelParameterProfileResponse, AppError> { ) -> Result<agent::model::parameter_profile::ModelParameterProfileResponse, AppError> {
super::provider::require_system_caller(ctx)?; super::provider::require_system_caller(ctx)?;
Ok(agent::model::parameter_profile::update_parameter_profile(&self.db, id, request).await?) Ok(
agent::model::parameter_profile::update_parameter_profile(&self.db, id, request)
.await?,
)
} }
pub async fn agent_model_parameter_profile_delete( pub async fn agent_model_parameter_profile_delete(

View File

@ -5,7 +5,9 @@ use crate::error::AppError;
use session::Session; use session::Session;
use uuid::Uuid; use uuid::Uuid;
pub use agent::model::pricing::{CreateModelPricingRequest, ModelPricingResponse, UpdateModelPricingRequest}; pub use agent::model::pricing::{
CreateModelPricingRequest, ModelPricingResponse, UpdateModelPricingRequest,
};
impl AppService { impl AppService {
pub async fn agent_model_pricing_list( pub async fn agent_model_pricing_list(
@ -43,11 +45,7 @@ impl AppService {
Ok(agent::model::pricing::update_pricing(&self.db, id, request).await?) Ok(agent::model::pricing::update_pricing(&self.db, id, request).await?)
} }
pub async fn agent_model_pricing_delete( pub async fn agent_model_pricing_delete(&self, id: i64, ctx: &Session) -> Result<(), AppError> {
&self,
id: i64,
ctx: &Session,
) -> Result<(), AppError> {
super::provider::require_system_caller(ctx)?; super::provider::require_system_caller(ctx)?;
Ok(agent::model::pricing::delete_pricing(&self.db, id).await?) Ok(agent::model::pricing::delete_pricing(&self.db, id).await?)
} }

View File

@ -5,7 +5,9 @@ use crate::error::AppError;
use session::Session; use session::Session;
use uuid::Uuid; use uuid::Uuid;
pub use agent::model::version::{CreateModelVersionRequest, ModelVersionResponse, UpdateModelVersionRequest}; pub use agent::model::version::{
CreateModelVersionRequest, ModelVersionResponse, UpdateModelVersionRequest,
};
impl AppService { impl AppService {
pub async fn agent_model_version_list( pub async fn agent_model_version_list(

View File

@ -138,7 +138,16 @@ async fn call_ai_model_for_description(
let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())]; let messages = vec![agent::ChatRequestMessage::user(prompt.to_string())];
agent::call_with_params(&messages, model_name, &client_config, 0.3, 4096, None, None, None) agent::call_with_params(
&messages,
model_name,
&client_config,
0.3,
4096,
None,
None,
None,
)
.await .await
.map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e))) .map_err(|e| AppError::InternalServerError(format!("AI call failed: {}", e)))
} }

View File

@ -49,11 +49,7 @@ impl AppService {
Ok(agent::model::provider::update_provider(&self.db, id, request).await?) Ok(agent::model::provider::update_provider(&self.db, id, request).await?)
} }
pub async fn agent_provider_delete( pub async fn agent_provider_delete(&self, id: Uuid, ctx: &Session) -> Result<(), AppError> {
&self,
id: Uuid,
ctx: &Session,
) -> Result<(), AppError> {
require_system_caller(ctx)?; require_system_caller(ctx)?;
Ok(agent::model::provider::delete_provider(&self.db, id).await?) Ok(agent::model::provider::delete_provider(&self.db, id).await?)
} }

View File

@ -17,8 +17,8 @@ use std::time::Duration;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio::time::interval; use tokio::time::interval;
use crate::error::AppError;
use crate::AppService; use crate::AppService;
use crate::error::AppError;
use chrono::Utc; use chrono::Utc;
use db::database::AppDatabase; use db::database::AppDatabase;
use models::agents::model::Entity as ModelEntity; use models::agents::model::Entity as ModelEntity;
@ -28,8 +28,8 @@ use models::agents::model_provider::Entity as ProviderEntity;
use models::agents::model_provider::Model as ProviderModel; use models::agents::model_provider::Model as ProviderModel;
use models::agents::model_version::Entity as VersionEntity; use models::agents::model_version::Entity as VersionEntity;
use models::agents::{CapabilityType, ModelCapability, ModelModality, ModelStatus}; use models::agents::{CapabilityType, ModelCapability, ModelModality, ModelStatus};
use sea_orm::prelude::*;
use sea_orm::Set; use sea_orm::Set;
use sea_orm::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use session::Session; use session::Session;
@ -444,8 +444,8 @@ fn extract_model_name(model: &UpstreamModel) -> String {
/// For models with the same name from different providers, keeps the newest one /// For models with the same name from different providers, keeps the newest one
/// and deletes the older duplicates. /// and deletes the older duplicates.
async fn mark_all_models_offline(db: &AppDatabase) -> Result<i64, AppError> { async fn mark_all_models_offline(db: &AppDatabase) -> Result<i64, AppError> {
use models::agents::model::Entity as MEntity;
use models::agents::model::Column as MCol; use models::agents::model::Column as MCol;
use models::agents::model::Entity as MEntity;
let now = Utc::now(); let now = Utc::now();
let updated = MEntity::update_many() let updated = MEntity::update_many()
@ -569,8 +569,8 @@ async fn sync_models_from_upstream(
/// Deactivates models that were previously marked offline and are not in any active sync. /// Deactivates models that were previously marked offline and are not in any active sync.
/// These are manually added models that are no longer needed. /// These are manually added models that are no longer needed.
async fn deactivate_orphaned_models(db: &AppDatabase) -> Result<i64, AppError> { async fn deactivate_orphaned_models(db: &AppDatabase) -> Result<i64, AppError> {
use models::agents::model::Entity as MEntity;
use models::agents::model::Column as MCol; use models::agents::model::Column as MCol;
use models::agents::model::Entity as MEntity;
let now = Utc::now(); let now = Utc::now();
let updated = MEntity::update_many() let updated = MEntity::update_many()

View File

@ -1,5 +1,5 @@
use crate::error::AppError;
use crate::AppService; use crate::AppService;
use crate::error::AppError;
use argon2::password_hash::{PasswordHasher, SaltString}; use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::{Argon2, PasswordHash, PasswordVerifier}; use argon2::{Argon2, PasswordHash, PasswordVerifier};
use models::users::{user_activity_log, user_password}; use models::users::{user_activity_log, user_password};

View File

@ -209,7 +209,10 @@ impl AppService {
let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default()); let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default());
let new_password_hash = Argon2::default() let new_password_hash = Argon2::default()
.hash_password(params.new_password.as_bytes(), Salt::from_b64(&*salt.to_string())?) .hash_password(
params.new_password.as_bytes(),
Salt::from_b64(&*salt.to_string())?,
)
.map_err(|_| AppError::PasswordHashError("hash failed".to_string()))? .map_err(|_| AppError::PasswordHashError("hash failed".to_string()))?
.to_string(); .to_string();
@ -218,11 +221,17 @@ impl AppService {
let mut active_password: user_password::ActiveModel = user_password.into(); let mut active_password: user_password::ActiveModel = user_password.into();
active_password.password_hash = Set(new_password_hash); active_password.password_hash = Set(new_password_hash);
active_password.password_salt = Set(Some(salt.to_string())); active_password.password_salt = Set(Some(salt.to_string()));
active_password.update(&txn).await.map_err(|_| AppError::TxnError)?; active_password
.update(&txn)
.await
.map_err(|_| AppError::TxnError)?;
let mut used_token: user_password_reset::ActiveModel = reset_token.clone().into(); let mut used_token: user_password_reset::ActiveModel = reset_token.clone().into();
used_token.used = Set(true); used_token.used = Set(true);
used_token.update(&txn).await.map_err(|_| AppError::TxnError)?; used_token
.update(&txn)
.await
.map_err(|_| AppError::TxnError)?;
let _ = user_activity_log::ActiveModel { let _ = user_activity_log::ActiveModel {
user_uid: Set(Some(reset_token.user_uid)), user_uid: Set(Some(reset_token.user_uid)),
@ -251,7 +260,10 @@ impl AppService {
.await .await
.map_err(|_| AppError::UserNotFound)?; .map_err(|_| AppError::UserNotFound)?;
tracing::info!(count = result.rows_affected, "Expired password reset tokens cleaned up"); tracing::info!(
count = result.rows_affected,
"Expired password reset tokens cleaned up"
);
Ok(result.rows_affected) Ok(result.rows_affected)
} }
} }

View File

@ -6,8 +6,8 @@ use hkdf::Hkdf;
use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey}; use rsa::pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey};
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey}; use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::Sha256;
use session::Session; use session::Session;
use sha2::Sha256;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct RsaResponse { pub struct RsaResponse {
@ -21,12 +21,16 @@ impl AppService {
/// Derive a ChaCha20-Poly1305 encryption key from APP_SESSION_SECRET via HKDF-SHA256. /// Derive a ChaCha20-Poly1305 encryption key from APP_SESSION_SECRET via HKDF-SHA256.
fn derive_rsa_encryption_key(&self) -> [u8; 32] { fn derive_rsa_encryption_key(&self) -> [u8; 32] {
let secret = self.config.env.get("APP_SESSION_SECRET") let secret = self
.config
.env
.get("APP_SESSION_SECRET")
.map(|s| s.as_str()) .map(|s| s.as_str())
.expect("APP_SESSION_SECRET must be set in production. Do not use fallback keys."); .expect("APP_SESSION_SECRET must be set in production. Do not use fallback keys.");
let hk = Hkdf::<Sha256>::new(Some(b"rsa-session-encryption"), secret.as_bytes()); let hk = Hkdf::<Sha256>::new(Some(b"rsa-session-encryption"), secret.as_bytes());
let mut okm = [0u8; 32]; let mut okm = [0u8; 32];
hk.expand(b"rsa-private-key-aead", &mut okm).expect("HKDF expand within hash length"); hk.expand(b"rsa-private-key-aead", &mut okm)
.expect("HKDF expand within hash length");
okm okm
} }
@ -37,7 +41,8 @@ impl AppService {
.expect("32-byte key is valid for ChaCha20Poly1305"); .expect("32-byte key is valid for ChaCha20Poly1305");
let nonce_bytes: [u8; 12] = rand::random(); let nonce_bytes: [u8; 12] = rand::random();
let nonce = chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes); let nonce = chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, plaintext.as_bytes()) let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|_| AppError::RsaGenerationError)?; .map_err(|_| AppError::RsaGenerationError)?;
let mut combined = nonce_bytes.to_vec(); let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext); combined.extend_from_slice(&ciphertext);
@ -55,22 +60,34 @@ impl AppService {
if combined.len() < 12 { if combined.len() < 12 {
return Err(AppError::RsaDecodeError); return Err(AppError::RsaDecodeError);
} }
let nonce = chacha20poly1305::aead::generic_array::GenericArray::from_slice(&combined[..12]); let nonce =
let plaintext = cipher.decrypt(nonce, &combined[12..]) chacha20poly1305::aead::generic_array::GenericArray::from_slice(&combined[..12]);
let plaintext = cipher
.decrypt(nonce, &combined[12..])
.map_err(|_| AppError::RsaDecodeError)?; .map_err(|_| AppError::RsaDecodeError)?;
Ok(String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError)?) Ok(String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError)?)
} }
pub async fn auth_rsa(&self, context: &Session) -> Result<RsaResponse, AppError> { pub async fn auth_rsa(&self, context: &Session) -> Result<RsaResponse, AppError> {
if context.get::<String>(Self::RSA_PRIVATE_KEY).ok().flatten().is_some() if context
&& context.get::<String>(Self::RSA_PUBLIC_KEY).ok().flatten().is_some() .get::<String>(Self::RSA_PRIVATE_KEY)
.ok()
.flatten()
.is_some()
&& context
.get::<String>(Self::RSA_PUBLIC_KEY)
.ok()
.flatten()
.is_some()
{ {
let pub_pem = context let pub_pem = context
.get::<String>(Self::RSA_PUBLIC_KEY) .get::<String>(Self::RSA_PUBLIC_KEY)
.ok() .ok()
.flatten() .flatten()
.expect("checked above"); .expect("checked above");
return Ok(RsaResponse { public_key: pub_pem }); return Ok(RsaResponse {
public_key: pub_pem,
});
} }
let mut rng = rsa::rand_core::OsRng; let mut rng = rsa::rand_core::OsRng;

View File

@ -1,4 +1,4 @@
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
@ -210,7 +210,8 @@ impl AppService {
.ok_or(AppError::TwoFactorNotSetup)? .ok_or(AppError::TwoFactorNotSetup)?
.into(); .into();
active_model.backup_codes = Set(serde_json::json!(Self::hash_backup_codes(&backup_codes))); active_model.backup_codes =
Set(serde_json::json!(Self::hash_backup_codes(&backup_codes)));
active_model.updated_at = Set(chrono::Utc::now()); active_model.updated_at = Set(chrono::Utc::now());
active_model.update(&self.db).await?; active_model.update(&self.db).await?;
@ -363,7 +364,11 @@ impl AppService {
fn hash_backup_code(code: &str) -> String { fn hash_backup_code(code: &str) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(code.as_bytes()); hasher.update(code.as_bytes());
hasher.finalize().iter().map(|b| format!("{:02x}", b)).collect::<String>() hasher
.finalize()
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()
} }
fn hash_backup_codes(codes: &[String]) -> Vec<String> { fn hash_backup_codes(codes: &[String]) -> Vec<String> {
@ -387,7 +392,7 @@ impl AppService {
} }
fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> { fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> {
use hmac::{Hmac, Mac, KeyInit}; use hmac::{Hmac, KeyInit, Mac};
use sha1::Sha1; use sha1::Sha1;
let secret_bytes = self.decode_base32(secret)?; let secret_bytes = self.decode_base32(secret)?;

View File

@ -32,11 +32,7 @@ pub enum AccessLevel {
/// with the given `access_visibility` setting. /// with the given `access_visibility` setting.
/// ///
/// Hierarchical override: higher roles always see lower roles' chats. /// Hierarchical override: higher roles always see lower roles' chats.
pub fn can_view( pub fn can_view(creator_role: MemberRole, user_role: MemberRole, access_visibility: &str) -> bool {
creator_role: MemberRole,
user_role: MemberRole,
access_visibility: &str,
) -> bool {
// Owner sees everything // Owner sees everything
if user_role == MemberRole::Owner { if user_role == MemberRole::Owner {
return true; return true;
@ -59,11 +55,7 @@ pub fn can_view(
} }
/// Check whether `user_role` can ASK (send messages) in a conversation. /// Check whether `user_role` can ASK (send messages) in a conversation.
pub fn can_ask( pub fn can_ask(creator_role: MemberRole, user_role: MemberRole, can_ask_setting: &str) -> bool {
creator_role: MemberRole,
user_role: MemberRole,
can_ask_setting: &str,
) -> bool {
// Same hierarchy as viewing // Same hierarchy as viewing
if user_role == MemberRole::Owner { if user_role == MemberRole::Owner {
return true; return true;
@ -148,7 +140,8 @@ pub async fn check_conversation_access(
} }
// Get creator's role // Get creator's role
let Some(creator_role) = resolve_project_role(db, project_id, conversation.user_id).await? else { let Some(creator_role) = resolve_project_role(db, project_id, conversation.user_id).await?
else {
return Ok(AccessLevel::Denied); return Ok(AccessLevel::Denied);
}; };

View File

@ -1,7 +1,10 @@
use crate::error::AppError;
use models::ai::{AiConversation, ai_conversation}; use models::ai::{AiConversation, ai_conversation};
use models::projects::MemberRole; use models::projects::MemberRole;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set}; use sea_orm::{
use crate::error::AppError; ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, Set,
};
use uuid::Uuid; use uuid::Uuid;
use crate::AppService; use crate::AppService;
@ -26,9 +29,8 @@ impl AppService {
if c.user_id != user_id { if c.user_id != user_id {
// For project conversations, check access control // For project conversations, check access control
if c.project_id.is_some() { if c.project_id.is_some() {
let access = super::access::check_conversation_access( let access =
&self.db, &c, user_id, super::access::check_conversation_access(&self.db, &c, user_id).await?;
).await?;
if access != super::AccessLevel::Denied { if access != super::AccessLevel::Denied {
return Ok(c); return Ok(c);
} }
@ -48,9 +50,7 @@ impl AppService {
return Ok(c); return Ok(c);
} }
if c.project_id.is_some() { if c.project_id.is_some() {
let access = super::access::check_conversation_access( let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
&self.db, &c, user_id,
).await?;
if access != super::AccessLevel::Denied { if access != super::AccessLevel::Denied {
return Ok(c); return Ok(c);
} }
@ -58,6 +58,37 @@ impl AppService {
Err(AppError::PermissionDenied) Err(AppError::PermissionDenied)
} }
pub async fn find_conversation_full_access(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id == user_id {
return Ok(c);
}
if c.project_id.is_some() {
let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
if access == super::AccessLevel::Full {
return Ok(c);
}
}
Err(AppError::PermissionDenied)
}
pub async fn find_conversation_creator(
&self,
conversation_id: Uuid,
user_id: Uuid,
) -> Result<ai_conversation::Model, AppError> {
let c = self.find_conversation(conversation_id).await?;
if c.user_id == user_id {
Ok(c)
} else {
Err(AppError::PermissionDenied)
}
}
pub async fn create_conversation( pub async fn create_conversation(
&self, &self,
user_id: Uuid, user_id: Uuid,
@ -81,7 +112,8 @@ impl AppService {
// Auto-increment project_uid // Auto-increment project_uid
let next_uid = self.next_project_chat_uid(pid).await?; let next_uid = self.next_project_chat_uid(pid).await?;
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let conv = ai_conversation::ActiveModel { let conv =
ai_conversation::ActiveModel {
id: Set(Uuid::new_v4()), id: Set(Uuid::new_v4()),
user_id: Set(user_id), user_id: Set(user_id),
project_id: Set(Some(pid)), project_id: Set(Some(pid)),
@ -95,7 +127,9 @@ impl AppService {
is_shared: Set(false), is_shared: Set(false),
message_count: Set(0), message_count: Set(0),
token_usage_total: Set(None), token_usage_total: Set(None),
access_visibility: Set(access_visibility.unwrap_or_else(|| "owner".to_string())), access_visibility: Set(
access_visibility.unwrap_or_else(|| "owner".to_string())
),
can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())), can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())),
project_uid: Set(Some(next_uid)), project_uid: Set(Some(next_uid)),
model_uid: Set(model_uid), model_uid: Set(model_uid),
@ -165,10 +199,9 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
project_id: Option<Uuid>, project_id: Option<Uuid>,
page_size: u64, page_size: u64,
search_query: Option<String>,
) -> Result<Vec<ai_conversation::Model>, AppError> { ) -> Result<Vec<ai_conversation::Model>, AppError> {
let mut query = let mut query = AiConversation::find().order_by_desc(ai_conversation::Column::UpdatedAt);
AiConversation::find()
.order_by_desc(ai_conversation::Column::UpdatedAt);
if let Some(pid) = project_id { if let Some(pid) = project_id {
// For project chats, apply visibility rules // For project chats, apply visibility rules
@ -176,14 +209,25 @@ impl AppService {
match role { match role {
Some(r) => { Some(r) => {
query = query.filter(ai_conversation::Column::ProjectId.eq(pid)); query = query.filter(ai_conversation::Column::ProjectId.eq(pid));
// Filter visible conversations based on role let convs = query
// Owner sees all; Admin sees own + member-visible; Member sees only member-visible + own .paginate(self.db.reader(), page_size.saturating_mul(4).max(page_size))
if !matches!(r, MemberRole::Owner) { .fetch_page(0)
// Not owner, so apply visibility filter: .await?;
// - Own conversations let mut visible = Vec::new();
// - OR access_visibility = "member" (for member) or "admin"/"member" (for admin) for conv in convs {
// - OR hierarchical: admin sees member creator's chats if conv.user_id == user_id || matches!(r, MemberRole::Owner) {
visible.push(conv);
} else if super::access::check_conversation_access(&self.db, &conv, user_id)
.await?
!= super::AccessLevel::Denied
{
visible.push(conv);
} }
if visible.len() >= page_size as usize {
break;
}
}
return Ok(visible);
} }
None => { None => {
// Not a project member — only show own chats // Not a project member — only show own chats
@ -199,7 +243,17 @@ impl AppService {
.filter(ai_conversation::Column::ProjectId.is_null()); .filter(ai_conversation::Column::ProjectId.is_null());
} }
let convs = query.paginate(self.db.reader(), page_size).fetch_page(0).await?; // Apply search filter if provided
if let Some(ref q) = search_query {
if !q.is_empty() {
query = query.filter(ai_conversation::Column::Title.contains(q));
}
}
let convs = query
.paginate(self.db.reader(), page_size)
.fetch_page(0)
.await?;
Ok(convs) Ok(convs)
} }
@ -217,7 +271,9 @@ impl AppService {
model_uid: Option<Uuid>, model_uid: Option<Uuid>,
model_name: Option<String>, model_name: Option<String>,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_creator(conversation_id, user_id)
.await?;
let mut active: ai_conversation::ActiveModel = c.into(); let mut active: ai_conversation::ActiveModel = c.into();
if let Some(t) = title { if let Some(t) = title {
@ -254,7 +310,8 @@ impl AppService {
conversation_id: Uuid, conversation_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_creator(conversation_id, user_id)
.await?;
AiConversation::delete_by_id(conversation_id) AiConversation::delete_by_id(conversation_id)
.exec(self.db.writer()) .exec(self.db.writer())
.await?; .await?;

View File

@ -1,51 +1,149 @@
use models::ai::{AiMessage, ai_conversation, ai_message, ai_message_fork};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::error::AppError; use crate::error::AppError;
use models::ai::{AiMessage, ai_conversation, ai_message, ai_message_fork};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use uuid::Uuid; use uuid::Uuid;
use crate::AppService; use crate::AppService;
impl AppService { impl AppService {
pub async fn fork_message( /// Fork a conversation from a specific message: creates a new conversation
/// with all messages up to and including the source message, then copies
/// the source message content as a new user message in the forked conversation.
///
/// Returns the new conversation.
pub async fn fork_conversation_from_message(
&self, &self,
conversation_id: Uuid,
user_id: Uuid, user_id: Uuid,
conversation_id: Uuid,
source_message_id: Uuid, source_message_id: Uuid,
target_message_id: Uuid, ) -> Result<ai_conversation::Model, AppError> {
) -> Result<ai_message_fork::Model, AppError> { let c = self
let c = self.find_conversation_owned(conversation_id, user_id).await?; .find_conversation_owned(conversation_id, user_id)
.await?;
// Mark source as fork origin // Verify source message exists in this conversation
let mut source: ai_message::ActiveModel = AiMessage::find_by_id(source_message_id) let source_msg = AiMessage::find_by_id(source_message_id)
.one(self.db.reader()) .one(self.db.reader())
.await? .await?
.ok_or_else(|| AppError::NotFound("message".into()))? .ok_or_else(|| AppError::NotFound("message".into()))?;
.into();
source.is_fork_origin = Set(true);
source.update(self.db.writer()).await?;
// Create fork record if source_msg.conversation_id != conversation_id {
let fork_record = ai_message_fork::ActiveModel { return Err(AppError::NotFound("message not in conversation".into()));
id: Set(Uuid::new_v4()), }
conversation_id: Set(Some(conversation_id)),
source_message_id: Set(source_message_id), // Get all messages in the conversation up to the source message
fork_message_id: Set(target_message_id), let all_messages = AiMessage::find()
.filter(ai_message::Column::ConversationId.eq(conversation_id))
.filter(ai_message::Column::IsLatest.eq(true))
.order_by_asc(ai_message::Column::CreatedAt)
.all(self.db.reader())
.await?;
// Find the index of the source message in the ordered list
let source_idx = all_messages
.iter()
.position(|m| m.id == source_message_id)
.ok_or_else(|| AppError::NotFound("source message not found in conversation".into()))?;
// Messages to copy: up to and including the source message
let messages_to_copy = &all_messages[..=source_idx];
// Create new conversation
let new_conv_id = Uuid::new_v4();
let now = chrono::Utc::now();
let new_conv = ai_conversation::ActiveModel {
id: Set(new_conv_id),
user_id: Set(user_id),
project_id: Set(c.project_id),
scope: Set(c.scope.clone()),
title: Set(c.title.clone().map(|t| format!("Fork: {}", t))),
model: Set(c.model.clone()),
model_config: Set(c.model_config.clone()),
status: Set("active".to_string()),
root_message_id: Set(None),
fork_count: Set(0),
is_shared: Set(false),
message_count: Set(0),
token_usage_total: Set(None),
access_visibility: Set(c.access_visibility.clone()),
can_ask: Set(c.can_ask.clone()),
project_uid: Set(c.project_uid),
model_uid: Set(c.model_uid),
model_name: Set(c.model_name.clone()),
created_at: Set(now),
updated_at: Set(now),
}
.insert(self.db.writer())
.await?;
// Copy messages to the new conversation
let mut prev_msg_id: Option<Uuid> = None;
let mut msg_count = 0;
for msg in messages_to_copy {
let new_msg_id = Uuid::now_v7();
ai_message::ActiveModel {
id: Set(new_msg_id),
conversation_id: Set(new_conv_id),
parent_message_id: Set(prev_msg_id),
role: Set(msg.role.clone()),
content: Set(msg.content.clone()),
model: Set(msg.model.clone()),
is_fork_origin: Set(false),
stop_reason: Set(None),
input_tokens: Set(None),
output_tokens: Set(None),
latency_ms: Set(None),
metadata: Set(None),
room_id: Set(None),
version_group_id: Set(Some(new_msg_id)),
version_number: Set(1),
is_latest: Set(true),
created_at: Set(chrono::Utc::now()), created_at: Set(chrono::Utc::now()),
} }
.insert(self.db.writer()) .insert(self.db.writer())
.await?; .await?;
// Update conversation fork_count prev_msg_id = Some(new_msg_id);
let fork_count = c.fork_count; msg_count += 1;
let root_msg_id = c.root_message_id;
let mut updated: ai_conversation::ActiveModel = c.into();
updated.fork_count = Set(fork_count + 1);
if root_msg_id.is_none() {
updated.root_message_id = Set(Some(target_message_id));
} }
// Update conversation message count
let mut updated: ai_conversation::ActiveModel = new_conv.clone().into();
updated.message_count = Set(msg_count);
updated.updated_at = Set(chrono::Utc::now());
updated.update(self.db.writer()).await?; updated.update(self.db.writer()).await?;
Ok(fork_record) // Mark source message as fork origin
let mut source: ai_message::ActiveModel = source_msg.into();
source.is_fork_origin = Set(true);
source.update(self.db.writer()).await?;
// Create fork record
ai_message_fork::ActiveModel {
id: Set(Uuid::new_v4()),
conversation_id: Set(Some(new_conv_id)),
source_message_id: Set(source_message_id),
fork_message_id: Set(prev_msg_id.unwrap_or(source_message_id)),
created_at: Set(chrono::Utc::now()),
}
.insert(self.db.writer())
.await?;
// Update original conversation fork_count
let original_fork_count = c.fork_count;
let mut orig_updated: ai_conversation::ActiveModel = c.into();
orig_updated.fork_count = Set(original_fork_count + 1);
orig_updated.updated_at = Set(chrono::Utc::now());
orig_updated.update(self.db.writer()).await?;
// Return the new conversation
let new_conv = ai_conversation::Entity::find_by_id(new_conv_id)
.one(self.db.reader())
.await?
.ok_or_else(|| AppError::NotFound("conversation".into()))?;
Ok(new_conv)
} }
/// List all fork records for a message within a conversation. /// List all fork records for a message within a conversation.
@ -55,11 +153,11 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
source_message_id: Uuid, source_message_id: Uuid,
) -> Result<Vec<ai_message_fork::Model>, AppError> { ) -> Result<Vec<ai_message_fork::Model>, AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_owned(conversation_id, user_id)
.await?;
let forks = ai_message_fork::Entity::find() let forks = ai_message_fork::Entity::find()
.filter(ai_message_fork::Column::SourceMessageId.eq(source_message_id)) .filter(ai_message_fork::Column::SourceMessageId.eq(source_message_id))
.filter(ai_message_fork::Column::ConversationId.eq(conversation_id))
.all(self.db.reader()) .all(self.db.reader())
.await?; .await?;

View File

@ -1,6 +1,8 @@
use models::ai::{AiMessage, ai_conversation, ai_message};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QuerySelect, QueryFilter, QueryOrder, Set};
use crate::error::AppError; use crate::error::AppError;
use models::ai::{AiMessage, ai_conversation, ai_message};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use uuid::Uuid; use uuid::Uuid;
use crate::AppService; use crate::AppService;
@ -22,7 +24,8 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
limit: u64, limit: u64,
) -> Result<Vec<ai_message::Model>, AppError> { ) -> Result<Vec<ai_message::Model>, AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_owned(conversation_id, user_id)
.await?;
// Only return latest versions for each version group // Only return latest versions for each version group
let msgs = AiMessage::find() let msgs = AiMessage::find()
@ -48,13 +51,24 @@ impl AppService {
metadata: Option<serde_json::Value>, metadata: Option<serde_json::Value>,
room_id: Option<Uuid>, room_id: Option<Uuid>,
) -> Result<ai_message::Model, AppError> { ) -> Result<ai_message::Model, AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_full_access(conversation_id, user_id)
.await?;
if role != "user" {
return Err(AppError::PermissionDenied);
}
if let Some(parent_id) = parent_message_id {
let parent = self.find_message(parent_id).await?;
if parent.conversation_id != conversation_id || !parent.is_latest {
return Err(AppError::NotFound("parent message".into()));
}
}
// For project chats, non-owner must also have can_ask permission // For project chats, non-owner must also have can_ask permission
if c.user_id != user_id && c.project_id.is_some() { if c.user_id != user_id && c.project_id.is_some() {
let access = super::access::check_conversation_access( let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
&self.db, &c, user_id,
).await?;
if access != super::AccessLevel::Full { if access != super::AccessLevel::Full {
return Err(AppError::PermissionDenied); return Err(AppError::PermissionDenied);
} }
@ -99,7 +113,8 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
message_id: Uuid, message_id: Uuid,
) -> Result<ai_message::Model, AppError> { ) -> Result<ai_message::Model, AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_owned(conversation_id, user_id)
.await?;
let msg = self.find_message(message_id).await?; let msg = self.find_message(message_id).await?;
if msg.conversation_id != conversation_id { if msg.conversation_id != conversation_id {
@ -114,26 +129,34 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
message_id: Uuid, message_id: Uuid,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_full_access(conversation_id, user_id)
.await?;
// For project chats, non-owner must also have can_ask permission // For project chats, non-owner must also have can_ask permission
if c.user_id != user_id && c.project_id.is_some() { if c.user_id != user_id && c.project_id.is_some() {
let access = super::access::check_conversation_access( let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
&self.db, &c, user_id,
).await?;
if access != super::AccessLevel::Full { if access != super::AccessLevel::Full {
return Err(AppError::PermissionDenied); return Err(AppError::PermissionDenied);
} }
} }
let mut msg: ai_message::ActiveModel = AiMessage::find_by_id(message_id) let existing = AiMessage::find_by_id(message_id)
.one(self.db.reader()) .one(self.db.reader())
.await? .await?
.ok_or_else(|| AppError::NotFound("message".into()))? .ok_or_else(|| AppError::NotFound("message".into()))?;
.into(); if existing.conversation_id != conversation_id {
return Err(AppError::NotFound("message".into()));
}
let mut msg: ai_message::ActiveModel = existing.into();
msg.stop_reason = Set(Some("stop".to_string())); msg.stop_reason = Set(Some("stop".to_string()));
msg.update(self.db.writer()).await?; msg.update(self.db.writer()).await?;
// Signal cancellation to the active stream
self.cache.set_chat_stream_cancelled(conversation_id).await;
Ok(()) Ok(())
} }
@ -147,13 +170,13 @@ impl AppService {
message_id: Uuid, message_id: Uuid,
new_content: String, new_content: String,
) -> Result<ai_message::Model, AppError> { ) -> Result<ai_message::Model, AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_full_access(conversation_id, user_id)
.await?;
// For project chats, non-owner must also have can_ask permission // For project chats, non-owner must also have can_ask permission
if c.user_id != user_id && c.project_id.is_some() { if c.user_id != user_id && c.project_id.is_some() {
let access = super::access::check_conversation_access( let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
&self.db, &c, user_id,
).await?;
if access != super::AccessLevel::Full { if access != super::AccessLevel::Full {
return Err(AppError::PermissionDenied); return Err(AppError::PermissionDenied);
} }
@ -244,7 +267,8 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
message_id: Uuid, message_id: Uuid,
) -> Result<Vec<ai_message::Model>, AppError> { ) -> Result<Vec<ai_message::Model>, AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_owned(conversation_id, user_id)
.await?;
let msg = self.find_message(message_id).await?; let msg = self.find_message(message_id).await?;
if msg.conversation_id != conversation_id { if msg.conversation_id != conversation_id {
@ -271,7 +295,9 @@ impl AppService {
message_id: Uuid, message_id: Uuid,
target_version_number: i32, target_version_number: i32,
) -> Result<ai_message::Model, AppError> { ) -> Result<ai_message::Model, AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_full_access(conversation_id, user_id)
.await?;
if c.user_id != user_id { if c.user_id != user_id {
return Err(AppError::PermissionDenied); return Err(AppError::PermissionDenied);
@ -316,7 +342,8 @@ impl AppService {
} }
// Find the target version and mark it as latest // Find the target version and mark it as latest
let target = all_versions.iter() let target = all_versions
.iter()
.find(|v| v.version_number == target_version_number) .find(|v| v.version_number == target_version_number)
.ok_or_else(|| AppError::NotFound("version".into()))?; .ok_or_else(|| AppError::NotFound("version".into()))?;
@ -357,13 +384,13 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
message_id: Uuid, message_id: Uuid,
) -> Result<ai_message::Model, AppError> { ) -> Result<ai_message::Model, AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_full_access(conversation_id, user_id)
.await?;
// For project chats, non-owner must also have can_ask permission // For project chats, non-owner must also have can_ask permission
if c.user_id != user_id && c.project_id.is_some() { if c.user_id != user_id && c.project_id.is_some() {
let access = super::access::check_conversation_access( let access = super::access::check_conversation_access(&self.db, &c, user_id).await?;
&self.db, &c, user_id,
).await?;
if access != super::AccessLevel::Full { if access != super::AccessLevel::Full {
return Err(AppError::PermissionDenied); return Err(AppError::PermissionDenied);
} }
@ -451,7 +478,8 @@ impl AppService {
user_id: Uuid, user_id: Uuid,
parent_message_id: Uuid, parent_message_id: Uuid,
) -> Result<Vec<ai_message::Model>, AppError> { ) -> Result<Vec<ai_message::Model>, AppError> {
self.find_conversation_owned(conversation_id, user_id).await?; self.find_conversation_owned(conversation_id, user_id)
.await?;
let msgs = AiMessage::find() let msgs = AiMessage::find()
.filter(ai_message::Column::ConversationId.eq(conversation_id)) .filter(ai_message::Column::ConversationId.eq(conversation_id))

View File

@ -4,4 +4,7 @@ pub mod fork;
pub mod message; pub mod message;
pub mod share; pub mod share;
pub use access::{AccessLevel, can_view, can_ask, can_create, check_access, check_conversation_access, resolve_project_role}; pub use access::{
AccessLevel, can_ask, can_create, can_view, check_access, check_conversation_access,
resolve_project_role,
};

View File

@ -1,6 +1,6 @@
use crate::error::AppError;
use models::ai::{AiSharedConversation, ai_conversation, ai_shared_conversation}; use models::ai::{AiSharedConversation, ai_conversation, ai_shared_conversation};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::error::AppError;
use uuid::Uuid; use uuid::Uuid;
use crate::AppService; use crate::AppService;
@ -11,7 +11,9 @@ impl AppService {
conversation_id: Uuid, conversation_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> Result<(ai_shared_conversation::Model, String), AppError> { ) -> Result<(ai_shared_conversation::Model, String), AppError> {
let c = self.find_conversation_owned(conversation_id, user_id).await?; let c = self
.find_conversation_owned(conversation_id, user_id)
.await?;
let share_token = Uuid::new_v4().to_string().replace("-", ""); let share_token = Uuid::new_v4().to_string().replace("-", "");
let now = chrono::Utc::now(); let now = chrono::Utc::now();

View File

@ -820,7 +820,8 @@ impl AppService {
// Cache miss: compute count and cache it. // Cache miss: compute count and cache it.
let computed = git_spawn!(repo, domain -> { let computed = git_spawn!(repo, domain -> {
domain.commit_count(from.as_deref(), to.as_deref()) domain.commit_count(from.as_deref(), to.as_deref())
}).unwrap_or(0); })
.unwrap_or(0);
if let Err(e) = conn if let Err(e) = conn
.set_ex::<String, String, ()>( .set_ex::<String, String, ()>(
total_cache_key.clone(), total_cache_key.clone(),
@ -838,7 +839,8 @@ impl AppService {
// No cache: compute directly. // No cache: compute directly.
git_spawn!(repo, domain -> { git_spawn!(repo, domain -> {
domain.commit_count(from.as_deref(), to.as_deref()) domain.commit_count(from.as_deref(), to.as_deref())
}).unwrap_or(0) })
.unwrap_or(0)
}; };
let total_pages = if total == 0 { let total_pages = if total == 0 {

View File

@ -38,7 +38,11 @@ impl AppService {
}) })
} }
pub async fn git_open_bare(&self, path: String, ctx: &Session) -> Result<GitInitResponse, AppError> { pub async fn git_open_bare(
&self,
path: String,
ctx: &Session,
) -> Result<GitInitResponse, AppError> {
let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let _user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let domain = git::GitDomain::open_bare(&path).map_err(AppError::from)?; let domain = git::GitDomain::open_bare(&path).map_err(AppError::from)?;
Ok(GitInitResponse { Ok(GitInitResponse {

View File

@ -8,11 +8,7 @@ use session::Session;
/// Delete all cached ref list entries for a given namespace/repo. /// Delete all cached ref list entries for a given namespace/repo.
/// Redis DEL does not support glob patterns, so we SCAN and delete each key. /// Redis DEL does not support glob patterns, so we SCAN and delete each key.
async fn invalidate_ref_cache( async fn invalidate_ref_cache(cache: &db::cache::AppCache, namespace: &str, repo_name: &str) {
cache: &db::cache::AppCache,
namespace: &str,
repo_name: &str,
) {
let prefix = format!("git:ref:list:{}:{}:", namespace, repo_name); let prefix = format!("git:ref:list:{}:{}:", namespace, repo_name);
if let Ok(mut conn) = cache.conn().await { if let Ok(mut conn) = cache.conn().await {
let pattern = format!("{}*", prefix); let pattern = format!("{}*", prefix);

View File

@ -478,14 +478,16 @@ impl AppService {
active.update(&txn).await?; active.update(&txn).await?;
txn.commit().await?; txn.commit().await?;
self.room.publish_room_event( self.room
.publish_room_event(
repo.project, repo.project,
room::RoomEventType::RepoUpdated, room::RoomEventType::RepoUpdated,
None, None,
None, None,
None, None,
None, None,
).await; )
.await;
Ok(()) Ok(())
} }

View File

@ -399,10 +399,8 @@ impl AppService {
let info = tokio::task::spawn_blocking(move || { let info = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?; let domain = git::GitDomain::from_model(repo)?;
let tagger = git::CommitSignature { let tagger = git::CommitSignature {
name: tagger_name name: tagger_name.unwrap_or_else(|| "Anonymous".to_string()),
.unwrap_or_else(|| "Anonymous".to_string()), email: tagger_email.unwrap_or_else(|| "anonymous@example.com".to_string()),
email: tagger_email
.unwrap_or_else(|| "anonymous@example.com".to_string()),
time_secs: chrono::Utc::now().timestamp(), time_secs: chrono::Utc::now().timestamp(),
offset_minutes: 0, offset_minutes: 0,
}; };

View File

@ -12,7 +12,9 @@ pub struct TreeGetQuery {
#[serde(default = "default_tree_limit")] #[serde(default = "default_tree_limit")]
pub limit: usize, pub limit: usize,
} }
fn default_tree_limit() -> usize { 1000 } fn default_tree_limit() -> usize {
1000
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct TreeEntryQuery { pub struct TreeEntryQuery {
@ -184,8 +186,11 @@ impl AppService {
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?; .map_err(AppError::from)?;
let response: Vec<TreeEntryResponse> = let response: Vec<TreeEntryResponse> = entries
entries.into_iter().take(query.limit).map(TreeEntryResponse::from).collect(); .into_iter()
.take(query.limit)
.map(TreeEntryResponse::from)
.collect();
if let Ok(mut conn) = self.cache.conn().await { if let Ok(mut conn) = self.cache.conn().await {
if let Err(e) = conn if let Err(e) = conn

View File

@ -1,8 +1,8 @@
use crate::error::AppError;
use crate::AppService; use crate::AppService;
use crate::error::AppError;
use chrono::Utc; use chrono::Utc;
use models::issues::{ use models::issues::{
issue, issue_assignee, issue_comment, issue_label, issue_repo, issue_subscriber, IssueState, IssueState, issue, issue_assignee, issue_comment, issue_label, issue_repo, issue_subscriber,
}; };
use models::projects::project_members; use models::projects::project_members;
use models::users::user; use models::users::user;

View File

@ -25,7 +25,10 @@ fn default_color_for_label(name: &str) -> String {
let lower = name.to_lowercase(); let lower = name.to_lowercase();
if lower.contains("bug") || lower.contains("critical") || lower.contains("security") { if lower.contains("bug") || lower.contains("critical") || lower.contains("security") {
"ef4444".to_string() "ef4444".to_string()
} else if lower.contains("enhancement") || lower.contains("feature") || lower.contains("improvement") { } else if lower.contains("enhancement")
|| lower.contains("feature")
|| lower.contains("improvement")
{
"22c55e".to_string() "22c55e".to_string()
} else if lower.contains("documentation") || lower.contains("docs") { } else if lower.contains("documentation") || lower.contains("docs") {
"3b82f6".to_string() "3b82f6".to_string()

View File

@ -16,7 +16,10 @@ pub use comment::{
pub use issue::{ pub use issue::{
IssueCreateRequest, IssueListResponse, IssueResponse, IssueSummaryResponse, IssueUpdateRequest, IssueCreateRequest, IssueListResponse, IssueResponse, IssueSummaryResponse, IssueUpdateRequest,
}; };
pub use label::{CreateLabelRequest, IssueAddLabelRequest, IssueAddLabelsByNamesRequest, IssueLabelResponse, LabelResponse}; pub use label::{
CreateLabelRequest, IssueAddLabelRequest, IssueAddLabelsByNamesRequest, IssueLabelResponse,
LabelResponse,
};
pub use pull_request::{IssueLinkPullRequestRequest, IssuePullRequestResponse}; pub use pull_request::{IssueLinkPullRequestRequest, IssuePullRequestResponse};
pub use reaction::{ReactionAddRequest, ReactionListResponse, ReactionResponse}; pub use reaction::{ReactionAddRequest, ReactionListResponse, ReactionResponse};
pub use repo::{IssueLinkRepoRequest, IssueRepoResponse}; pub use repo::{IssueLinkRepoRequest, IssueRepoResponse};

View File

@ -3,17 +3,17 @@ use std::sync::Arc;
use ::agent::chat::ChatService; use ::agent::chat::ChatService;
use ::agent::client::AiClientConfig; use ::agent::client::AiClientConfig;
use ::agent::tool::ToolRegistry; use ::agent::tool::ToolRegistry;
use ::agent::{new_embed_client, EmbedService, TaskService}; use ::agent::{EmbedService, TaskService, new_embed_client};
use avatar::AppAvatar; use avatar::AppAvatar;
use config::AppConfig; use config::AppConfig;
use db::cache::AppCache; use db::cache::AppCache;
use db::database::AppDatabase; use db::database::AppDatabase;
use email::AppEmail; use email::AppEmail;
use queue::{ use queue::{
start_email_worker, EmailEnvelope, EmailSendFn, EmailSendFut, MessageProducer, NatsClient, EmailEnvelope, EmailSendFn, EmailSendFut, MessageProducer, NatsClient, start_email_worker,
}; };
use room::metrics::RoomMetrics;
use room::RoomService; use room::RoomService;
use room::metrics::RoomMetrics;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use ws_token::WsTokenService; use ws_token::WsTokenService;
@ -147,6 +147,11 @@ impl AppService {
(Ok(api_key), Ok(base_url)) => { (Ok(api_key), Ok(base_url)) => {
tracing::info!(url = %base_url, "AI chat enabled"); tracing::info!(url = %base_url, "AI chat enabled");
let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url); let ai_client_config = AiClientConfig::new(api_key).with_base_url(&base_url);
let compact_service = ::agent::CompactService::new(
db.writer().clone(),
ai_client_config.clone(),
"gpt-4o-mini".to_string(),
);
let mut registry = ToolRegistry::new(); let mut registry = ToolRegistry::new();
fctool::git_tools::register_all(&mut registry); fctool::git_tools::register_all(&mut registry);
fctool::file_tools::register_all(&mut registry); fctool::file_tools::register_all(&mut registry);
@ -154,6 +159,7 @@ impl AppService {
fctool::chat_tools::register_all(&mut registry); fctool::chat_tools::register_all(&mut registry);
let mut chat_svc = ChatService::new() let mut chat_svc = ChatService::new()
.with_ai_client_config(ai_client_config) .with_ai_client_config(ai_client_config)
.with_compact_service(compact_service)
.with_tool_registry(registry); .with_tool_registry(registry);
if let Some(ref es) = embed_service { if let Some(ref es) = embed_service {
chat_svc = chat_svc.with_embed_service((**es).clone()); chat_svc = chat_svc.with_embed_service((**es).clone());

View File

@ -11,7 +11,6 @@ use session::Session;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProjectBillingCurrentResponse { pub struct ProjectBillingCurrentResponse {
pub project_uid: Uuid, pub project_uid: Uuid,
@ -216,7 +215,9 @@ impl AppService {
.await?; .await?;
Ok(BillingErrorsResponse { Ok(BillingErrorsResponse {
list: errors.into_iter().map(|e| BillingErrorItem { list: errors
.into_iter()
.map(|e| BillingErrorItem {
id: e.id, id: e.id,
scope: e.scope, scope: e.scope,
scope_id: e.scope_id, scope_id: e.scope_id,
@ -225,7 +226,8 @@ impl AppService {
details: e.details, details: e.details,
resolved: e.resolved, resolved: e.resolved,
created_at: e.created_at, created_at: e.created_at,
}).collect(), })
.collect(),
}) })
} }
} }

View File

@ -91,7 +91,9 @@ impl AppService {
observability::incr!(observability::PROJECTS_CREATED_TOTAL); observability::incr!(observability::PROJECTS_CREATED_TOTAL);
// Initialize project billing ($20 for first project, $0 otherwise) // Initialize project billing ($20 for first project, $0 otherwise)
if let Err(e) = agent::billing::initialize_project_billing(&self.db, _project.id, user.uid).await { if let Err(e) =
agent::billing::initialize_project_billing(&self.db, _project.id, user.uid).await
{
tracing::warn!(project_id = %_project.id, error = %e, "Failed to initialize project billing — non-critical, continuing"); tracing::warn!(project_id = %_project.id, error = %e, "Failed to initialize project billing — non-critical, continuing");
} }

View File

@ -2,7 +2,7 @@ use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use models::projects::{ use models::projects::{
project, MemberRole, project_audit_log, project_member_invitations, project_members, MemberRole, project, project_audit_log, project_member_invitations, project_members,
}; };
use models::users::{user, user_email}; use models::users::{user, user_email};
use sea_orm::*; use sea_orm::*;
@ -330,7 +330,10 @@ impl AppService {
target_uid, target_uid,
crate::push::PushPayload { crate::push::PushPayload {
title: format!("Project invitation: {}", project.name), title: format!("Project invitation: {}", project.name),
body: format!("{} invited you to join \"{}\" as {:?}", inviter.username, project.name, scope), body: format!(
"{} invited you to join \"{}\" as {:?}",
inviter.username, project.name, scope
),
url: Some(format!("/projects/{}/invitations", project.name)), url: Some(format!("/projects/{}/invitations", project.name)),
icon: None, icon: None,
}, },
@ -341,7 +344,10 @@ impl AppService {
.notification_create(room::NotificationCreateRequest { .notification_create(room::NotificationCreateRequest {
notification_type: room::NotificationType::ProjectInvitation, notification_type: room::NotificationType::ProjectInvitation,
user_id: target_uid, user_id: target_uid,
title: format!("{} invited you to join \"{}\"", inviter.username, project.name), title: format!(
"{} invited you to join \"{}\"",
inviter.username, project.name
),
content: Some(format!("Role: {:?}", scope)), content: Some(format!("Role: {:?}", scope)),
room_id: None, room_id: None,
project_id: project.id, project_id: project.id,

View File

@ -4,8 +4,8 @@ use chrono::Utc;
use models::projects::{MemberRole, project_audit_log, project_members}; use models::projects::{MemberRole, project_audit_log, project_members};
use models::rooms::{room, room_user_state}; use models::rooms::{room, room_user_state};
use models::users::user; use models::users::user;
use sea_orm::*;
use sea_orm::sea_query::OnConflict; use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session::Session; use session::Session;
use utoipa::ToSchema; use utoipa::ToSchema;
@ -148,9 +148,7 @@ impl AppService {
ctx: &Session, ctx: &Session,
) -> Result<GroupedMemberListResponse, AppError> { ) -> Result<GroupedMemberListResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let project = self let project = self.utils_find_project_by_name(project_name).await?;
.utils_find_project_by_name(project_name)
.await?;
let _requester_member = project_members::Entity::find() let _requester_member = project_members::Entity::find()
.filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::Project.eq(project.id))
@ -195,7 +193,8 @@ impl AppService {
}) })
.collect(); .collect();
let mut groups: std::collections::BTreeMap<String, Vec<MemberInfo>> = std::collections::BTreeMap::new(); let mut groups: std::collections::BTreeMap<String, Vec<MemberInfo>> =
std::collections::BTreeMap::new();
for m in member_infos { for m in member_infos {
let role_str = m.scope.to_string(); let role_str = m.scope.to_string();
groups.entry(role_str).or_default().push(m); groups.entry(role_str).or_default().push(m);
@ -208,8 +207,14 @@ impl AppService {
.collect(); .collect();
sorted_groups.sort_by(|a, b| { sorted_groups.sort_by(|a, b| {
let pa = role_priority.iter().position(|&r| r == a.role).unwrap_or(99); let pa = role_priority
let pb = role_priority.iter().position(|&r| r == b.role).unwrap_or(99); .iter()
.position(|&r| r == a.role)
.unwrap_or(99);
let pb = role_priority
.iter()
.position(|&r| r == b.role)
.unwrap_or(99);
pa.cmp(&pb) pa.cmp(&pb)
}); });

View File

@ -1,7 +1,7 @@
use crate::error::AppError;
use crate::AppService; use crate::AppService;
use crate::error::AppError;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use models::projects::{project_members, Project}; use models::projects::{Project, project_members};
use models::repos::repo::{ use models::repos::repo::{
ActiveModel as RepoActiveModel, Column as RepoColumn, Entity as RepoEntity, ActiveModel as RepoActiveModel, Column as RepoColumn, Entity as RepoEntity,
}; };
@ -348,14 +348,16 @@ impl AppService {
) )
.await; .await;
self.room.publish_room_event( self.room
.publish_room_event(
project.id, project.id,
room::RoomEventType::RepoCreated, room::RoomEventType::RepoCreated,
None, None,
None, None,
None, None,
None, None,
).await; )
.await;
observability::incr!(observability::REPOS_CREATED_TOTAL); observability::incr!(observability::REPOS_CREATED_TOTAL);
Ok(ProjectRepoCreateResponse { Ok(ProjectRepoCreateResponse {

View File

@ -1,11 +1,11 @@
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use models::ai::ai_token_usage;
use models::issues::issue;
use models::projects::{project_activity, project_members}; use models::projects::{project_activity, project_members};
use models::repos::repo; use models::repos::repo;
use models::rooms::{room, room_ai}; use models::rooms::{room, room_ai};
use models::issues::issue;
use models::ai::ai_token_usage;
use models::users::user; use models::users::user;
use sea_orm::*; use sea_orm::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -152,7 +152,12 @@ impl AppService {
.count(&self.db) .count(&self.db)
.await?; .await?;
(pr_total as i64, pr_open as i64, pr_merged as i64, pr_closed as i64) (
pr_total as i64,
pr_open as i64,
pr_merged as i64,
pr_closed as i64,
)
}; };
// ── Room count ────────────────────────────────────────── // ── Room count ──────────────────────────────────────────
@ -200,12 +205,16 @@ impl AppService {
.all(&self.db) .all(&self.db)
.await?; .await?;
let input_sum = token_records.iter().map(|t| t.input_tokens as i64).sum::<i64>(); let input_sum = token_records
let output_sum = token_records.iter().map(|t| t.output_tokens as i64).sum::<i64>();
let cost_sum: rust_decimal::Decimal = token_records
.iter() .iter()
.filter_map(|t| t.cost_usd) .map(|t| t.input_tokens as i64)
.sum(); .sum::<i64>();
let output_sum = token_records
.iter()
.map(|t| t.output_tokens as i64)
.sum::<i64>();
let cost_sum: rust_decimal::Decimal =
token_records.iter().filter_map(|t| t.cost_usd).sum();
let cost_str = if cost_sum != rust_decimal::Decimal::ZERO { let cost_str = if cost_sum != rust_decimal::Decimal::ZERO {
Some(cost_sum.to_string()) Some(cost_sum.to_string())
@ -226,7 +235,8 @@ impl AppService {
.await?; .await?;
// Group by event_type // Group by event_type
let mut breakdown_map: std::collections::HashMap<String, i64> = std::collections::HashMap::new(); let mut breakdown_map: std::collections::HashMap<String, i64> =
std::collections::HashMap::new();
for a in &activity_rows { for a in &activity_rows {
*breakdown_map.entry(a.event_type.clone()).or_insert(0) += 1; *breakdown_map.entry(a.event_type.clone()).or_insert(0) += 1;
} }
@ -246,7 +256,8 @@ impl AppService {
// Enrich with actor info // Enrich with actor info
let actor_ids: Vec<Uuid> = recent.iter().map(|a| a.actor).collect(); let actor_ids: Vec<Uuid> = recent.iter().map(|a| a.actor).collect();
let actor_map: std::collections::HashMap<Uuid, (String, Option<String>)> = if actor_ids.is_empty() { let actor_map: std::collections::HashMap<Uuid, (String, Option<String>)> =
if actor_ids.is_empty() {
std::collections::HashMap::new() std::collections::HashMap::new()
} else { } else {
let users = user::Entity::find() let users = user::Entity::find()
@ -255,7 +266,15 @@ impl AppService {
.await?; .await?;
users users
.into_iter() .into_iter()
.map(|u| (u.uid, (u.display_name.or(Some(u.username)).unwrap_or_default(), u.avatar_url))) .map(|u| {
(
u.uid,
(
u.display_name.or(Some(u.username)).unwrap_or_default(),
u.avatar_url,
),
)
})
.collect() .collect()
}; };

View File

@ -170,8 +170,9 @@ impl AppService {
.ref_target(&head_ref_name)? .ref_target(&head_ref_name)?
.ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?;
let (analysis, _pref) = domain.merge_analysis_for_ref(&base, &head_oid)?; let (analysis, _pref) = domain.merge_analysis_for_ref(&base, &head_oid)?;
let has_conflicts = let has_conflicts = !analysis.is_fast_forward
!analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted(); && !analysis.is_up_to_date
&& domain.merge_is_conflicted();
let conflicted_files = if has_conflicts { let conflicted_files = if has_conflicts {
let index = domain let index = domain
.repo() .repo()
@ -289,7 +290,8 @@ impl AppService {
let (analysis, _pref) = domain.merge_analysis_for_ref(&pr_base, &head_oid)?; let (analysis, _pref) = domain.merge_analysis_for_ref(&pr_base, &head_oid)?;
if !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted() { if !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted()
{
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
"Pull request has merge conflicts".to_string(), "Pull request has merge conflicts".to_string(),
)); ));

View File

@ -137,7 +137,11 @@ impl AppService {
} }
let total = stmt.clone().count(&self.db).await? as i64; let total = stmt.clone().count(&self.db).await? as i64;
let comments = stmt.limit(query.limit.unwrap_or(200)).offset(query.offset.unwrap_or(0)).all(&self.db).await?; let comments = stmt
.limit(query.limit.unwrap_or(200))
.offset(query.offset.unwrap_or(0))
.all(&self.db)
.await?;
let author_ids: Vec<Uuid> = comments.iter().map(|c| c.author).collect(); let author_ids: Vec<Uuid> = comments.iter().map(|c| c.author).collect();
let authors = if author_ids.is_empty() { let authors = if author_ids.is_empty() {

View File

@ -1,10 +1,10 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{bail, Context}; use anyhow::{Context, bail};
use base64ct::{Base64UrlUnpadded, Encoding}; use base64ct::{Base64UrlUnpadded, Encoding};
use serde::Serialize; use serde::Serialize;
use web_push_native::{ use web_push_native::{
jwt_simple::algorithms::ES256KeyPair, p256::PublicKey, Auth, WebPushBuilder, Auth, WebPushBuilder, jwt_simple::algorithms::ES256KeyPair, p256::PublicKey,
}; };
#[derive(Clone)] #[derive(Clone)]
@ -63,8 +63,8 @@ impl WebPushService {
let ua_public_bytes = Base64UrlUnpadded::decode_vec(p256dh) let ua_public_bytes = Base64UrlUnpadded::decode_vec(p256dh)
.with_context(|| format!("Failed to decode p256dh: {}", p256dh))?; .with_context(|| format!("Failed to decode p256dh: {}", p256dh))?;
let ua_public = PublicKey::from_sec1_bytes(&ua_public_bytes) let ua_public =
.with_context(|| "Invalid p256dh key")?; PublicKey::from_sec1_bytes(&ua_public_bytes).with_context(|| "Invalid p256dh key")?;
let auth_bytes = Base64UrlUnpadded::decode_vec(auth) let auth_bytes = Base64UrlUnpadded::decode_vec(auth)
.with_context(|| format!("Failed to decode auth: {}", auth))?; .with_context(|| format!("Failed to decode auth: {}", auth))?;
@ -76,8 +76,8 @@ impl WebPushService {
.with_vapid(&self.vapid_key_pair, &self.sender_email) .with_vapid(&self.vapid_key_pair, &self.sender_email)
.build(payload_bytes)?; .build(payload_bytes)?;
let reqwest_request = reqwest::Request::try_from(request) let reqwest_request =
.context("Failed to convert web-push request")?; reqwest::Request::try_from(request).context("Failed to convert web-push request")?;
let response = self.http.execute(reqwest_request).await?; let response = self.http.execute(reqwest_request).await?;
let status = response.status(); let status = response.status();

View File

@ -63,7 +63,8 @@ fn parse_types(types: Option<String>) -> Vec<String> {
} }
fn build_like_pattern(q: &str) -> String { fn build_like_pattern(q: &str) -> String {
let escaped = q.trim() let escaped = q
.trim()
.replace('\\', "\\\\") .replace('\\', "\\\\")
.replace('%', "\\%") .replace('%', "\\%")
.replace('_', "\\_"); .replace('_', "\\_");
@ -678,15 +679,19 @@ impl AppService {
AND m.revoked IS NULL AND m.revoked IS NULL
ORDER BY m.send_at DESC ORDER BY m.send_at DESC
LIMIT $3 OFFSET $4"#, LIMIT $3 OFFSET $4"#,
tsquery, tsquery, tsquery
tsquery
); );
// Results query // Results query
let results_sql = Statement::from_sql_and_values( let results_sql = Statement::from_sql_and_values(
DbBackend::Postgres, DbBackend::Postgres,
&sql, &sql,
vec![q.into(), accessible_rooms.clone().into(), per_page.into(), offset.into()], vec![
q.into(),
accessible_rooms.clone().into(),
per_page.into(),
offset.into(),
],
); );
let rows = self.db.query_all_raw(results_sql).await?; let rows = self.db.query_all_raw(results_sql).await?;
@ -694,11 +699,11 @@ impl AppService {
for row in rows { for row in rows {
let room_id: Uuid = row.try_get::<Uuid>("", "room").unwrap_or_default(); let room_id: Uuid = row.try_get::<Uuid>("", "room").unwrap_or_default();
let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default(); let sender_type_str = row.try_get::<String>("", "sender_type").unwrap_or_default();
let content_type_str = row.try_get::<String>("", "content_type").unwrap_or_default(); let content_type_str = row
.try_get::<String>("", "content_type")
.unwrap_or_default();
let highlighted = row let highlighted = row.try_get::<String>("", "highlighted_content").ok();
.try_get::<String>("", "highlighted_content")
.ok();
messages.push(GlobalMessageSearchItem { messages.push(GlobalMessageSearchItem {
id: row.try_get::<Uuid>("", "id").unwrap_or_default(), id: row.try_get::<Uuid>("", "id").unwrap_or_default(),
@ -709,7 +714,9 @@ impl AppService {
display_name: None, display_name: None,
content: row.try_get::<String>("", "content").unwrap_or_default(), content: row.try_get::<String>("", "content").unwrap_or_default(),
content_type: content_type_str, content_type: content_type_str,
send_at: row.try_get::<DateTime<Utc>>("", "send_at").unwrap_or_default(), send_at: row
.try_get::<DateTime<Utc>>("", "send_at")
.unwrap_or_default(),
highlighted_content: highlighted, highlighted_content: highlighted,
}); });
} }

View File

@ -1,11 +1,11 @@
//! Create, update, delete project skills. //! Create, update, delete project skills.
use super::info::SkillResponse;
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use super::info::SkillResponse;
use chrono::Utc; use chrono::Utc;
use models::projects::project_skill::{Column as C, Entity as SkillEntity};
use models::ActiveModelTrait; use models::ActiveModelTrait;
use models::projects::project_skill::{Column as C, Entity as SkillEntity};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session::Session; use session::Session;
@ -64,9 +64,7 @@ impl AppService {
let project_id = Uuid::parse_str(&project_uuid) let project_id = Uuid::parse_str(&project_uuid)
.map_err(|_| AppError::BadRequest("Invalid project UUID".to_string()))?; .map_err(|_| AppError::BadRequest("Invalid project UUID".to_string()))?;
let user_id = ctx let user_id = ctx.user().ok_or_else(|| AppError::Unauthorized)?;
.user()
.ok_or_else(|| AppError::Unauthorized)?;
// Check for duplicate slug within project // Check for duplicate slug within project
let exists = SkillEntity::find() let exists = SkillEntity::find()
@ -82,7 +80,9 @@ impl AppService {
} }
let now = Utc::now(); let now = Utc::now();
let metadata = request.metadata.unwrap_or(serde_json::Value::Object(Default::default())); let metadata = request
.metadata
.unwrap_or(serde_json::Value::Object(Default::default()));
let name = request.name.unwrap_or_else(|| request.slug.clone()); let name = request.name.unwrap_or_else(|| request.slug.clone());
let active = models::projects::project_skill::ActiveModel { let active = models::projects::project_skill::ActiveModel {
@ -114,7 +114,10 @@ impl AppService {
let scontent = inserted.content.clone(); let scontent = inserted.content.clone();
let sproj = inserted.project_uuid.to_string(); let sproj = inserted.project_uuid.to_string();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = es.embed_skill(sid, &sname, sdesc.as_deref(), &scontent, &sproj).await { if let Err(e) = es
.embed_skill(sid, &sname, sdesc.as_deref(), &scontent, &sproj)
.await
{
tracing::warn!(error = %e, skill_id = %sid, "failed to embed skill"); tracing::warn!(error = %e, skill_id = %sid, "failed to embed skill");
} }
}); });
@ -170,7 +173,10 @@ impl AppService {
let scontent = updated.content.clone(); let scontent = updated.content.clone();
let sproj = updated.project_uuid.to_string(); let sproj = updated.project_uuid.to_string();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = es.embed_skill(sid, &sname, sdesc.as_deref(), &scontent, &sproj).await { if let Err(e) = es
.embed_skill(sid, &sname, sdesc.as_deref(), &scontent, &sproj)
.await
{
tracing::warn!(error = %e, skill_id = %sid, "failed to re-embed skill on update"); tracing::warn!(error = %e, skill_id = %sid, "failed to re-embed skill on update");
} }
}); });

View File

@ -2,8 +2,8 @@
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use models::repos::repo::Entity as RepoEntity;
use models::repos::repo::Column as RCol; use models::repos::repo::Column as RCol;
use models::repos::repo::Entity as RepoEntity;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid; use uuid::Uuid;

View File

@ -115,7 +115,8 @@ pub fn scan_repo_for_skills(
.map(|s| s.to_lowercase()) .map(|s| s.to_lowercase())
== Some("skill.md".to_string()) == Some("skill.md".to_string())
{ {
if let Some(dir_name) = path.parent() if let Some(dir_name) = path
.parent()
.and_then(|p| p.file_name()) .and_then(|p| p.file_name())
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.filter(|s| !s.starts_with('.')) .filter(|s| !s.starts_with('.'))
@ -168,7 +169,9 @@ pub fn scan_repo_tree_for_skills(
match entry.kind() { match entry.kind() {
Some(git2::ObjectType::Tree) => { Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') { if !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) { if let Ok(subtree) =
entry.to_object(git_repo).and_then(|o| o.peel_to_tree())
{
stack.push((subtree, entry_path)); stack.push((subtree, entry_path));
} }
} }
@ -220,7 +223,11 @@ pub async fn scan_and_sync_skills(
} }
}; };
let commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string()); let commit_sha = git_repo
.head()
.ok()
.and_then(|h| h.target())
.map(|oid| oid.to_string());
// For bare repos (no workdir), scan git tree objects directly // For bare repos (no workdir), scan git tree objects directly
let mut discovered = if git_repo.is_bare() || git_repo.workdir().is_none() { let mut discovered = if git_repo.is_bare() || git_repo.workdir().is_none() {
@ -265,13 +272,20 @@ async fn sync_discovered_skills(
let mut updated = 0i64; let mut updated = 0i64;
// Deduplicate by {repo_id}+{blob_hash}, keep latest by commit_sha // Deduplicate by {repo_id}+{blob_hash}, keep latest by commit_sha
let mut deduped: std::collections::HashMap<String, DiscoveredSkill> = std::collections::HashMap::new(); let mut deduped: std::collections::HashMap<String, DiscoveredSkill> =
std::collections::HashMap::new();
for skill in discovered { for skill in discovered {
let key = format!("{}:{}", repo_id, skill.blob_hash.as_ref().unwrap_or(&skill.slug)); let key = format!(
"{}:{}",
repo_id,
skill.blob_hash.as_ref().unwrap_or(&skill.slug)
);
match deduped.get(&key) { match deduped.get(&key) {
Some(existing) => { Some(existing) => {
// Keep the one with the later commit_sha // Keep the one with the later commit_sha
if skill.commit_sha.as_ref().unwrap_or(&String::new()) > existing.commit_sha.as_ref().unwrap_or(&String::new()) { if skill.commit_sha.as_ref().unwrap_or(&String::new())
> existing.commit_sha.as_ref().unwrap_or(&String::new())
{
deduped.insert(key, skill); deduped.insert(key, skill);
} }
} }
@ -292,7 +306,11 @@ async fn sync_discovered_skills(
let existing_by_hash: std::collections::HashMap<_, _> = existing let existing_by_hash: std::collections::HashMap<_, _> = existing
.into_iter() .into_iter()
.map(|s| { .map(|s| {
let key = format!("{}:{}", s.repo_id.unwrap_or_default(), s.blob_hash.clone().unwrap_or_default()); let key = format!(
"{}:{}",
s.repo_id.unwrap_or_default(),
s.blob_hash.clone().unwrap_or_default()
);
(key, s) (key, s)
}) })
.collect(); .collect();

View File

@ -39,11 +39,7 @@ impl AppStorage {
} }
/// Write data to a local path and return the public URL. /// Write data to a local path and return the public URL.
pub async fn upload( pub async fn upload(&self, key: &str, data: Vec<u8>) -> anyhow::Result<String> {
&self,
key: &str,
data: Vec<u8>,
) -> anyhow::Result<String> {
let safe_path = Self::sanitize_key(key)?; let safe_path = Self::sanitize_key(key)?;
let path = self.base_path.join(safe_path); let path = self.base_path.join(safe_path);
@ -54,11 +50,7 @@ impl AppStorage {
tokio::fs::write(&path, &data).await?; tokio::fs::write(&path, &data).await?;
let url = format!( let url = format!("{}/{}", self.public_url_base.trim_end_matches('/'), key);
"{}/{}",
self.public_url_base.trim_end_matches('/'),
key
);
Ok(url) Ok(url)
} }

View File

@ -222,8 +222,8 @@ impl AppService {
} }
fn user_hash_access_key(&self, access_key: &str) -> String { fn user_hash_access_key(&self, access_key: &str) -> String {
use argon2::password_hash::{SaltString, PasswordHasher};
use argon2::Argon2; use argon2::Argon2;
use argon2::password_hash::{PasswordHasher, SaltString};
let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default()); let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default());
Argon2::default() Argon2::default()
.hash_password(access_key.as_bytes(), &salt) .hash_password(access_key.as_bytes(), &salt)

View File

@ -75,10 +75,28 @@ impl AppService {
) -> Result<UserBillingResponse, AppError> { ) -> Result<UserBillingResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let billing = user_billing::Entity::find_by_id(user_uid) let billing = match user_billing::Entity::find_by_id(user_uid)
.one(&self.db) .one(&self.db)
.await? .await?
.ok_or_else(|| AppError::InternalServerError("User billing not found".into()))?; {
Some(b) => b,
None => {
let now = Utc::now();
let active = user_billing::ActiveModel {
user: Set(user_uid),
balance: Set(rust_decimal::Decimal::from(10)),
currency: Set("USD".to_string()),
is_pro: Set(false),
monthly_quota: Set(rust_decimal::Decimal::from(0)),
month_used: Set(rust_decimal::Decimal::from(0)),
cycle_start: Set(None),
cycle_end: Set(None),
updated_at: Set(now),
created_at: Set(now),
};
active.insert(&self.db).await?
}
};
Ok(UserBillingResponse { Ok(UserBillingResponse {
user_uid: billing.user, user_uid: billing.user,
@ -109,7 +127,9 @@ impl AppService {
.await?; .await?;
Ok(UserBillingErrorsResponse { Ok(UserBillingErrorsResponse {
list: errors.into_iter().map(|e| UserBillingErrorItem { list: errors
.into_iter()
.map(|e| UserBillingErrorItem {
id: e.id, id: e.id,
scope: e.scope, scope: e.scope,
scope_id: e.scope_id, scope_id: e.scope_id,
@ -118,7 +138,8 @@ impl AppService {
details: e.details, details: e.details,
resolved: e.resolved, resolved: e.resolved,
created_at: e.created_at, created_at: e.created_at,
}).collect(), })
.collect(),
}) })
} }
@ -138,7 +159,9 @@ impl AppService {
let total = paginator.num_items().await?; let total = paginator.num_items().await?;
let rows = paginator.fetch_page(page - 1).await?; let rows = paginator.fetch_page(page - 1).await?;
let list = rows.into_iter().map(|x| UserBillingHistoryItem { let list = rows
.into_iter()
.map(|x| UserBillingHistoryItem {
uid: x.uid, uid: x.uid,
project_uid: x.project, project_uid: x.project,
user_uid: x.user, user_uid: x.user,
@ -147,8 +170,14 @@ impl AppService {
reason: x.reason, reason: x.reason,
extra: x.extra.map(|v| v.into()), extra: x.extra.map(|v| v.into()),
created_at: x.created_at, created_at: x.created_at,
}).collect(); })
.collect();
Ok(UserBillingHistoryResponse { page, per_page, total, list }) Ok(UserBillingHistoryResponse {
page,
per_page,
total,
list,
})
} }
} }

View File

@ -78,8 +78,12 @@ impl AppService {
return Ok(response); return Ok(response);
} }
let start_dt = start_date.and_hms_opt(0, 0, 0).unwrap_or(chrono::NaiveDateTime::MIN); let start_dt = start_date
let end_dt = end_date.and_hms_opt(23, 59, 59).unwrap_or(chrono::NaiveDateTime::MAX); .and_hms_opt(0, 0, 0)
.unwrap_or(chrono::NaiveDateTime::MIN);
let end_dt = end_date
.and_hms_opt(23, 59, 59)
.unwrap_or(chrono::NaiveDateTime::MAX);
let commits: Vec<repo_commit::Model> = repo_commit::Entity::find() let commits: Vec<repo_commit::Model> = repo_commit::Entity::find()
.filter(repo_commit::Column::AuthorEmail.is_in(emails.clone())) .filter(repo_commit::Column::AuthorEmail.is_in(emails.clone()))

View File

@ -135,8 +135,10 @@ impl AppService {
active_prefs.push_subscription_keys_auth = Set(None); active_prefs.push_subscription_keys_auth = Set(None);
} else { } else {
active_prefs.push_subscription_endpoint = Set(Some(endpoint)); active_prefs.push_subscription_endpoint = Set(Some(endpoint));
active_prefs.push_subscription_keys_p256dh = Set(params.push_subscription_keys_p256dh.clone()); active_prefs.push_subscription_keys_p256dh =
active_prefs.push_subscription_keys_auth = Set(params.push_subscription_keys_auth.clone()); Set(params.push_subscription_keys_p256dh.clone());
active_prefs.push_subscription_keys_auth =
Set(params.push_subscription_keys_auth.clone());
} }
} }
active_prefs.updated_at = Set(Utc::now()); active_prefs.updated_at = Set(Utc::now());
@ -222,10 +224,7 @@ impl AppService {
Ok(NotificationPreferencesResponse::from(created_prefs)) Ok(NotificationPreferencesResponse::from(created_prefs))
} }
pub async fn user_unsubscribe_push( pub async fn user_unsubscribe_push(&self, context: &Session) -> Result<(), AppError> {
&self,
context: &Session,
) -> Result<(), AppError> {
let user_uid = context.user().ok_or(AppError::Unauthorized)?; let user_uid = context.user().ok_or(AppError::Unauthorized)?;
let prefs = user_notification::Entity::find_by_id(user_uid) let prefs = user_notification::Entity::find_by_id(user_uid)

View File

@ -81,13 +81,20 @@ impl AppService {
// Union + dedup (preserving first occurrence order) // Union + dedup (preserving first occurrence order)
let mut project_ids: Vec<Uuid> = created_projects; let mut project_ids: Vec<Uuid> = created_projects;
let project_id_set: std::collections::HashSet<&Uuid> = project_ids.iter().collect(); let project_id_set: std::collections::HashSet<&Uuid> = project_ids.iter().collect();
let new_ids: Vec<Uuid> = member_projects.into_iter().filter(|id| !project_id_set.contains(id)).collect(); let new_ids: Vec<Uuid> = member_projects
.into_iter()
.filter(|id| !project_id_set.contains(id))
.collect();
project_ids.extend(new_ids); project_ids.extend(new_ids);
let total_count = project_ids.len() as u64; let total_count = project_ids.len() as u64;
// Paginate // Paginate
let page_ids: Vec<Uuid> = project_ids.into_iter().skip(offset as usize).take(per_page as usize).collect(); let page_ids: Vec<Uuid> = project_ids
.into_iter()
.skip(offset as usize)
.take(per_page as usize)
.collect();
if page_ids.is_empty() { if page_ids.is_empty() {
return Ok(UserProjectsResponse { return Ok(UserProjectsResponse {
@ -105,8 +112,14 @@ impl AppService {
// Preserve the order from project_ids (created projects first, then member projects) // Preserve the order from project_ids (created projects first, then member projects)
let mut sorted_projects = project_list; let mut sorted_projects = project_list;
sorted_projects.sort_by(|a, b| { sorted_projects.sort_by(|a, b| {
let a_idx = page_ids.iter().position(|&x| x == a.id).unwrap_or(usize::MAX); let a_idx = page_ids
let b_idx = page_ids.iter().position(|&x| x == b.id).unwrap_or(usize::MAX); .iter()
.position(|&x| x == a.id)
.unwrap_or(usize::MAX);
let b_idx = page_ids
.iter()
.position(|&x| x == b.id)
.unwrap_or(usize::MAX);
a_idx.cmp(&b_idx) a_idx.cmp(&b_idx)
}); });

View File

@ -1,16 +1,16 @@
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use chrono::Utc; use chrono::Utc;
use models::repos::repo;
use models::projects::project; use models::projects::project;
use models::repos::repo;
use models::users::user; use models::users::user;
use sea_orm::prelude::*; use sea_orm::prelude::*;
use sea_orm::*; use sea_orm::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session::Session; use session::Session;
use std::collections::HashMap;
use utoipa::{IntoParams, ToSchema}; use utoipa::{IntoParams, ToSchema};
use uuid::Uuid; use uuid::Uuid;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserRepoInfo { pub struct UserRepoInfo {

View File

@ -115,7 +115,8 @@ impl AppService {
let followed_project_ids: Vec<Uuid> = follows.iter().map(|f| f.project).collect(); let followed_project_ids: Vec<Uuid> = follows.iter().map(|f| f.project).collect();
let followed_projects: std::collections::HashMap<Uuid, project::Model> = project::Entity::find() let followed_projects: std::collections::HashMap<Uuid, project::Model> =
project::Entity::find()
.filter(project::Column::Id.is_in(followed_project_ids)) .filter(project::Column::Id.is_in(followed_project_ids))
.all(&self.db) .all(&self.db)
.await? .await?

View File

@ -182,7 +182,8 @@ impl AppService {
let followed_uids: Vec<Uuid> = following.iter().map(|f| f.target).collect(); let followed_uids: Vec<Uuid> = following.iter().map(|f| f.target).collect();
let followed_users: std::collections::HashMap<Uuid, models::users::user::Model> = models::users::user::Entity::find() let followed_users: std::collections::HashMap<Uuid, models::users::user::Model> =
models::users::user::Entity::find()
.filter(models::users::user::Column::Uid.is_in(followed_uids.clone())) .filter(models::users::user::Column::Uid.is_in(followed_uids.clone()))
.all(&self.db) .all(&self.db)
.await? .await?

View File

@ -1,13 +1,13 @@
use super::chpc::{ContributionHeatmapQuery, ContributionHeatmapResponse};
use super::projects::{UserProjectInfo, UserProjectsQuery};
use super::repository::{UserRepoInfo, UserReposQuery};
use super::user_activity::{UserActivityItem, UserActivityQuery};
use super::user_info::UserInfoExternal;
use crate::AppService; use crate::AppService;
use crate::error::AppError; use crate::error::AppError;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session::Session; use session::Session;
use utoipa::ToSchema; use utoipa::ToSchema;
use super::user_info::UserInfoExternal;
use super::repository::{UserRepoInfo, UserReposQuery};
use super::projects::{UserProjectInfo, UserProjectsQuery};
use super::user_activity::{UserActivityItem, UserActivityQuery};
use super::chpc::{ContributionHeatmapResponse, ContributionHeatmapQuery};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserSummaryResponse { pub struct UserSummaryResponse {
@ -29,18 +29,60 @@ impl AppService {
) -> Result<UserSummaryResponse, AppError> { ) -> Result<UserSummaryResponse, AppError> {
let info = self.user_info(context.clone(), username.clone()).await?; let info = self.user_info(context.clone(), username.clone()).await?;
let repos_resp = self.get_user_repos(context.clone(), username.clone(), UserReposQuery { page: Some(1), per_page: Some(4) }).await?; let repos_resp = self
.get_user_repos(
context.clone(),
username.clone(),
UserReposQuery {
page: Some(1),
per_page: Some(4),
},
)
.await?;
let projects_resp = self.get_user_projects(context.clone(), username.clone(), UserProjectsQuery { page: Some(1), per_page: Some(4) }).await?; let projects_resp = self
.get_user_projects(
context.clone(),
username.clone(),
UserProjectsQuery {
page: Some(1),
per_page: Some(4),
},
)
.await?;
let activity_resp = self.get_user_activity(context.clone(), username.clone(), UserActivityQuery { page: Some(1), per_page: Some(8) }).await?; let activity_resp = self
.get_user_activity(
context.clone(),
username.clone(),
UserActivityQuery {
page: Some(1),
per_page: Some(8),
},
)
.await?;
let heatmap = self.get_user_contribution_heatmap(context.clone(), username.clone(), ContributionHeatmapQuery { start_date: None, end_date: None }).await?; let heatmap = self
.get_user_contribution_heatmap(
context.clone(),
username.clone(),
ContributionHeatmapQuery {
start_date: None,
end_date: None,
},
)
.await?;
let follower_count = self.user_get_subscriber_count(context.clone(), username.clone()).await?; let follower_count = self
let following_count = self.user_get_subscription_count(context.clone(), username.clone()).await?; .user_get_subscriber_count(context.clone(), username.clone())
.await?;
let following_count = self
.user_get_subscription_count(context.clone(), username.clone())
.await?;
let stars_resp = self.get_user_stars(context.clone(), username.clone()).await?; let stars_resp = self
.get_user_stars(context.clone(), username.clone())
.await?;
let stars_count = stars_resp.total; let stars_count = stars_resp.total;
Ok(UserSummaryResponse { Ok(UserSummaryResponse {

View File

@ -128,9 +128,7 @@ impl AppService {
action: activity.event_type, action: activity.event_type,
title: activity.title, title: activity.title,
resource_type: Some("project".to_string()), resource_type: Some("project".to_string()),
resource_name: proj_map resource_name: proj_map.get(&activity.project).map(|p| p.name.clone()),
.get(&activity.project)
.map(|p| p.name.clone()),
metadata: activity.metadata, metadata: activity.metadata,
created_at: activity.created_at, created_at: activity.created_at,
}); });
@ -140,7 +138,11 @@ impl AppService {
items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let total = items.len() as u64; let total = items.len() as u64;
let page_items: Vec<UserActivityItem> = items.into_iter().skip(offset as usize).take(per_page as usize).collect(); let page_items: Vec<UserActivityItem> = items
.into_iter()
.skip(offset as usize)
.take(per_page as usize)
.collect();
Ok(UserActivityResponse { Ok(UserActivityResponse {
items: page_items, items: page_items,

View File

@ -34,7 +34,12 @@ impl WsTokenService {
} }
/// Generate a new WebSocket token for the given user /// Generate a new WebSocket token for the given user
pub async fn generate_token(&self, user_id: Uuid, device_id: Option<String>, client_id: Option<String>) -> Result<String, AppError> { pub async fn generate_token(
&self,
user_id: Uuid,
device_id: Option<String>,
client_id: Option<String>,
) -> Result<String, AppError> {
let token = Self::random_token(); let token = Self::random_token();
let now = Utc::now(); let now = Utc::now();
let token_data = WsTokenData { let token_data = WsTokenData {

View File

@ -8,8 +8,6 @@ pub mod storage;
pub use self::{ pub use self::{
middleware::SessionMiddleware, middleware::SessionMiddleware,
session::{ session::{Session, SessionGetError, SessionInsertError, SessionStatus, SessionUser},
Session, SessionGetError, SessionInsertError, SessionStatus, SessionUser,
},
session_ext::SessionExt, session_ext::SessionExt,
}; };