use super::fuzzy::find_similar_projects;
use super::models::{
AccessClassInfo, CreateProjectRequest, CreateProjectResponse, GetProjectParams,
ListAccessClassesResponse, OwnerInfo, Project as ApiProject, ProjectErrorResponse,
ProjectOwner, ProjectStatus, TeamInfo, UpdateProjectRequest, UpdateProjectResponse, UserInfo,
};
use crate::db::models::User;
use crate::db::{projects, service_accounts, teams as db_teams, users as db_users};
use crate::server::error::{ServerError, ServerErrorExt};
use crate::server::state::AppState;
use axum::{
extract::{Extension, Path, Query, State},
http::StatusCode,
Json,
};
use uuid::Uuid;
pub async fn list_access_classes(
State(state): State<AppState>,
Extension(_user): Extension<User>,
) -> Result<Json<ListAccessClassesResponse>, ServerError> {
let access_classes = state
.access_classes
.iter()
.map(|(id, class)| AccessClassInfo {
id: id.clone(),
display_name: class.display_name.clone(),
description: class.description.clone(),
})
.collect();
Ok(Json(ListAccessClassesResponse { access_classes }))
}
async fn resolve_user_identifier(
pool: &sqlx::PgPool,
identifier: &str,
) -> Result<uuid::Uuid, ServerError> {
use crate::server::error::ServerErrorExt;
if let Ok(uuid) = uuid::Uuid::parse_str(identifier) {
db_users::find_by_id(pool, uuid)
.await
.internal_err("Failed to lookup user")?
.ok_or_else(|| ServerError::not_found(format!("User not found: {}", identifier)))
.map(|u| u.id)
} else {
db_users::find_by_email(pool, identifier)
.await
.internal_err("Failed to lookup user")?
.ok_or_else(|| ServerError::not_found(format!("User not found: {}", identifier)))
.map(|u| u.id)
}
}
async fn resolve_team_identifier(
pool: &sqlx::PgPool,
identifier: &str,
) -> Result<uuid::Uuid, ServerError> {
use crate::server::error::ServerErrorExt;
if let Ok(uuid) = uuid::Uuid::parse_str(identifier) {
db_teams::find_by_id(pool, uuid)
.await
.internal_err("Failed to lookup team")?
.ok_or_else(|| ServerError::not_found(format!("Team not found: {}", identifier)))
.map(|t| t.id)
} else {
db_teams::find_by_name(pool, identifier)
.await
.internal_err("Failed to lookup team")?
.ok_or_else(|| ServerError::not_found(format!("Team not found: {}", identifier)))
.map(|t| t.id)
}
}
pub async fn create_project(
State(state): State<AppState>,
Extension(user): Extension<User>,
Json(payload): Json<CreateProjectRequest>,
) -> Result<Json<CreateProjectResponse>, ServerError> {
let is_valid_access_class = state.access_classes.contains_key(&payload.access_class);
if !is_valid_access_class {
let available = state
.access_classes
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ");
return Err(ServerError::bad_request(format!(
"Invalid access class '{}'. Available: {}",
payload.access_class, available
)));
}
let (owner_user_id, owner_team_id) = match &payload.owner {
ProjectOwner::User(user_id) => {
let uuid =
Uuid::parse_str(user_id).server_err(StatusCode::BAD_REQUEST, "Invalid user ID")?;
(Some(uuid), None)
}
ProjectOwner::Team(team_id) => {
let uuid =
Uuid::parse_str(team_id).server_err(StatusCode::BAD_REQUEST, "Invalid team ID")?;
let is_member = db_teams::is_member(&state.db_pool, uuid, user.id)
.await
.internal_err("Failed to check team membership")
.map_err(|e| {
e.with_context("team_id", uuid.to_string())
.with_context("user_id", user.id.to_string())
})?;
if !is_member {
return Err(ServerError::forbidden("You are not a member of this team"));
}
(None, Some(uuid))
}
};
tracing::info!(
"Creating project '{}' for user {}",
payload.name,
user.email
);
let project = projects::create(
&state.db_pool,
&payload.name,
crate::db::models::ProjectStatus::Stopped,
payload.access_class,
owner_user_id,
owner_team_id,
)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") || e.to_string().contains("unique constraint") {
ServerError::new(
StatusCode::CONFLICT,
format!("Project '{}' already exists", &payload.name),
)
} else {
ServerError::internal_anyhow(e, "Failed to create project")
.with_context("project_name", &payload.name)
.with_context("user_email", &user.email)
}
})?;
use crate::server::error::ServerErrorExt;
let mut tx = state
.db_pool
.begin()
.await
.internal_err("Failed to start transaction")?;
for user_identifier in &payload.app_users {
let user_id = resolve_user_identifier(&state.db_pool, user_identifier).await?;
crate::db::project_app_users::add_user(&mut *tx, project.id, user_id)
.await
.internal_err("Failed to add app user")?;
}
for team_identifier in &payload.app_teams {
let team_id = resolve_team_identifier(&state.db_pool, team_identifier).await?;
crate::db::project_app_users::add_team(&mut *tx, project.id, team_id)
.await
.internal_err("Failed to add app team")?;
}
tx.commit()
.await
.internal_err("Failed to commit transaction")?;
let owner_info = resolve_owner_info(&state, &project)
.await
.map_err(|e| ServerError::internal(format!("Failed to resolve owner info: {}", e)))?;
Ok(Json(CreateProjectResponse {
project: convert_project(project, owner_info),
}))
}
pub async fn list_projects(
State(state): State<AppState>,
Extension(user): Extension<User>,
) -> Result<Json<Vec<ApiProject>>, ServerError> {
let projects = if state.is_admin(&user.email) {
projects::list(&state.db_pool, None)
.await
.internal_err("Failed to list projects")?
} else {
projects::list_accessible_by_user(&state.db_pool, user.id)
.await
.internal_err("Failed to list projects")
.map_err(|e| e.with_context("user_id", user.id.to_string()))?
};
let project_ids: Vec<uuid::Uuid> = projects.iter().map(|p| p.id).collect();
let active_deployment_info =
projects::get_active_deployment_info_batch(&state.db_pool, &project_ids)
.await
.internal_err("Failed to get active deployment info")?;
let deployment_ids: Vec<Uuid> = active_deployment_info
.values()
.filter_map(|info| info.as_ref().map(|i| i.id))
.collect();
let deployments_map = if !deployment_ids.is_empty() {
crate::db::deployments::get_deployments_batch(&state.db_pool, &deployment_ids)
.await
.internal_err("Failed to get deployments")?
} else {
std::collections::HashMap::new()
};
let user_ids: Vec<Uuid> = projects.iter().filter_map(|p| p.owner_user_id).collect();
let team_ids: Vec<Uuid> = projects.iter().filter_map(|p| p.owner_team_id).collect();
let user_emails = if !user_ids.is_empty() {
db_users::get_emails_batch(&state.db_pool, &user_ids)
.await
.internal_err("Failed to get user emails")?
} else {
std::collections::HashMap::new()
};
let team_names = if !team_ids.is_empty() {
db_teams::get_names_batch(&state.db_pool, &team_ids)
.await
.internal_err("Failed to get team names")?
} else {
std::collections::HashMap::new()
};
let mut api_projects = Vec::new();
for project in projects {
let (active_deployment_status, default_url, primary_url, custom_domain_urls) =
if let Some(Some(info)) = active_deployment_info.get(&project.id) {
if let Some(deployment) = deployments_map.get(&info.id) {
match state
.deployment_backend
.get_deployment_urls(deployment, &project)
.await
{
Ok(urls) => (
Some(info.status.to_string()),
Some(urls.default_url),
Some(urls.primary_url),
urls.custom_domain_urls,
),
Err(e) => {
return Err(ServerError::internal_anyhow(
e,
"Failed to calculate deployment URLs",
)
.with_context("project_name", &project.name));
}
}
} else {
(Some(info.status.to_string()), None, None, vec![])
}
} else {
(None, None, None, vec![])
};
let owner = if let Some(user_id) = project.owner_user_id {
user_emails.get(&user_id).map(|email| {
OwnerInfo::User(UserInfo {
id: user_id.to_string(),
email: email.clone(),
})
})
} else if let Some(team_id) = project.owner_team_id {
team_names.get(&team_id).map(|name| {
OwnerInfo::Team(TeamInfo {
id: team_id.to_string(),
name: name.clone(),
})
})
} else {
None
};
api_projects.push(ApiProject {
id: project.id.to_string(),
created: project.created_at.to_rfc3339(),
updated: project.updated_at.to_rfc3339(),
name: project.name,
status: ProjectStatus::from(project.status),
access_class: project.access_class,
owner,
active_deployment_status,
default_url,
primary_url,
custom_domain_urls,
deployment_groups: None, finalizers: vec![], app_users: vec![], app_teams: vec![], });
}
Ok(Json(api_projects))
}
pub async fn get_project(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(id_or_name): Path<String>,
Query(params): Query<GetProjectParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ProjectErrorResponse>)> {
let project = resolve_project(&state, &id_or_name, params.by_id).await?;
let can_read = check_read_permission(&state, &project, &user)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check permissions: {}", e),
suggestions: None,
}),
)
})?;
if !can_read {
return Err((
StatusCode::NOT_FOUND,
Json(ProjectErrorResponse {
error: format!("Project '{}' not found", id_or_name),
suggestions: None,
}),
));
}
let (default_url, primary_url, custom_domain_urls) =
match crate::db::deployments::get_active_deployments_for_project(&state.db_pool, project.id)
.await
{
Ok(active_deployments) => {
if let Some(deployment) = active_deployments.iter().find(|d| {
d.deployment_group
== crate::server::deployment::models::DEFAULT_DEPLOYMENT_GROUP
}) {
match state
.deployment_backend
.get_deployment_urls(deployment, &project)
.await
{
Ok(urls) => (
Some(urls.default_url),
Some(urls.primary_url),
urls.custom_domain_urls,
),
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to calculate URLs: {}", e),
suggestions: None,
}),
));
}
}
} else {
(None, None, vec![])
}
}
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to get active deployments: {}", e),
suggestions: None,
}),
));
}
};
let owner_info = resolve_owner_info(&state, &project).await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to resolve owner info: {}", e),
suggestions: None,
}),
)
})?;
let mut api_project = convert_project(project.clone(), owner_info);
api_project.default_url = default_url;
api_project.primary_url = primary_url;
api_project.custom_domain_urls = custom_domain_urls;
let deployment_groups =
crate::db::deployments::get_active_deployment_groups(&state.db_pool, project.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to get deployment groups: {}", e),
suggestions: None,
}),
)
})?;
api_project.deployment_groups = if deployment_groups.is_empty() {
None
} else {
Some(deployment_groups)
};
let (app_users, app_teams) = load_app_users_for_project(&state, project.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to load app users: {}", e),
suggestions: None,
}),
)
})?;
api_project.app_users = app_users;
api_project.app_teams = app_teams;
Ok(Json(serde_json::to_value(api_project).unwrap()))
}
pub async fn update_project(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(id_or_name): Path<String>,
Query(params): Query<GetProjectParams>,
Json(payload): Json<UpdateProjectRequest>,
) -> Result<Json<UpdateProjectResponse>, (StatusCode, Json<ProjectErrorResponse>)> {
let project = resolve_project(&state, &id_or_name, params.by_id).await?;
let can_write = check_write_permission(&state, &project, &user)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check permissions: {}", e),
suggestions: None,
}),
)
})?;
if !can_write {
return Err((
StatusCode::FORBIDDEN,
Json(ProjectErrorResponse {
error: "You do not have permission to update this project".to_string(),
suggestions: None,
}),
));
}
let is_sa = service_accounts::is_service_account(&state.db_pool, user.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check service account status: {}", e),
suggestions: None,
}),
)
})?;
if is_sa {
return Err((
StatusCode::FORBIDDEN,
Json(ProjectErrorResponse {
error: "Service accounts cannot modify projects".to_string(),
suggestions: None,
}),
));
}
let mut updated_project = project;
if let Some(owner) = payload.owner {
let (owner_user_id, owner_team_id) = match owner {
ProjectOwner::User(user_identifier) => {
let user = if let Ok(uuid) = Uuid::parse_str(&user_identifier) {
db_users::find_by_id(&state.db_pool, uuid)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to verify user: {}", e),
suggestions: None,
}),
)
})?
} else {
db_users::find_by_email(&state.db_pool, &user_identifier)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to verify user: {}", e),
suggestions: None,
}),
)
})?
};
let user = user.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ProjectErrorResponse {
error: format!("User '{}' not found", user_identifier),
suggestions: None,
}),
)
})?;
(Some(user.id), None)
}
ProjectOwner::Team(team_identifier) => {
let team = if let Ok(uuid) = Uuid::parse_str(&team_identifier) {
db_teams::find_by_id(&state.db_pool, uuid)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to verify team: {}", e),
suggestions: None,
}),
)
})?
} else {
db_teams::find_by_name(&state.db_pool, &team_identifier)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to verify team: {}", e),
suggestions: None,
}),
)
})?
};
let team = team.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ProjectErrorResponse {
error: format!("Team '{}' not found", team_identifier),
suggestions: None,
}),
)
})?;
let is_member = db_teams::is_member(&state.db_pool, team.id, user.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check team membership: {}", e),
suggestions: None,
}),
)
})?;
if !is_member {
return Err((
StatusCode::FORBIDDEN,
Json(ProjectErrorResponse {
error: format!(
"You must be a member of team '{}' to transfer projects to it",
team.name
),
suggestions: None,
}),
));
}
(None, Some(team.id))
}
};
updated_project = projects::update_owner(
&state.db_pool,
updated_project.id,
owner_user_id,
owner_team_id,
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to update project owner: {}", e),
suggestions: None,
}),
)
})?;
}
if let Some(access_class) = payload.access_class {
let is_valid = state.access_classes.contains_key(&access_class);
if !is_valid {
let available = state
.access_classes
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ");
return Err((
StatusCode::BAD_REQUEST,
Json(ProjectErrorResponse {
error: format!(
"Invalid access class '{}'. Available: {}",
access_class, available
),
suggestions: None,
}),
));
}
updated_project =
projects::update_access_class(&state.db_pool, updated_project.id, access_class)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to update project access class: {}", e),
suggestions: None,
}),
)
})?;
}
if let Some(app_users) = payload.app_users {
let mut tx = state.db_pool.begin().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to start transaction: {}", e),
suggestions: None,
}),
)
})?;
let existing_users = crate::db::project_app_users::list_users(&mut *tx, updated_project.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to list existing app users: {}", e),
suggestions: None,
}),
)
})?;
for user_id in existing_users {
crate::db::project_app_users::remove_user(&mut *tx, updated_project.id, user_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to remove app user: {}", e),
suggestions: None,
}),
)
})?;
}
for user_identifier in &app_users {
let user_id = resolve_user_identifier(&state.db_pool, user_identifier)
.await
.map_err(|e| {
(
e.status,
Json(ProjectErrorResponse {
error: e.message,
suggestions: None,
}),
)
})?;
crate::db::project_app_users::add_user(&mut *tx, updated_project.id, user_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to add app user: {}", e),
suggestions: None,
}),
)
})?;
}
tx.commit().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to commit transaction: {}", e),
suggestions: None,
}),
)
})?;
}
if let Some(app_teams) = payload.app_teams {
let mut tx = state.db_pool.begin().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to start transaction: {}", e),
suggestions: None,
}),
)
})?;
let existing_teams = crate::db::project_app_users::list_teams(&mut *tx, updated_project.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to list existing app teams: {}", e),
suggestions: None,
}),
)
})?;
for team_id in existing_teams {
crate::db::project_app_users::remove_team(&mut *tx, updated_project.id, team_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to remove app team: {}", e),
suggestions: None,
}),
)
})?;
}
for team_identifier in &app_teams {
let team_id = resolve_team_identifier(&state.db_pool, team_identifier)
.await
.map_err(|e| {
(
e.status,
Json(ProjectErrorResponse {
error: e.message,
suggestions: None,
}),
)
})?;
crate::db::project_app_users::add_team(&mut *tx, updated_project.id, team_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to add app team: {}", e),
suggestions: None,
}),
)
})?;
}
tx.commit().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to commit transaction: {}", e),
suggestions: None,
}),
)
})?;
}
if let Some(status) = payload.status {
updated_project = projects::update_status(
&state.db_pool,
updated_project.id,
crate::db::models::ProjectStatus::from(status),
)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to update project status: {}", e),
suggestions: None,
}),
)
})?;
}
let owner_info = resolve_owner_info(&state, &updated_project)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to resolve owner info: {}", e),
suggestions: None,
}),
)
})?;
Ok(Json(UpdateProjectResponse {
project: convert_project(updated_project, owner_info),
}))
}
pub async fn delete_project(
State(state): State<AppState>,
Extension(user): Extension<User>,
Path(id_or_name): Path<String>,
Query(params): Query<GetProjectParams>,
) -> Result<StatusCode, (StatusCode, Json<ProjectErrorResponse>)> {
let project = resolve_project(&state, &id_or_name, params.by_id).await?;
let can_write = check_write_permission(&state, &project, &user)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check permissions: {}", e),
suggestions: None,
}),
)
})?;
if !can_write {
return Err((
StatusCode::FORBIDDEN,
Json(ProjectErrorResponse {
error: "You do not have permission to delete this project".to_string(),
suggestions: None,
}),
));
}
let is_sa = service_accounts::is_service_account(&state.db_pool, user.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to check service account status: {}", e),
suggestions: None,
}),
)
})?;
if is_sa {
return Err((
StatusCode::FORBIDDEN,
Json(ProjectErrorResponse {
error: "Service accounts cannot modify projects".to_string(),
suggestions: None,
}),
));
}
if project.status == crate::db::models::ProjectStatus::Deleting {
return Ok(StatusCode::ACCEPTED);
}
projects::mark_deleting(&state.db_pool, project.id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ProjectErrorResponse {
error: format!("Failed to mark project for deletion: {}", e),
suggestions: None,
}),
)
})?;
tracing::info!("Project {} marked for deletion", project.name);
Ok(StatusCode::ACCEPTED)
}
async fn query_project_by_id(
state: &AppState,
project_id: &str,
) -> Result<crate::db::models::Project, String> {
let uuid = Uuid::parse_str(project_id).map_err(|e| format!("Invalid project ID: {}", e))?;
projects::find_by_id(&state.db_pool, uuid)
.await
.map_err(|e| format!("Project not found: {}", e))?
.ok_or_else(|| "Project not found".to_string())
}
async fn query_project_by_name(
state: &AppState,
project_name: &str,
) -> Result<crate::db::models::Project, String> {
tracing::info!("Querying project by name: {}", project_name);
projects::find_by_name(&state.db_pool, project_name)
.await
.map_err(|e| format!("Failed to query project by name: {}", e))?
.ok_or_else(|| format!("Project '{}' not found", project_name))
}
async fn resolve_owner_info(
state: &AppState,
project: &crate::db::models::Project,
) -> Result<Option<OwnerInfo>, String> {
if let Some(user_id) = project.owner_user_id {
let user = db_users::find_by_id(&state.db_pool, user_id)
.await
.map_err(|e| format!("Failed to fetch user: {}", e))?
.ok_or_else(|| "Owner user not found".to_string())?;
Ok(Some(OwnerInfo::User(UserInfo {
id: user.id.to_string(),
email: user.email,
})))
} else if let Some(team_id) = project.owner_team_id {
let team = db_teams::find_by_id(&state.db_pool, team_id)
.await
.map_err(|e| format!("Failed to fetch team: {}", e))?
.ok_or_else(|| "Owner team not found".to_string())?;
Ok(Some(OwnerInfo::Team(TeamInfo {
id: team.id.to_string(),
name: team.name,
})))
} else {
Ok(None)
}
}
async fn resolve_project(
state: &AppState,
id_or_name: &str,
by_id: bool,
) -> Result<crate::db::models::Project, (StatusCode, Json<ProjectErrorResponse>)> {
tracing::info!("Resolving project '{}', by_id={}", id_or_name, by_id);
let project = if by_id {
tracing::info!("Using explicit ID lookup");
query_project_by_id(state, id_or_name).await.map_err(|e| {
(
StatusCode::NOT_FOUND,
Json(ProjectErrorResponse {
error: e,
suggestions: None,
}),
)
})?
} else {
tracing::info!("Trying name lookup first, will fallback to ID");
match query_project_by_name(state, id_or_name).await {
Ok(project) => project,
Err(e) => {
tracing::info!("Name lookup failed: {}, trying ID fallback", e);
query_project_by_id(state, id_or_name).await.map_err(|_e| {
tracing::info!("Both lookups failed, generating fuzzy suggestions");
let all_projects = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(projects::list(&state.db_pool, None))
});
let suggestions = match all_projects {
Ok(all_projects) => {
let api_projects: Vec<ApiProject> = all_projects
.into_iter()
.map(|p| convert_project(p, None))
.collect();
let similar = find_similar_projects(id_or_name, &api_projects, 0.85);
if similar.is_empty() {
None
} else {
Some(similar)
}
}
Err(_) => None,
};
(
StatusCode::NOT_FOUND,
Json(ProjectErrorResponse {
error: format!("Project '{}' not found", id_or_name),
suggestions,
}),
)
})?
}
}
};
Ok(project)
}
async fn load_app_users_for_project(
state: &AppState,
project_id: uuid::Uuid,
) -> Result<(Vec<UserInfo>, Vec<TeamInfo>), String> {
let user_ids = crate::db::project_app_users::list_users(&state.db_pool, project_id)
.await
.map_err(|e| format!("Failed to list app users: {}", e))?;
let team_ids = crate::db::project_app_users::list_teams(&state.db_pool, project_id)
.await
.map_err(|e| format!("Failed to list app teams: {}", e))?;
let users = if !user_ids.is_empty() {
let users_map = db_users::get_users_batch(&state.db_pool, &user_ids)
.await
.map_err(|e| format!("Failed to batch fetch users: {}", e))?;
user_ids
.into_iter()
.filter_map(|id| {
users_map.get(&id).map(|u| UserInfo {
id: u.id.to_string(),
email: u.email.clone(),
})
})
.collect()
} else {
Vec::new()
};
let teams = if !team_ids.is_empty() {
let teams_map = db_teams::get_teams_batch(&state.db_pool, &team_ids)
.await
.map_err(|e| format!("Failed to batch fetch teams: {}", e))?;
team_ids
.into_iter()
.filter_map(|id| {
teams_map.get(&id).map(|t| TeamInfo {
id: t.id.to_string(),
name: t.name.clone(),
})
})
.collect()
} else {
Vec::new()
};
Ok((users, teams))
}
fn convert_project(project: crate::db::models::Project, owner: Option<OwnerInfo>) -> ApiProject {
ApiProject {
id: project.id.to_string(),
created: project.created_at.to_rfc3339(),
updated: project.updated_at.to_rfc3339(),
name: project.name,
status: ProjectStatus::from(project.status),
access_class: project.access_class,
owner,
active_deployment_status: None, default_url: None, primary_url: None, custom_domain_urls: vec![], deployment_groups: None, finalizers: project.finalizers.clone(),
app_users: vec![], app_teams: vec![], }
}
pub async fn check_read_permission(
state: &AppState,
project: &crate::db::models::Project,
user: &User,
) -> Result<bool, String> {
if state.is_admin(&user.email) {
return Ok(true);
}
projects::user_can_access(&state.db_pool, project.id, user.id)
.await
.map_err(|e| format!("Failed to check access: {}", e))
}
pub async fn check_write_permission(
state: &AppState,
project: &crate::db::models::Project,
user: &User,
) -> Result<bool, String> {
if state.is_admin(&user.email) {
return Ok(true);
}
projects::user_can_access(&state.db_pool, project.id, user.id)
.await
.map_err(|e| format!("Failed to check access: {}", e))
}