diff --git a/api/auth/change_password.rs b/api/auth/change_password.rs new file mode 100644 index 0000000..6102997 --- /dev/null +++ b/api/auth/change_password.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::auth::change_password::ChangePasswordParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/auth/password/change", + tag = "Auth", + operation_id = "authChangePassword", + request_body(content = ChangePasswordParams, description = "Password change parameters (passwords encrypted with session RSA public key)", content_type = "application/json"), + responses( + (status = 200, description = "Password changed successfully", body = ApiEmptyResponse), + (status = 400, description = "Invalid password", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn change_password( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + service + .auth + .auth_change_password(&session, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password changed successfully"))) +} diff --git a/api/auth/mod.rs b/api/auth/mod.rs index 25d4607..a67df05 100644 --- a/api/auth/mod.rs +++ b/api/auth/mod.rs @@ -1,4 +1,5 @@ pub mod captcha; +pub mod change_password; pub mod disable_2fa; pub mod enable_2fa; pub mod get_2fa_status; @@ -52,6 +53,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route( "/2fa/backup-codes/regenerate", web::post().to(regenerate_2fa_backup_codes::handle), + ) + .route( + "/password/change", + web::post().to(change_password::change_password), ), ); } diff --git a/api/im/category_create.rs b/api/im/category_create.rs new file mode 100644 index 0000000..6fc7e84 --- /dev/null +++ b/api/im/category_create.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelCategory; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::categories::CreateCategoryParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, +} + +#[utoipa::path( + post, + path = "/api/v1/im/workspaces/{workspace_name}/categories", + tag = "IM", + operation_id = "imCategoryCreate", + params(PathParams), + request_body( + content = CreateCategoryParams, + description = "Category creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Category created successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn category_create( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .category_create(&im_session, &path.workspace_name, params.into_inner()) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/im/category_delete.rs b/api/im/category_delete.rs new file mode 100644 index 0000000..ef5d9e8 --- /dev/null +++ b/api/im/category_delete.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub category_id: uuid::Uuid, +} + +/// Delete a category +#[utoipa::path( + delete, + path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}", + tag = "IM", + operation_id = "imCategoryDelete", + params(PathParams), + responses( + (status = 200, description = "Category deleted successfully", body = ApiEmptyResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or category not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn category_delete( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + service + .im + .category_delete(&im_session, &path.workspace_name, path.category_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Category deleted"))) +} diff --git a/api/im/category_list.rs b/api/im/category_list.rs new file mode 100644 index 0000000..a2d609e --- /dev/null +++ b/api/im/category_list.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelCategory; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, +} + +/// List categories +#[utoipa::path( + get, + path = "/api/v1/im/workspaces/{workspace_name}/categories", + tag = "IM", + operation_id = "imCategoryList", + params(PathParams), + responses( + (status = 200, description = "Categories listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn category_list( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .category_list(&im_session, &path.workspace_name) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/im/category_update.rs b/api/im/category_update.rs new file mode 100644 index 0000000..8912534 --- /dev/null +++ b/api/im/category_update.rs @@ -0,0 +1,58 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelCategory; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::categories::UpdateCategoryParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub category_id: uuid::Uuid, +} + +/// Update a category +#[utoipa::path( + put, + path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}", + tag = "IM", + operation_id = "imCategoryUpdate", + params(PathParams), + request_body( + content = UpdateCategoryParams, + description = "Category update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Category updated successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or category not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn category_update( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .category_update( + &im_session, + &path.workspace_name, + path.category_id, + params.into_inner(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/im/channel_create.rs b/api/im/channel_create.rs new file mode 100644 index 0000000..f0624e0 --- /dev/null +++ b/api/im/channel_create.rs @@ -0,0 +1,65 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info::resolve_users; +use crate::models::channels::ChannelDetail; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::channels::CreateChannelParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, +} + +/// Create a channel +#[utoipa::path( + post, + path = "/api/v1/im/workspaces/{workspace_name}/channels", + tag = "IM", + operation_id = "imChannelCreate", + params(PathParams), + request_body( + content = CreateChannelParams, + description = "Channel creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Channel created successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn channel_create( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let request_id = uuid::Uuid::now_v7(); + let channel = service + .im + .channel_create( + &im_session, + &path.workspace_name, + params.into_inner(), + request_id, + ) + .await?; + + let db = &service.ctx.db; + let users = resolve_users(db, &[channel.created_by]).await?; + let creator = users.get(&channel.created_by).cloned().unwrap_or_default(); + let detail = channel.into_detail(creator); + + Ok(HttpResponse::Created().json(ApiResponse::new(detail))) +} diff --git a/api/im/channel_delete.rs b/api/im/channel_delete.rs new file mode 100644 index 0000000..2d2bb31 --- /dev/null +++ b/api/im/channel_delete.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Delete a channel +#[utoipa::path( + delete, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}", + tag = "IM", + operation_id = "imChannelDelete", + params(PathParams), + responses( + (status = 200, description = "Channel deleted successfully", body = ApiEmptyResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn channel_delete( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let request_id = uuid::Uuid::now_v7(); + service + .im + .channel_delete( + &im_session, + &path.workspace_name, + path.channel_id, + request_id, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Channel deleted"))) +} diff --git a/api/im/channel_get.rs b/api/im/channel_get.rs new file mode 100644 index 0000000..4ace0c9 --- /dev/null +++ b/api/im/channel_get.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info::resolve_users; +use crate::models::channels::ChannelDetail; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Get a channel +#[utoipa::path( + get, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}", + tag = "IM", + operation_id = "imChannelGet", + params(PathParams), + responses( + (status = 200, description = "Channel retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn channel_get( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let channel = service + .im + .channel_get(&im_session, &path.workspace_name, path.channel_id) + .await?; + + let db = &service.ctx.db; + let users = resolve_users(db, &[channel.created_by]).await?; + let creator = users.get(&channel.created_by).cloned().unwrap_or_default(); + let detail = channel.into_detail(creator); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) +} diff --git a/api/im/channel_list.rs b/api/im/channel_list.rs new file mode 100644 index 0000000..5470d5d --- /dev/null +++ b/api/im/channel_list.rs @@ -0,0 +1,82 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info::resolve_users; +use crate::models::channels::ChannelDetail; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::channels::ChannelListFilters; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub channel_type: Option, + pub channel_kind: Option, + pub category_id: Option, + pub archived: Option, + pub limit: Option, + pub offset: Option, +} + +/// List channels +#[utoipa::path( + get, + path = "/api/v1/im/workspaces/{workspace_name}/channels", + tag = "IM", + operation_id = "imChannelList", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Channels listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn channel_list( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let filters = ChannelListFilters { + channel_type: query.channel_type.clone(), + channel_kind: query.channel_kind.clone(), + category_id: query.category_id, + archived: query.archived, + }; + let result = service + .im + .channel_list( + &im_session, + &path.workspace_name, + filters, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + + let db = &service.ctx.db; + let creator_ids: Vec = result.iter().map(|c| c.created_by).collect(); + let users = resolve_users(db, &creator_ids).await?; + let details: Vec = result + .into_iter() + .map(|c| { + let creator = users.get(&c.created_by).cloned().unwrap_or_default(); + c.into_detail(creator) + }) + .collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) +} diff --git a/api/im/channel_update.rs b/api/im/channel_update.rs new file mode 100644 index 0000000..1f06c05 --- /dev/null +++ b/api/im/channel_update.rs @@ -0,0 +1,67 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info::resolve_users; +use crate::models::channels::ChannelDetail; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::channels::UpdateChannelParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Update a channel +#[utoipa::path( + put, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}", + tag = "IM", + operation_id = "imChannelUpdate", + params(PathParams), + request_body( + content = UpdateChannelParams, + description = "Channel update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Channel updated successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn channel_update( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let request_id = uuid::Uuid::now_v7(); + let channel = service + .im + .channel_update( + &im_session, + &path.workspace_name, + path.channel_id, + params.into_inner(), + request_id, + ) + .await?; + + let db = &service.ctx.db; + let users = resolve_users(db, &[channel.created_by]).await?; + let creator = users.get(&channel.created_by).cloned().unwrap_or_default(); + let detail = channel.into_detail(creator); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) +} diff --git a/api/im/member_invite.rs b/api/im/member_invite.rs new file mode 100644 index 0000000..302ef58 --- /dev/null +++ b/api/im/member_invite.rs @@ -0,0 +1,58 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelMember; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::members::InviteMemberParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Invite a member +#[utoipa::path( + post, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members", + tag = "IM", + operation_id = "imMemberInvite", + params(PathParams), + request_body( + content = InviteMemberParams, + description = "Invitation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Member invited successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_invite( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .member_invite( + &im_session, + &path.workspace_name, + path.channel_id, + params.into_inner(), + ) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/im/member_join.rs b/api/im/member_join.rs new file mode 100644 index 0000000..a383400 --- /dev/null +++ b/api/im/member_join.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelMember; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Join a channel +#[utoipa::path( + post, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/join", + tag = "IM", + operation_id = "imMemberJoin", + params(PathParams), + responses( + (status = 200, description = "Joined channel successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_join( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .member_join(&im_session, &path.workspace_name, path.channel_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/im/member_kick.rs b/api/im/member_kick.rs new file mode 100644 index 0000000..a6a4b95 --- /dev/null +++ b/api/im/member_kick.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, + pub user_id: uuid::Uuid, +} + +/// Kick a member +#[utoipa::path( + delete, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}", + tag = "IM", + operation_id = "imMemberKick", + params(PathParams), + responses( + (status = 200, description = "Member kicked successfully", body = ApiEmptyResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_kick( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + service + .im + .member_kick( + &im_session, + &path.workspace_name, + path.channel_id, + path.user_id, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Member kicked"))) +} diff --git a/api/im/member_leave.rs b/api/im/member_leave.rs new file mode 100644 index 0000000..4860bd8 --- /dev/null +++ b/api/im/member_leave.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +/// Leave a channel +#[utoipa::path( + post, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/leave", + tag = "IM", + operation_id = "imMemberLeave", + params(PathParams), + responses( + (status = 200, description = "Left channel successfully", body = ApiEmptyResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_leave( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + service + .im + .member_leave(&im_session, &path.workspace_name, path.channel_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Left channel"))) +} diff --git a/api/im/member_list.rs b/api/im/member_list.rs new file mode 100644 index 0000000..108aba7 --- /dev/null +++ b/api/im/member_list.rs @@ -0,0 +1,58 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelMember; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List channel members +#[utoipa::path( + get, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members", + tag = "IM", + operation_id = "imMemberList", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Members listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace or channel not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_list( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .member_list( + &im_session, + &path.workspace_name, + path.channel_id, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/im/member_update.rs b/api/im/member_update.rs new file mode 100644 index 0000000..5a30e8b --- /dev/null +++ b/api/im/member_update.rs @@ -0,0 +1,60 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::channels::ChannelMember; +use crate::service::AppService; +use crate::service::im::ImSession; +use crate::service::im::members::UpdateMemberParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub channel_id: uuid::Uuid, + pub user_id: uuid::Uuid, +} + +/// Update member role +#[utoipa::path( + put, + path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}", + tag = "IM", + operation_id = "imMemberUpdate", + params(PathParams), + request_body( + content = UpdateMemberParams, + description = "Member update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Member updated successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn member_update( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = session.user().ok_or(AppError::Unauthorized)?; + let im_session = ImSession::new(user_id); + let result = service + .im + .member_update( + &im_session, + &path.workspace_name, + path.channel_id, + path.user_id, + params.into_inner(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/im/mod.rs b/api/im/mod.rs new file mode 100644 index 0000000..26dd73e --- /dev/null +++ b/api/im/mod.rs @@ -0,0 +1,77 @@ +pub mod category_create; +pub mod category_delete; +pub mod category_list; +pub mod category_update; +pub mod channel_create; +pub mod channel_delete; +pub mod channel_get; +pub mod channel_list; +pub mod channel_update; +pub mod member_invite; +pub mod member_join; +pub mod member_kick; +pub mod member_leave; +pub mod member_list; +pub mod member_update; + +use actix_web::web; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/im/workspaces/{workspace_name}") + // Channels + .route("/channels", web::get().to(channel_list::channel_list)) + .route("/channels", web::post().to(channel_create::channel_create)) + .route( + "/channels/{channel_id}", + web::get().to(channel_get::channel_get), + ) + .route( + "/channels/{channel_id}", + web::put().to(channel_update::channel_update), + ) + .route( + "/channels/{channel_id}", + web::delete().to(channel_delete::channel_delete), + ) + // Members + .route( + "/channels/{channel_id}/members", + web::get().to(member_list::member_list), + ) + .route( + "/channels/{channel_id}/members", + web::post().to(member_invite::member_invite), + ) + .route( + "/channels/{channel_id}/members/{user_id}", + web::put().to(member_update::member_update), + ) + .route( + "/channels/{channel_id}/members/{user_id}", + web::delete().to(member_kick::member_kick), + ) + .route( + "/channels/{channel_id}/join", + web::post().to(member_join::member_join), + ) + .route( + "/channels/{channel_id}/leave", + web::post().to(member_leave::member_leave), + ) + // Categories + .route("/categories", web::get().to(category_list::category_list)) + .route( + "/categories", + web::post().to(category_create::category_create), + ) + .route( + "/categories/{category_id}", + web::put().to(category_update::category_update), + ) + .route( + "/categories/{category_id}", + web::delete().to(category_delete::category_delete), + ), + ); +} diff --git a/api/internal/issue_api_key.rs b/api/internal/issue_api_key.rs new file mode 100644 index 0000000..590184a --- /dev/null +++ b/api/internal/issue_api_key.rs @@ -0,0 +1,72 @@ +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use crate::api::response::ApiResponse; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct IssueApiKeyRequest { + pub service_name: String, + pub scopes: Vec, + pub ttl_hours: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct IssueApiKeyResponse { + pub api_key: String, + pub service_name: String, + pub service_id: String, + pub scopes: Vec, + pub expires_at: i64, +} + +#[utoipa::path( + post, + path = "/api/v1/internal/api-keys", + tag = "Internal", + operation_id = "internalIssueApiKey", + request_body = IssueApiKeyRequest, + responses( + (status = 200, description = "API key issued", body = ApiResponse), + (status = 401, description = "Authentication required"), + (status = 403, description = "Admin permission required"), + ), + security(("session_cookie" = [])) +)] +pub async fn issue_api_key( + session: Session, + service: web::Data, + body: web::Json, +) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + + let is_owner: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL)", + ) + .bind(user_uid) + .fetch_one(service.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !is_owner { + return Err(AppError::Forbidden( + "workspace owner permission required".into(), + )); + } + + let ttl_secs = body.ttl_hours.map(|h| h * 3600); + let (api_key, identity) = service + .internal_auth + .issue_api_key(&body.service_name, body.scopes.clone(), ttl_secs) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(IssueApiKeyResponse { + api_key, + service_name: identity.service_name, + service_id: identity.service_id, + scopes: identity.scopes, + expires_at: identity.expires_at, + }))) +} diff --git a/api/internal/mod.rs b/api/internal/mod.rs new file mode 100644 index 0000000..7bf258e --- /dev/null +++ b/api/internal/mod.rs @@ -0,0 +1,10 @@ +pub mod issue_api_key; + +use actix_web::web; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/internal") + .route("/api-keys", web::post().to(issue_api_key::issue_api_key)), + ); +} diff --git a/api/issue/create.rs b/api/issue/create.rs index b511bbd..7f2fd86 100644 --- a/api/issue/create.rs +++ b/api/issue/create.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::issues::Issue; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::issues::IssueDetail; use crate::service::AppService; use crate::service::issues::core::CreateIssueParams; use crate::session::Session; @@ -50,7 +51,7 @@ pub struct PathParams { content_type = "application/json" ), responses( - (status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse), + (status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse), (status = 400, description = "Invalid parameters: empty title, invalid repository/label/milestone references", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse), @@ -71,5 +72,11 @@ pub async fn create( .issue .issue_create(&session, &path.workspace_name, params.into_inner()) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(issue))) + let author_id = issue.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Created().json(ApiResponse::new(issue.into_detail(author)))) } diff --git a/api/issue/create_comment.rs b/api/issue/create_comment.rs index 663242d..67a23d4 100644 --- a/api/issue/create_comment.rs +++ b/api/issue/create_comment.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::issues::IssueComment; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::issues::IssueCommentDetail; use crate::service::AppService; use crate::service::issues::comments::CreateCommentParams; use crate::session::Session; @@ -38,7 +39,7 @@ pub struct PathParams { params(PathParams), request_body(content = CreateCommentParams, description = "Comment creation parameters", content_type = "application/json"), responses( - (status = 201, description = "Comment created successfully.", body = ApiResponse), + (status = 201, description = "Comment created successfully.", body = ApiResponse), (status = 400, description = "Invalid parameters: empty body or issue is locked", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (issue locked and user lacks write access)", body = ApiErrorResponse), @@ -62,5 +63,11 @@ pub async fn create_comment( params.into_inner(), ) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(comment))) + let author_id = comment.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Created().json(ApiResponse::new(comment.into_detail(author)))) } diff --git a/api/issue/get.rs b/api/issue/get.rs index e7b7b59..6527f73 100644 --- a/api/issue/get.rs +++ b/api/issue/get.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::issues::Issue; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::issues::IssueDetail; use crate::service::AppService; use crate::session::Session; @@ -27,7 +28,7 @@ pub struct PathParams { operation_id = "issueGet", params(PathParams), responses( - (status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse), + (status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse), (status = 404, description = "Workspace or issue not found", body = ApiErrorResponse), @@ -46,5 +47,11 @@ pub async fn get( .issue .issue_get(&session, &path.workspace_name, path.number) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(issue))) + let author_id = issue.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Ok().json(ApiResponse::new(issue.into_detail(author)))) } diff --git a/api/issue/list.rs b/api/issue/list.rs index 952d6f4..420e2ef 100644 --- a/api/issue/list.rs +++ b/api/issue/list.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::issues::Issue; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::issues::IssueDetail; use crate::service::AppService; use crate::service::issues::core::IssueListFilters; use crate::session::Session; @@ -48,7 +49,7 @@ pub struct QueryParams { operation_id = "issueList", params(PathParams, QueryParams), responses( - (status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse>), + (status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 404, description = "Workspace not found", body = ApiErrorResponse), (status = 500, description = "Internal server error", body = ApiErrorResponse), @@ -81,5 +82,17 @@ pub async fn list( query.offset.unwrap_or(0), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(issues))) + let user_ids: Vec<_> = issues.iter().map(|i| i.author_id).collect(); + let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?; + let details: Vec = issues + .into_iter() + .map(|i| { + let author = users + .get(&i.author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(i.author_id)); + i.into_detail(author) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } diff --git a/api/issue/list_comments.rs b/api/issue/list_comments.rs index e2e62c8..7c96fa1 100644 --- a/api/issue/list_comments.rs +++ b/api/issue/list_comments.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::issues::IssueComment; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::issues::IssueCommentDetail; use crate::service::AppService; use crate::session::Session; @@ -31,7 +32,7 @@ pub struct QueryParams { operation_id = "issueListComments", params(PathParams, QueryParams), responses( - (status = 200, description = "Comments listed successfully.", body = ApiResponse>), + (status = 200, description = "Comments listed successfully.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse), (status = 404, description = "Workspace or issue not found", body = ApiErrorResponse), @@ -55,5 +56,17 @@ pub async fn list_comments( query.offset.unwrap_or(0), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(comments))) + let user_ids: Vec<_> = comments.iter().map(|c| c.author_id).collect(); + let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?; + let details: Vec = comments + .into_iter() + .map(|c| { + let author = users + .get(&c.author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(c.author_id)); + c.into_detail(author) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } diff --git a/api/issue/mod.rs b/api/issue/mod.rs index 67a37e7..5833321 100644 --- a/api/issue/mod.rs +++ b/api/issue/mod.rs @@ -36,7 +36,7 @@ use actix_web::web; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/issues") + web::scope("") // Core .route("", web::get().to(list::list)) .route("", web::post().to(create::create)) @@ -144,7 +144,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { pub fn configure_repo_level(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/issues") + web::scope("") .route("/labels", web::get().to(list_labels::list_labels)) .route("/labels", web::post().to(create_label::create_label)) .route( diff --git a/api/mod.rs b/api/mod.rs index 53c9f6b..1c669a0 100644 --- a/api/mod.rs +++ b/api/mod.rs @@ -1,5 +1,8 @@ pub mod auth; +pub mod im; +pub mod internal; pub mod issue; +pub mod notify; pub mod openapi; pub mod pr; pub mod repo; diff --git a/api/notify/clear_all_notifications.rs b/api/notify/clear_all_notifications.rs new file mode 100644 index 0000000..3c30f16 --- /dev/null +++ b/api/notify/clear_all_notifications.rs @@ -0,0 +1,29 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +/// Clear all notifications (dismiss all) +#[utoipa::path( + delete, + path = "/api/v1/notifications", + tag = "Notifications", + operation_id = "notificationClearAll", + responses( + (status = 200, description = "All notifications cleared", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn clear_all_notifications( + service: web::Data, + session: Session, +) -> Result { + let result = service.notify.clear_all_notifications(&session).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/create_block.rs b/api/notify/create_block.rs new file mode 100644 index 0000000..6f7215a --- /dev/null +++ b/api/notify/create_block.rs @@ -0,0 +1,40 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationBlock; +use crate::service::AppService; +use crate::service::notify::blocks::CreateBlockParams; +use crate::session::Session; + +/// Create a notification block +#[utoipa::path( + post, + path = "/api/v1/notifications/blocks", + tag = "Notifications", + operation_id = "notificationCreateBlock", + request_body( + content = CreateBlockParams, + description = "Block creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Block created", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn create_block( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let result = service + .notify + .create_block(&session, params.into_inner()) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/notify/create_subscription.rs b/api/notify/create_subscription.rs new file mode 100644 index 0000000..ae09965 --- /dev/null +++ b/api/notify/create_subscription.rs @@ -0,0 +1,40 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationSubscription; +use crate::service::AppService; +use crate::service::notify::subscriptions::CreateSubscriptionParams; +use crate::session::Session; + +/// Create a notification subscription +#[utoipa::path( + post, + path = "/api/v1/notifications/subscriptions", + tag = "Notifications", + operation_id = "notificationCreateSubscription", + request_body( + content = CreateSubscriptionParams, + description = "Subscription creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Subscription created", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn create_subscription( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let result = service + .notify + .create_subscription(&session, params.into_inner()) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/notify/create_template.rs b/api/notify/create_template.rs new file mode 100644 index 0000000..8010ff6 --- /dev/null +++ b/api/notify/create_template.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationTemplate; +use crate::service::AppService; +use crate::service::notify::templates::CreateTemplateParams; +use crate::session::Session; + +/// Create a notification template (requires system admin) +#[utoipa::path( + post, + path = "/api/v1/notifications/templates", + tag = "Notifications", + operation_id = "notificationCreateTemplate", + request_body( + content = CreateTemplateParams, + description = "Template creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Template created", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 403, description = "System admin access required", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn create_template( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let result = service + .notify + .create_template(&session, params.into_inner()) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/notify/delete_block.rs b/api/notify/delete_block.rs new file mode 100644 index 0000000..da0bd40 --- /dev/null +++ b/api/notify/delete_block.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub block_id: uuid::Uuid, +} + +/// Delete a notification block +#[utoipa::path( + delete, + path = "/api/v1/notifications/blocks/{block_id}", + tag = "Notifications", + operation_id = "notificationDeleteBlock", + params(PathParams), + responses( + (status = 200, description = "Block deleted", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Block not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn delete_block( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service.notify.delete_block(&session, path.block_id).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("Block deleted".to_string()))) +} diff --git a/api/notify/delete_notification.rs b/api/notify/delete_notification.rs new file mode 100644 index 0000000..38c0f9d --- /dev/null +++ b/api/notify/delete_notification.rs @@ -0,0 +1,42 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub notification_id: uuid::Uuid, +} + +/// Delete a notification +#[utoipa::path( + delete, + path = "/api/v1/notifications/{notification_id}", + tag = "Notifications", + operation_id = "notificationDelete", + params(PathParams), + responses( + (status = 200, description = "Notification deleted", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Notification not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn delete_notification( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .notify + .delete_notification(&session, path.notification_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("Notification deleted".to_string()))) +} diff --git a/api/notify/delete_subscription.rs b/api/notify/delete_subscription.rs new file mode 100644 index 0000000..684e73b --- /dev/null +++ b/api/notify/delete_subscription.rs @@ -0,0 +1,42 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub subscription_id: uuid::Uuid, +} + +/// Delete a notification subscription +#[utoipa::path( + delete, + path = "/api/v1/notifications/subscriptions/{subscription_id}", + tag = "Notifications", + operation_id = "notificationDeleteSubscription", + params(PathParams), + responses( + (status = 200, description = "Subscription deleted", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Subscription not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn delete_subscription( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .notify + .delete_subscription(&session, path.subscription_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("Subscription deleted".to_string()))) +} diff --git a/api/notify/delete_template.rs b/api/notify/delete_template.rs new file mode 100644 index 0000000..5d3349a --- /dev/null +++ b/api/notify/delete_template.rs @@ -0,0 +1,43 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub template_id: uuid::Uuid, +} + +/// Delete a notification template (requires system admin) +#[utoipa::path( + delete, + path = "/api/v1/notifications/templates/{template_id}", + tag = "Notifications", + operation_id = "notificationDeleteTemplate", + params(PathParams), + responses( + (status = 200, description = "Template deleted", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 403, description = "System admin access required", body = ApiErrorResponse), + (status = 404, description = "Template not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn delete_template( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .notify + .delete_template(&session, path.template_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string()))) +} diff --git a/api/notify/dismiss_notification.rs b/api/notify/dismiss_notification.rs new file mode 100644 index 0000000..2784576 --- /dev/null +++ b/api/notify/dismiss_notification.rs @@ -0,0 +1,66 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info; +use crate::models::notifications::NotificationDetail; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub notification_id: uuid::Uuid, +} + +/// Dismiss a notification +#[utoipa::path( + post, + path = "/api/v1/notifications/{notification_id}/dismiss", + tag = "Notifications", + operation_id = "notificationDismiss", + params(PathParams), + responses( + (status = 200, description = "Notification dismissed", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Notification not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn dismiss_notification( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let notification = service + .notify + .dismiss_notification(&session, path.notification_id) + .await?; + + let actor = match notification.actor_id { + Some(id) => base_info::resolve_users(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + let workspace = match notification.workspace_id { + Some(id) => base_info::resolve_workspaces(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + let repo = match notification.repo_id { + Some(id) => base_info::resolve_repos(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + + Ok(HttpResponse::Ok().json(ApiResponse::new( + notification.into_detail(actor, workspace, repo), + ))) +} diff --git a/api/notify/get_template.rs b/api/notify/get_template.rs new file mode 100644 index 0000000..49b5a80 --- /dev/null +++ b/api/notify/get_template.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationTemplate; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub template_id: uuid::Uuid, +} + +/// Get a notification template by ID (requires system admin) +#[utoipa::path( + get, + path = "/api/v1/notifications/templates/{template_id}", + tag = "Notifications", + operation_id = "notificationGetTemplate", + params(PathParams), + responses( + (status = 200, description = "Template retrieved", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 403, description = "System admin access required", body = ApiErrorResponse), + (status = 404, description = "Template not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn get_template( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .notify + .get_template(&session, path.template_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/get_unread_count.rs b/api/notify/get_unread_count.rs new file mode 100644 index 0000000..d676a58 --- /dev/null +++ b/api/notify/get_unread_count.rs @@ -0,0 +1,29 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +/// Get unread notification count for the current user +#[utoipa::path( + get, + path = "/api/v1/notifications/unread-count", + tag = "Notifications", + operation_id = "notificationUnreadCount", + responses( + (status = 200, description = "Unread count returned successfully", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn get_unread_count( + service: web::Data, + session: Session, +) -> Result { + let result = service.notify.count_unread(&session).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/list_blocks.rs b/api/notify/list_blocks.rs new file mode 100644 index 0000000..a89020d --- /dev/null +++ b/api/notify/list_blocks.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationBlock; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List notification blocks for the current user +#[utoipa::path( + get, + path = "/api/v1/notifications/blocks", + tag = "Notifications", + operation_id = "notificationListBlocks", + params(QueryParams), + responses( + (status = 200, description = "Blocks listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_blocks( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let result = service + .notify + .list_blocks( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/list_deliveries.rs b/api/notify/list_deliveries.rs new file mode 100644 index 0000000..d519839 --- /dev/null +++ b/api/notify/list_deliveries.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationDelivery; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List notification deliveries for the current user +#[utoipa::path( + get, + path = "/api/v1/notifications/deliveries", + tag = "Notifications", + operation_id = "notificationListDeliveries", + params(QueryParams), + responses( + (status = 200, description = "Deliveries listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_deliveries( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let result = service + .notify + .list_deliveries( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/list_deliveries_for_notification.rs b/api/notify/list_deliveries_for_notification.rs new file mode 100644 index 0000000..295aa9d --- /dev/null +++ b/api/notify/list_deliveries_for_notification.rs @@ -0,0 +1,55 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationDelivery; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub notification_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List deliveries for a specific notification +#[utoipa::path( + get, + path = "/api/v1/notifications/{notification_id}/deliveries", + tag = "Notifications", + operation_id = "notificationListDeliveriesForNotification", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Deliveries listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Notification not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_deliveries_for_notification( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .notify + .list_deliveries_for_notification( + &session, + path.notification_id, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/list_notifications.rs b/api/notify/list_notifications.rs new file mode 100644 index 0000000..df5e53b --- /dev/null +++ b/api/notify/list_notifications.rs @@ -0,0 +1,72 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info; +use crate::models::notifications::NotificationDetail; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub unread_only: Option, + pub limit: Option, + pub offset: Option, +} + +/// List notifications for the current user +#[utoipa::path( + get, + path = "/api/v1/notifications", + tag = "Notifications", + operation_id = "notificationList", + params(QueryParams), + responses( + (status = 200, description = "Notifications listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_notifications( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let notifications = service + .notify + .list_notifications( + &session, + query.unread_only.unwrap_or(false), + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + + let actor_ids: Vec<_> = notifications.iter().filter_map(|n| n.actor_id).collect(); + let workspace_ids: Vec<_> = notifications + .iter() + .filter_map(|n| n.workspace_id) + .collect(); + let repo_ids: Vec<_> = notifications.iter().filter_map(|n| n.repo_id).collect(); + + let actors = base_info::resolve_users(&service.ctx.db, &actor_ids).await?; + let workspaces = base_info::resolve_workspaces(&service.ctx.db, &workspace_ids).await?; + let repos = base_info::resolve_repos(&service.ctx.db, &repo_ids).await?; + + let details: Vec = notifications + .into_iter() + .map(|n| { + let actor = n.actor_id.and_then(|id| actors.get(&id).cloned()); + let workspace = n.workspace_id.and_then(|id| workspaces.get(&id).cloned()); + let repo = n.repo_id.and_then(|id| repos.get(&id).cloned()); + n.into_detail(actor, workspace, repo) + }) + .collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) +} diff --git a/api/notify/list_subscriptions.rs b/api/notify/list_subscriptions.rs new file mode 100644 index 0000000..601e672 --- /dev/null +++ b/api/notify/list_subscriptions.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationSubscription; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List notification subscriptions for the current user +#[utoipa::path( + get, + path = "/api/v1/notifications/subscriptions", + tag = "Notifications", + operation_id = "notificationListSubscriptions", + params(QueryParams), + responses( + (status = 200, description = "Subscriptions listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_subscriptions( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let result = service + .notify + .list_subscriptions( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/list_templates.rs b/api/notify/list_templates.rs new file mode 100644 index 0000000..3e92a77 --- /dev/null +++ b/api/notify/list_templates.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationTemplate; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +/// List notification templates (requires system admin) +#[utoipa::path( + get, + path = "/api/v1/notifications/templates", + tag = "Notifications", + operation_id = "notificationListTemplates", + params(QueryParams), + responses( + (status = 200, description = "Templates listed successfully", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 403, description = "System admin access required", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_templates( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let result = service + .notify + .list_templates( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/mark_all_as_read.rs b/api/notify/mark_all_as_read.rs new file mode 100644 index 0000000..0c266e4 --- /dev/null +++ b/api/notify/mark_all_as_read.rs @@ -0,0 +1,29 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +/// Mark all notifications as read +#[utoipa::path( + put, + path = "/api/v1/notifications/read-all", + tag = "Notifications", + operation_id = "notificationMarkAllAsRead", + responses( + (status = 200, description = "All notifications marked as read", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn mark_all_as_read( + service: web::Data, + session: Session, +) -> Result { + let result = service.notify.mark_all_as_read(&session).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/mark_as_read.rs b/api/notify/mark_as_read.rs new file mode 100644 index 0000000..2763efa --- /dev/null +++ b/api/notify/mark_as_read.rs @@ -0,0 +1,66 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::base_info; +use crate::models::notifications::NotificationDetail; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub notification_id: uuid::Uuid, +} + +/// Mark a notification as read +#[utoipa::path( + put, + path = "/api/v1/notifications/{notification_id}/read", + tag = "Notifications", + operation_id = "notificationMarkAsRead", + params(PathParams), + responses( + (status = 200, description = "Notification marked as read", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Notification not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn mark_as_read( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let notification = service + .notify + .mark_as_read(&session, path.notification_id) + .await?; + + let actor = match notification.actor_id { + Some(id) => base_info::resolve_users(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + let workspace = match notification.workspace_id { + Some(id) => base_info::resolve_workspaces(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + let repo = match notification.repo_id { + Some(id) => base_info::resolve_repos(&service.ctx.db, &[id]) + .await? + .remove(&id), + None => None, + }; + + Ok(HttpResponse::Ok().json(ApiResponse::new( + notification.into_detail(actor, workspace, repo), + ))) +} diff --git a/api/notify/mod.rs b/api/notify/mod.rs new file mode 100644 index 0000000..d9e5f62 --- /dev/null +++ b/api/notify/mod.rs @@ -0,0 +1,108 @@ +pub mod clear_all_notifications; +pub mod create_block; +pub mod create_subscription; +pub mod create_template; +pub mod delete_block; +pub mod delete_notification; +pub mod delete_subscription; +pub mod delete_template; +pub mod dismiss_notification; +pub mod get_template; +pub mod get_unread_count; +pub mod list_blocks; +pub mod list_deliveries; +pub mod list_deliveries_for_notification; +pub mod list_notifications; +pub mod list_subscriptions; +pub mod list_templates; +pub mod mark_all_as_read; +pub mod mark_as_read; +pub mod update_subscription; +pub mod update_template; + +use actix_web::web; + +/// Configure notification routes under `/api/v1/notifications` +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/notifications") + // Non-parameterized paths first + .route("", web::get().to(list_notifications::list_notifications)) + .route( + "", + web::delete().to(clear_all_notifications::clear_all_notifications), + ) + .route( + "/unread-count", + web::get().to(get_unread_count::get_unread_count), + ) + .route( + "/read-all", + web::put().to(mark_all_as_read::mark_all_as_read), + ) + // Parameterized notification paths + .route( + "/{notification_id}", + web::delete().to(delete_notification::delete_notification), + ) + .route( + "/{notification_id}/read", + web::put().to(mark_as_read::mark_as_read), + ) + .route( + "/{notification_id}/dismiss", + web::post().to(dismiss_notification::dismiss_notification), + ) + .route( + "/{notification_id}/deliveries", + web::get().to(list_deliveries_for_notification::list_deliveries_for_notification), + ) + // Subscriptions + .route( + "/subscriptions", + web::get().to(list_subscriptions::list_subscriptions), + ) + .route( + "/subscriptions", + web::post().to(create_subscription::create_subscription), + ) + .route( + "/subscriptions/{subscription_id}", + web::put().to(update_subscription::update_subscription), + ) + .route( + "/subscriptions/{subscription_id}", + web::delete().to(delete_subscription::delete_subscription), + ) + // Blocks + .route("/blocks", web::get().to(list_blocks::list_blocks)) + .route("/blocks", web::post().to(create_block::create_block)) + .route( + "/blocks/{block_id}", + web::delete().to(delete_block::delete_block), + ) + // Deliveries + .route( + "/deliveries", + web::get().to(list_deliveries::list_deliveries), + ) + // Templates + .route("/templates", web::get().to(list_templates::list_templates)) + .route( + "/templates", + web::post().to(create_template::create_template), + ) + .route( + "/templates/{template_id}", + web::get().to(get_template::get_template), + ) + .route( + "/templates/{template_id}", + web::put().to(update_template::update_template), + ) + .route( + "/templates/{template_id}", + web::delete().to(delete_template::delete_template), + ), + ); +} diff --git a/api/notify/update_subscription.rs b/api/notify/update_subscription.rs new file mode 100644 index 0000000..0a1fb49 --- /dev/null +++ b/api/notify/update_subscription.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationSubscription; +use crate::service::AppService; +use crate::service::notify::subscriptions::UpdateSubscriptionParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub subscription_id: uuid::Uuid, +} + +/// Update a notification subscription +#[utoipa::path( + put, + path = "/api/v1/notifications/subscriptions/{subscription_id}", + tag = "Notifications", + operation_id = "notificationUpdateSubscription", + params(PathParams), + request_body( + content = UpdateSubscriptionParams, + description = "Subscription update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Subscription updated", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Subscription not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn update_subscription( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .notify + .update_subscription(&session, path.subscription_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/notify/update_template.rs b/api/notify/update_template.rs new file mode 100644 index 0000000..9fb9628 --- /dev/null +++ b/api/notify/update_template.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::notifications::NotificationTemplate; +use crate::service::AppService; +use crate::service::notify::templates::UpdateTemplateParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub template_id: uuid::Uuid, +} + +/// Update a notification template (requires system admin) +#[utoipa::path( + put, + path = "/api/v1/notifications/templates/{template_id}", + tag = "Notifications", + operation_id = "notificationUpdateTemplate", + params(PathParams), + request_body( + content = UpdateTemplateParams, + description = "Template update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Template updated", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 403, description = "System admin access required", body = ApiErrorResponse), + (status = 404, description = "Template not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn update_template( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .notify + .update_template(&session, path.template_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/openapi.rs b/api/openapi.rs index 4bcfb75..d0dd25f 100644 --- a/api/openapi.rs +++ b/api/openapi.rs @@ -13,34 +13,52 @@ use crate::api::repo::accept_invitation::AcceptInvitationParams; use crate::api::repo::set_branch_protection::SetBranchProtectionParams; use crate::api::repo::transfer_owner::TransferOwnerParams; use crate::api::response::{ApiEmptyResponse, ApiErrorResponse, ApiResponse}; +use crate::api::user::upload_avatar::AvatarData; use crate::api::wiki::compare_revisions::WikiCompareResult; use crate::api::workspace::accept_invitation::AcceptInvitationRequest; use crate::api::workspace::review_approval::ReviewApprovalRequest; use crate::api::workspace::transfer_owner::TransferOwnerRequest; +use crate::models::base_info::{ + ChannelBaseInfo, IssueBaseInfo, PullRequestBaseInfo, + RepoBaseInfo, UserBaseInfo, WikiPageBaseInfo, WorkspaceBaseInfo, +}; +use crate::models::channels::channel::ChannelDetail; +use crate::models::issues::issue::IssueDetail; +use crate::models::issues::issue_comments::IssueCommentDetail; use crate::models::issues::{ Issue, IssueAssignee, IssueComment, IssueEvent, IssueLabel, IssueLabelRelation, IssueMilestone, IssuePrRelation, IssueReaction, IssueRepoRelation, IssueSubscriber, IssueTemplate, }; +use crate::models::notifications::notification::NotificationDetail; +use crate::models::notifications::{ + Notification, NotificationBlock, NotificationDelivery, NotificationSubscription, + NotificationTemplate, +}; +use crate::models::prs::pr_review::PrReviewDetail; +use crate::models::prs::pull_request::PullRequestDetail; use crate::models::prs::{ PrAssignee, PrCheckRun, PrCommit, PrEvent, PrFile, PrLabel, PrLabelRelation, PrMergeStrategy, PrReaction, PrReview, PrReviewComment, PrStatus, PrSubscription, PullRequest, }; +use crate::models::repos::repo::RepoDetail; use crate::models::repos::{ BranchProtectionRule, Repo, RepoBranch, RepoCommitComment, RepoCommitStatus, RepoDeployKey, RepoFork, RepoInvitation, RepoMember, RepoRelease, RepoStar, RepoStats, RepoTag, RepoWatch, RepoWebhook, }; use crate::models::users::{ - User, UserAppearance, UserDevice, UserGpgKey, UserNotifySetting, UserProfile, UserSecurityLog, - UserSshKey, + User, UserAppearance, UserBlock, UserDevice, UserFollow, UserGpgKey, UserNotifySetting, + UserPresence, UserProfile, UserSecurityLog, UserSshKey, }; use crate::models::wiki::{WikiPage, WikiPageRevision}; +use crate::models::workspaces::workspace::WorkspaceDetail; use crate::models::workspaces::{ Workspace, WorkspaceAuditLog, WorkspaceBilling, WorkspaceCustomBranding, WorkspaceDomain, WorkspaceIntegration, WorkspaceInvitation, WorkspaceMember, WorkspacePendingApproval, WorkspaceSettings, WorkspaceStats, WorkspaceWebhook, }; use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse}; +use crate::service::auth::change_password::ChangePasswordParams; use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest}; use crate::service::auth::login::LoginParams; use crate::service::auth::me::ContextMe; @@ -60,6 +78,12 @@ use crate::service::issues::pr_relations::LinkPrParams; use crate::service::issues::reactions::CreateIssueReactionParams; use crate::service::issues::repo_relations::LinkRepoParams; use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams}; +use crate::service::notify::blocks::CreateBlockParams; +use crate::service::notify::subscriptions::{CreateSubscriptionParams, UpdateSubscriptionParams}; +use crate::service::notify::templates::{ + CreateTemplateParams as NotifyCreateTemplateParams, + UpdateTemplateParams as NotifyUpdateTemplateParams, +}; use crate::service::pr::check_runs::{CreateCheckRunParams, UpdateCheckRunParams}; use crate::service::pr::core::{CreatePrParams, MergePrParams, PrListFilters, UpdatePrParams}; use crate::service::pr::labels::{CreatePrLabelParams, UpdatePrLabelParams}; @@ -80,30 +104,36 @@ use crate::service::repo::protection::{ BranchMergeCheck, CreateProtectionRuleParams, UpdateProtectionRuleParams, }; use crate::service::repo::releases::{CreateReleaseParams, UpdateReleaseParams}; -use crate::service::repo::tags::CreateTagParams; +use crate::service::repo::tags::{CreateTagParams, UpdateTagParams}; use crate::service::repo::watches::WatchParams; use crate::service::repo::webhooks::{ CreateWebhookParams as RepoCreateWebhookParams, UpdateWebhookParams as RepoUpdateWebhookParams, }; -use crate::service::user::account::{ - UpdateUserAccountParams, UploadUserAvatarParams, UserAvatarResponse, -}; +use crate::service::user::account::UpdateUserAccountParams; use crate::service::user::appearance::UpdateUserAppearanceParams; use crate::service::user::keys::{AddGpgKeyParams, AddSshKeyParams}; use crate::service::user::notify::UpdateUserNotifySettingParams; use crate::service::user::profile::UpdateUserProfileParams; -use crate::service::user::security::{UserOAuthInfo, UserPersonalAccessTokenInfo, UserSessionInfo}; +use crate::service::user::security::{ + CreatePersonalAccessTokenResponse, UserOAuthInfo, UserPersonalAccessTokenInfo, UserSessionInfo, +}; use crate::service::wiki::core::{CreateWikiPageParams, UpdateWikiPageParams}; use crate::service::workspace::approvals::RequestApprovalParams; use crate::service::workspace::billing::UpdateBillingParams; use crate::service::workspace::branding::UpdateBrandingParams; use crate::service::workspace::core::{CreateWorkspaceParams, UpdateWorkspaceParams}; -use crate::service::workspace::domains::AddDomainParams; +use crate::service::workspace::domains::{AddDomainParams, UpdateDomainParams}; use crate::service::workspace::integrations::{CreateIntegrationParams, UpdateIntegrationParams}; use crate::service::workspace::invitations::{CreateInvitationParams, CreateInvitationResponse}; use crate::service::workspace::members::{AddMemberParams, UpdateMemberRoleParams}; use crate::service::workspace::settings::UpdateWorkspaceSettingsParams; use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookParams}; +// IM Channel models +use crate::models::channels::{Channel, ChannelCategory, ChannelMember}; +// IM Service params +use crate::service::im::categories::{CreateCategoryParams, UpdateCategoryParams}; +use crate::service::im::channels::{ChannelListFilters, CreateChannelParams, UpdateChannelParams}; +use crate::service::im::members::{InviteMemberParams, UpdateMemberParams}; #[derive(OpenApi)] #[openapi( @@ -120,6 +150,9 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara (name = "Issues", description = "Issue tracking, comments, labels, milestones, assignees, events, reactions, subscribers, templates, and cross-references."), (name = "Pull Requests", description = "Pull request lifecycle including reviews, check runs, merge strategies, labels, assignees, events, reactions, and subscriptions."), (name = "Wiki", description = "Wiki page management including CRUD operations, revision history, version comparison, and page reversion."), + (name = "Notifications", description = "User notification management including listing, reading, dismissing, deleting, subscriptions, blocks, deliveries, and templates."), + (name = "Git", description = "Git-level operations including commits, branches, merges, rebase, blame, tree, blob, tags, and repository health/statistics endpoints."), + (name = "IM", description = "Channel management, member administration, and category organization."), ), paths( // Auth @@ -140,6 +173,7 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::auth::verify_2fa::handle, crate::api::auth::disable_2fa::handle, crate::api::auth::regenerate_2fa_backup_codes::handle, + crate::api::auth::change_password::change_password, // User crate::api::user::get_account::get_account, crate::api::user::update_account::update_account, @@ -166,6 +200,15 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::user::list_security_logs::list_security_logs, crate::api::user::list_personal_access_tokens::list_tokens, crate::api::user::revoke_personal_access_token::revoke_token, + crate::api::user::create_personal_access_token::create_token, + crate::api::user::get_presence::get_presence, + crate::api::user::update_presence::update_presence, + crate::api::user::list_blocks::list_blocks, + crate::api::user::block_user::block_user, + crate::api::user::unblock_user::unblock_user, + crate::api::user::list_follows::list_follows, + crate::api::user::follow_user::follow_user, + crate::api::user::unfollow_user::unfollow_user, // Issues crate::api::issue::list::list, crate::api::issue::get::get, @@ -226,11 +269,9 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara // Pull Requests - Commits & Files crate::api::pr::list_commits::list_commits, crate::api::pr::list_files::list_files, - // Pull Requests - Status & Merge Strategy crate::api::pr::get_status::get_status, crate::api::pr::merge_strategy::get_merge_strategy, crate::api::pr::merge_strategy::update_merge_strategy, - // Pull Requests - Labels crate::api::pr::labels::list_labels, crate::api::pr::labels::create_label, crate::api::pr::labels::update_label, @@ -238,11 +279,9 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::pr::labels::list_label_relations, crate::api::pr::labels::assign_label, crate::api::pr::labels::unassign_label, - // Pull Requests - Assignees crate::api::pr::assignees::list_assignees, crate::api::pr::assignees::assign_user, crate::api::pr::assignees::unassign_user, - // Pull Requests - Reviews crate::api::pr::reviews::list_reviews, crate::api::pr::reviews::create_review, crate::api::pr::reviews::submit_review, @@ -251,23 +290,18 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::pr::reviews::add_review_reply, crate::api::pr::reviews::update_review_comment, crate::api::pr::reviews::delete_review_comment, - // Pull Requests - Check Runs crate::api::pr::check_runs::list_check_runs, crate::api::pr::check_runs::create_check_run, crate::api::pr::check_runs::update_check_run, crate::api::pr::check_runs::delete_check_run, - // Pull Requests - Events crate::api::pr::events::list_events, - // Pull Requests - Reactions crate::api::pr::reactions::list_reactions, crate::api::pr::reactions::add_reaction, crate::api::pr::reactions::remove_reaction, - // Pull Requests - Subscriptions crate::api::pr::subscriptions::list_subscriptions, crate::api::pr::subscriptions::subscribe, crate::api::pr::subscriptions::unsubscribe, crate::api::pr::subscriptions::mute, - // Wiki crate::api::wiki::list_pages::list_pages, crate::api::wiki::get_page::get_page, crate::api::wiki::create_page::create_page, @@ -277,7 +311,27 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::wiki::list_revisions::list_revisions, crate::api::wiki::get_revision::get_revision, crate::api::wiki::compare_revisions::compare_revisions, - // Workspaces + crate::api::notify::list_notifications::list_notifications, + crate::api::notify::get_unread_count::get_unread_count, + crate::api::notify::mark_as_read::mark_as_read, + crate::api::notify::mark_all_as_read::mark_all_as_read, + crate::api::notify::dismiss_notification::dismiss_notification, + crate::api::notify::delete_notification::delete_notification, + crate::api::notify::clear_all_notifications::clear_all_notifications, + crate::api::notify::list_subscriptions::list_subscriptions, + crate::api::notify::create_subscription::create_subscription, + crate::api::notify::update_subscription::update_subscription, + crate::api::notify::delete_subscription::delete_subscription, + crate::api::notify::list_blocks::list_blocks, + crate::api::notify::create_block::create_block, + crate::api::notify::delete_block::delete_block, + crate::api::notify::list_deliveries::list_deliveries, + crate::api::notify::list_deliveries_for_notification::list_deliveries_for_notification, + crate::api::notify::list_templates::list_templates, + crate::api::notify::get_template::get_template, + crate::api::notify::create_template::create_template, + crate::api::notify::update_template::update_template, + crate::api::notify::delete_template::delete_template, crate::api::workspace::list::handle, crate::api::workspace::get::handle, crate::api::workspace::create::handle, @@ -321,7 +375,17 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::workspace::request_approval::handle, crate::api::workspace::review_approval::handle, crate::api::workspace::audit_logs::handle, - // Repos + crate::api::workspace::restore::restore_workspace, + crate::api::workspace::billing_history::billing_history, + crate::api::workspace::list_webhook_deliveries::handle, + crate::api::workspace::retry_webhook_delivery::handle, + crate::api::workspace::update_domain::update_domain, + crate::api::workspace::get_member::handle, + crate::api::workspace::get_invitation::handle, + crate::api::workspace::get_webhook::handle, + crate::api::workspace::get_integration::handle, + crate::api::workspace::get_domain::handle, + crate::api::workspace::get_approval::handle, crate::api::repo::list::list, crate::api::repo::get::get, crate::api::repo::create::create, @@ -381,6 +445,61 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara crate::api::repo::resolve_commit_comment::resolve_commit_comment, crate::api::repo::get_stats::get_stats, crate::api::repo::refresh_stats::refresh_stats, + crate::api::repo::get_branch::get_branch, + crate::api::repo::get_tag::get_tag, + crate::api::repo::get_release::get_release, + crate::api::repo::get_webhook::get_webhook, + crate::api::repo::get_deploy_key::get_deploy_key, + crate::api::repo::get_member::get_member, + crate::api::repo::get_invitation::get_invitation, + crate::api::repo::update_commit_comment::update_commit_comment, + crate::api::repo::update_tag::update_tag, + crate::api::repo::delete_fork::delete_fork, + crate::api::repo::get_commit_status::get_commit_status, + crate::api::repo::repo_webhook_deliveries::repo_webhook_deliveries, + crate::api::repo::repo_webhook_retry::repo_webhook_retry, + crate::api::repo::git::git_list_commits::git_list_commits, + crate::api::repo::git::git_get_commit::git_get_commit, + crate::api::repo::git::git_create_commit::git_create_commit, + crate::api::repo::git::git_diff::git_diff, + crate::api::repo::git::git_diff_stats::git_diff_stats, + crate::api::repo::git::git_compare::git_compare, + crate::api::repo::git::git_list_branches::git_list_branches, + crate::api::repo::git::git_get_branch::git_get_branch, + crate::api::repo::git::git_create_branch::git_create_branch, + crate::api::repo::git::git_delete_branch::git_delete_branch, + crate::api::repo::git::git_merge_check::git_merge_check, + crate::api::repo::git::git_merge::git_merge, + crate::api::repo::git::git_rebase::git_rebase, + crate::api::repo::git::git_cherry_pick::git_cherry_pick, + crate::api::repo::git::git_revert::git_revert, + crate::api::repo::git::git_conflicts::git_conflicts, + crate::api::repo::git::git_tree::git_tree, + crate::api::repo::git::git_blob::git_blob, + crate::api::repo::git::git_blame::git_blame, + crate::api::repo::git::git_tags::git_tags, + crate::api::repo::git::git_create_tag::git_create_tag, + crate::api::repo::git::git_delete_tag::git_delete_tag, + crate::api::repo::git::git_info::git_info, + crate::api::repo::git::git_exists::git_exists, + crate::api::repo::git::git_stats::git_stats, + crate::api::repo::git::git_health::git_health, + crate::api::repo::git::git_gc::git_gc, + crate::api::im::channel_list::channel_list, + crate::api::im::channel_create::channel_create, + crate::api::im::channel_get::channel_get, + crate::api::im::channel_update::channel_update, + crate::api::im::channel_delete::channel_delete, + crate::api::im::member_list::member_list, + crate::api::im::member_invite::member_invite, + crate::api::im::member_update::member_update, + crate::api::im::member_kick::member_kick, + crate::api::im::member_join::member_join, + crate::api::im::member_leave::member_leave, + crate::api::im::category_list::category_list, + crate::api::im::category_create::category_create, + crate::api::im::category_update::category_update, + crate::api::im::category_delete::category_delete, ), components(schemas( ApiEmptyResponse, @@ -416,12 +535,11 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara Regenerate2FABackupCodesResponse, // User ApiResponse, - ApiResponse, + ApiResponse, ApiResponse, User, UpdateUserAccountParams, - UploadUserAvatarParams, - UserAvatarResponse, + AvatarData, ApiResponse, UserAppearance, UpdateUserAppearanceParams, @@ -697,6 +815,97 @@ use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookPara RepoCommitComment, CreateCommitCommentParams, RepoStats, + // Notifications + ApiResponse, + ApiResponse>, + Notification, + ApiResponse, + ApiResponse, + ApiResponse>, + NotificationSubscription, + CreateSubscriptionParams, + UpdateSubscriptionParams, + ApiResponse, + ApiResponse>, + NotificationBlock, + CreateBlockParams, + ApiResponse, + ApiResponse>, + NotificationDelivery, + ApiResponse, + ApiResponse>, + NotificationTemplate, + NotifyCreateTemplateParams, + NotifyUpdateTemplateParams, + // Auth additions + ChangePasswordParams, + // User additions - Presence/Block/Follow + ApiResponse, + UserPresence, + ApiResponse, + ApiResponse>, + UserBlock, + ApiResponse, + ApiResponse>, + UserFollow, + ApiResponse, + CreatePersonalAccessTokenResponse, + // Workspace additions + UpdateDomainParams, + // Repo additions + UpdateTagParams, + // IM - Channels + ApiResponse, + ApiResponse>, + Channel, + CreateChannelParams, + UpdateChannelParams, + ChannelListFilters, + // IM - Members + ApiResponse, + ApiResponse>, + ChannelMember, + InviteMemberParams, + UpdateMemberParams, + // IM - Categories + ApiResponse, + ApiResponse>, + ChannelCategory, + CreateCategoryParams, + UpdateCategoryParams, + // BaseInfo types + UserBaseInfo, + WorkspaceBaseInfo, + RepoBaseInfo, + ChannelBaseInfo, + IssueBaseInfo, + PullRequestBaseInfo, + WikiPageBaseInfo, + // Detail types + ApiResponse, + ApiResponse>, + RepoDetail, + ApiResponse, + ApiResponse>, + WorkspaceDetail, + ApiResponse, + ApiResponse>, + ChannelDetail, + ApiResponse, + ApiResponse>, + IssueDetail, + ApiResponse, + ApiResponse>, + IssueCommentDetail, + ApiResponse, + ApiResponse>, + PullRequestDetail, + ApiResponse, + ApiResponse>, + PrReviewDetail, + ApiResponse, + ApiResponse>, + NotificationDetail, )) )] pub struct OpenApiDoc; diff --git a/api/pr/create.rs b/api/pr/create.rs index 03433d1..77cdcea 100644 --- a/api/pr/create.rs +++ b/api/pr/create.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::prs::PullRequest; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::prs::PullRequestDetail; use crate::service::AppService; use crate::service::pr::core::CreatePrParams; use crate::session::Session; @@ -51,7 +52,7 @@ pub struct PathParams { content_type = "application/json" ), responses( - (status = 201, description = "PR created successfully. Returns the newly created PR with full metadata.", body = ApiResponse), + (status = 201, description = "PR created successfully. Returns the newly created PR with full metadata.", body = ApiResponse), (status = 400, description = "Invalid parameters: empty title, non-existent branch/commit, or invalid fork relationship", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse), @@ -77,5 +78,11 @@ pub async fn create( params.into_inner(), ) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(pr))) + let author_id = pr.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Created().json(ApiResponse::new(pr.into_detail(author)))) } diff --git a/api/pr/get.rs b/api/pr/get.rs index 203e0c8..eb57e07 100644 --- a/api/pr/get.rs +++ b/api/pr/get.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::prs::PullRequest; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::prs::PullRequestDetail; use crate::service::AppService; use crate::session::Session; @@ -29,7 +30,7 @@ pub struct PathParams { operation_id = "prGet", params(PathParams), responses( - (status = 200, description = "PR retrieved successfully. Returns complete PR with all metadata.", body = ApiResponse), + (status = 200, description = "PR retrieved successfully. Returns complete PR with all metadata.", body = ApiResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse), (status = 404, description = "Repository, workspace, or PR not found", body = ApiErrorResponse), @@ -48,5 +49,11 @@ pub async fn get( .pr .pr_get(&session, &path.workspace_name, &path.repo_name, path.number) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(pr))) + let author_id = pr.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Ok().json(ApiResponse::new(pr.into_detail(author)))) } diff --git a/api/pr/labels.rs b/api/pr/labels.rs index 5014ea3..38dfb20 100644 --- a/api/pr/labels.rs +++ b/api/pr/labels.rs @@ -55,7 +55,7 @@ pub struct QP { pub offset: Option, } -// ── Repo-level labels ── +// Section: Repo-level labels /// List PR labels in a repository #[utoipa::path( @@ -189,7 +189,7 @@ pub async fn delete_label( Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted".to_string()))) } -// ── PR-level label relations ── +// Section: PR-level label relations /// List labels assigned to a PR #[utoipa::path( diff --git a/api/pr/list.rs b/api/pr/list.rs index 06c4406..6dd5215 100644 --- a/api/pr/list.rs +++ b/api/pr/list.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::prs::PullRequest; +use crate::models::base_info::{self, UserBaseInfo}; +use crate::models::prs::PullRequestDetail; use crate::service::AppService; use crate::service::pr::core::PrListFilters; use crate::session::Session; @@ -43,7 +44,7 @@ pub struct QueryParams { operation_id = "prList", params(PathParams, QueryParams), responses( - (status = 200, description = "PRs listed successfully. Returns filtered array of PR objects.", body = ApiResponse>), + (status = 200, description = "PRs listed successfully. Returns filtered array of PR objects.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse), (status = 404, description = "Repository or workspace not found", body = ApiErrorResponse), @@ -75,5 +76,17 @@ pub async fn list( query.offset.unwrap_or(0), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(prs))) + let user_ids: Vec<_> = prs.iter().map(|p| p.author_id).collect(); + let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?; + let details: Vec = prs + .into_iter() + .map(|p| { + let author = users + .get(&p.author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(p.author_id)); + p.into_detail(author) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } diff --git a/api/pr/mod.rs b/api/pr/mod.rs index bbe11a4..f266f4a 100644 --- a/api/pr/mod.rs +++ b/api/pr/mod.rs @@ -16,8 +16,10 @@ pub mod merge; pub mod merge_strategy; pub mod reactions; pub mod reopen; +pub mod review_requests; pub mod reviews; pub mod subscriptions; +pub mod templates; pub mod update; use actix_web::web; @@ -25,12 +27,23 @@ use actix_web::web; /// Configure PR-level routes under `/workspaces/{workspace_name}/repos/{repo_name}/prs` pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/prs") + web::scope("") // Repo-level labels .route("/labels", web::get().to(labels::list_labels)) .route("/labels", web::post().to(labels::create_label)) .route("/labels/{label_id}", web::put().to(labels::update_label)) .route("/labels/{label_id}", web::delete().to(labels::delete_label)) + // Templates + .route("/templates", web::get().to(templates::list_templates)) + .route("/templates", web::post().to(templates::create_template)) + .route( + "/templates/{template_id}", + web::put().to(templates::update_template), + ) + .route( + "/templates/{template_id}", + web::delete().to(templates::delete_template), + ) // Core .route("", web::get().to(list::list)) .route("", web::post().to(create::create)) @@ -156,6 +169,19 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{number}/subscribe", web::delete().to(subscriptions::unsubscribe), ) - .route("/{number}/mute", web::put().to(subscriptions::mute)), + .route("/{number}/mute", web::put().to(subscriptions::mute)) + // Review Requests + .route( + "/{number}/requested_reviewers", + web::get().to(review_requests::list_requested_reviewers), + ) + .route( + "/{number}/requested_reviewers", + web::post().to(review_requests::request_reviewers), + ) + .route( + "/{number}/requested_reviewers/{user_id}", + web::delete().to(review_requests::remove_requested_reviewer), + ), ); } diff --git a/api/pr/review_requests.rs b/api/pr/review_requests.rs new file mode 100644 index 0000000..6af8021 --- /dev/null +++ b/api/pr/review_requests.rs @@ -0,0 +1,116 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::prs::PrReviewRequest; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub number: i64, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ReviewerPathParams { + pub workspace_name: String, + pub repo_name: String, + pub number: i64, + pub user_id: Uuid, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RequestReviewersBody { + pub reviewer_ids: Vec, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/requested_reviewers", + tag = "PullRequests", + operation_id = "prListRequestedReviewers", + params(PathParams), + responses( + (status = 200, description = "List of requested reviewers", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn list_requested_reviewers( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .pr + .pr_requested_reviewers(&session, &path.workspace_name, &path.repo_name, path.number) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/requested_reviewers", + tag = "PullRequests", + operation_id = "prRequestReviewers", + params(PathParams), + request_body(content = RequestReviewersBody), + responses( + (status = 201, description = "Reviewers requested", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn request_reviewers( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .pr + .pr_request_reviewers( + &session, + &path.workspace_name, + &path.repo_name, + path.number, + body.reviewer_ids.clone(), + ) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/{number}/requested_reviewers/{user_id}", + tag = "PullRequests", + operation_id = "prRemoveRequestedReviewer", + params(ReviewerPathParams), + responses( + (status = 200, description = "Reviewer removed", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn remove_requested_reviewer( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .pr + .pr_remove_requested_reviewer( + &session, + &path.workspace_name, + &path.repo_name, + path.number, + path.user_id, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("reviewer removed".to_string()))) +} diff --git a/api/pr/reviews.rs b/api/pr/reviews.rs index 753bf1e..f78d6bc 100644 --- a/api/pr/reviews.rs +++ b/api/pr/reviews.rs @@ -4,7 +4,9 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::prs::{PrReview, PrReviewComment}; +use crate::models::base_info; +use crate::models::base_info::UserBaseInfo; +use crate::models::prs::{PrReviewComment, PrReviewDetail}; use crate::service::AppService; use crate::service::pr::reviews::{ AddReplyParams, CreateReviewParams, DismissReviewParams, SubmitReviewParams, @@ -59,7 +61,7 @@ pub struct QP { operation_id = "prListReviews", params(PrPath, QP), responses( - (status = 200, description = "Reviews listed.", body = ApiResponse>), + (status = 200, description = "Reviews listed.", body = ApiResponse>), (status = 401, description = "Authentication required", body = ApiErrorResponse), (status = 404, description = "PR not found", body = ApiErrorResponse), (status = 500, description = "Internal server error", body = ApiErrorResponse), @@ -83,7 +85,19 @@ pub async fn list_reviews( query.offset.unwrap_or(0), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(reviews))) + let user_ids: Vec<_> = reviews.iter().map(|r| r.author_id).collect(); + let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?; + let details: Vec = reviews + .into_iter() + .map(|r| { + let author = users + .get(&r.author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(r.author_id)); + r.into_detail(author) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } /// Create a review. States: pending, approved, changes_requested, commented. Authors cannot approve their own PRs. @@ -95,7 +109,7 @@ pub async fn list_reviews( params(PrPath), request_body(content = CreateReviewParams, description = "Review parameters", content_type = "application/json"), responses( - (status = 201, description = "Review created.", body = ApiResponse), + (status = 201, description = "Review created.", body = ApiResponse), (status = 400, description = "Invalid state or self-approval", body = ApiErrorResponse), (status = 401, description = "Authentication required", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions", body = ApiErrorResponse), @@ -120,7 +134,13 @@ pub async fn create_review( params.into_inner(), ) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(review))) + let author_id = review.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Created().json(ApiResponse::new(review.into_detail(author)))) } /// Submit a pending review. Changes its state to approved, changes_requested, or commented. @@ -132,7 +152,7 @@ pub async fn create_review( params(ReviewPath), request_body(content = SubmitReviewParams, description = "Submit parameters", content_type = "application/json"), responses( - (status = 200, description = "Review submitted.", body = ApiResponse), + (status = 200, description = "Review submitted.", body = ApiResponse), (status = 400, description = "Invalid state or self-approval", body = ApiErrorResponse), (status = 401, description = "Authentication required", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions", body = ApiErrorResponse), @@ -158,7 +178,13 @@ pub async fn submit_review( params.into_inner(), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(review))) + let author_id = review.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Ok().json(ApiResponse::new(review.into_detail(author)))) } /// Dismiss a submitted review. Requires Admin role. @@ -170,7 +196,7 @@ pub async fn submit_review( params(ReviewPath), request_body(content = DismissReviewParams, description = "Dismiss parameters", content_type = "application/json"), responses( - (status = 200, description = "Review dismissed.", body = ApiResponse), + (status = 200, description = "Review dismissed.", body = ApiResponse), (status = 401, description = "Authentication required", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (Admin required)", body = ApiErrorResponse), (status = 404, description = "Review not found or not submitted", body = ApiErrorResponse), @@ -195,7 +221,13 @@ pub async fn dismiss_review( params.into_inner(), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(review))) + let author_id = review.author_id; + let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?; + let author = users + .get(&author_id) + .cloned() + .unwrap_or_else(|| UserBaseInfo::placeholder(author_id)); + Ok(HttpResponse::Ok().json(ApiResponse::new(review.into_detail(author)))) } /// List comments for a specific review diff --git a/api/pr/templates.rs b/api/pr/templates.rs new file mode 100644 index 0000000..8e8e2a7 --- /dev/null +++ b/api/pr/templates.rs @@ -0,0 +1,152 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::prs::PrTemplate; +use crate::service::AppService; +use crate::service::pr::templates::{CreatePrTemplateParams, UpdatePrTemplateParams}; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct TemplatePathParams { + pub workspace_name: String, + pub repo_name: String, + pub template_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/templates", + tag = "PullRequests", + operation_id = "prListTemplates", + params(PathParams, QueryParams), + responses( + (status = 200, description = "List of PR templates", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn list_templates( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .pr + .pr_templates( + &session, + &path.workspace_name, + &path.repo_name, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/templates", + tag = "PullRequests", + operation_id = "prCreateTemplate", + params(PathParams), + request_body(content = CreatePrTemplateParams), + responses( + (status = 201, description = "Template created", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn create_template( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .pr + .pr_create_template( + &session, + &path.workspace_name, + &path.repo_name, + body.into_inner(), + ) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/templates/{template_id}", + tag = "PullRequests", + operation_id = "prUpdateTemplate", + params(TemplatePathParams), + request_body(content = UpdatePrTemplateParams), + responses( + (status = 200, description = "Template updated", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn update_template( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .pr + .pr_update_template( + &session, + &path.workspace_name, + &path.repo_name, + path.template_id, + body.into_inner(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/prs/templates/{template_id}", + tag = "PullRequests", + operation_id = "prDeleteTemplate", + params(TemplatePathParams), + responses( + (status = 200, description = "Template deleted", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn delete_template( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .pr + .pr_delete_template( + &session, + &path.workspace_name, + &path.repo_name, + path.template_id, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("template deleted".to_string()))) +} diff --git a/api/repo/contributors.rs b/api/repo/contributors.rs new file mode 100644 index 0000000..14e336d --- /dev/null +++ b/api/repo/contributors.rs @@ -0,0 +1,53 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::contributors::Contributor; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/contributors", + tag = "Repos", + operation_id = "repoListContributors", + params(PathParams, QueryParams), + responses( + (status = 200, description = "List of contributors", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Repo not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn list_contributors( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .repo_contributors( + &session, + &path.workspace_name, + &path.repo_name, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/create.rs b/api/repo/create.rs index db945a5..1af8685 100644 --- a/api/repo/create.rs +++ b/api/repo/create.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::service::repo::core::CreateRepoParams; use crate::session::Session; @@ -42,7 +43,7 @@ pub struct PathParams { content_type = "application/json" ), responses( - (status = 201, description = "Repository created successfully. Returns the newly created repository with full metadata.", body = ApiResponse), + (status = 201, description = "Repository created successfully. Returns the newly created repository with full metadata.", body = ApiResponse), (status = 400, description = "Invalid parameters: name too long, invalid characters, or invalid visibility", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to create repositories in this workspace", body = ApiErrorResponse), @@ -65,5 +66,15 @@ pub async fn create( .repo_create(&session, &path.workspace_name, params.into_inner()) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(repo))) + let db = &service.ctx.db; + let users = resolve_users(db, &[repo.owner_id]).await?; + let workspaces = resolve_workspaces(db, &[repo.workspace_id]).await?; + let owner = users.get(&repo.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces + .get(&repo.workspace_id) + .cloned() + .unwrap_or_default(); + let detail = repo.into_detail(owner, workspace); + + Ok(HttpResponse::Created().json(ApiResponse::new(detail))) } diff --git a/api/repo/delete_branch.rs b/api/repo/delete_branch.rs index 240a25e..0436a06 100644 --- a/api/repo/delete_branch.rs +++ b/api/repo/delete_branch.rs @@ -9,42 +9,24 @@ use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, - /// Branch ID (UUID) - pub branch_id: uuid::Uuid, + pub branch_name: String, } -/// Delete a branch -/// -/// Permanently deletes a branch from the repository. The default branch cannot be deleted. -/// Requires Write role or higher in the repository. -/// -/// Effects: -/// - Branch is permanently removed from the repository -/// - All commits exclusive to this branch remain accessible via their SHA -/// - Open pull requests targeting this branch will be closed -/// -/// Returns success message on completion. #[utoipa::path( delete, - path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}", + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}", tag = "Repos", operation_id = "repoDeleteBranch", params(PathParams), responses( - (status = 200, description = "Branch deleted successfully.", body = ApiResponse), - (status = 400, description = "Cannot delete the default branch", body = ApiErrorResponse), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions (requires Write role or higher)", body = ApiErrorResponse), - (status = 404, description = "Repository, workspace, or branch not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error or Git operation failed", body = ApiErrorResponse), + (status = 200, description = "Branch deleted", body = ApiResponse), + (status = 400, description = "Cannot delete default branch", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Branch not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn delete_branch( service: web::Data, @@ -53,13 +35,12 @@ pub async fn delete_branch( ) -> Result { service .repo - .repo_delete_branch( + .git_delete_branch( &session, &path.workspace_name, &path.repo_name, - path.branch_id, + &path.branch_name, ) .await?; - - Ok(HttpResponse::Ok().json(ApiResponse::new("Branch deleted successfully".to_string()))) + Ok(HttpResponse::Ok().json(ApiResponse::new("Branch deleted".to_string()))) } diff --git a/api/repo/delete_fork.rs b/api/repo/delete_fork.rs new file mode 100644 index 0000000..2f4c70a --- /dev/null +++ b/api/repo/delete_fork.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/fork", + tag = "Repos", + operation_id = "repoDeleteFork", + params(PathParams), + responses( + (status = 200, description = "Fork deleted successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Fork not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn delete_fork( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .repo + .repo_delete_fork(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new("Fork deleted successfully".to_string()))) +} diff --git a/api/repo/delete_tag.rs b/api/repo/delete_tag.rs index e0c0352..629c294 100644 --- a/api/repo/delete_tag.rs +++ b/api/repo/delete_tag.rs @@ -9,41 +9,23 @@ use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, - /// Tag ID (UUID) - pub tag_id: uuid::Uuid, + pub tag_name: String, } -/// Delete a tag -/// -/// Permanently deletes a tag from the repository. The tagged commit remains accessible via its SHA. -/// Requires Write role or higher in the repository. -/// -/// Effects: -/// - Tag is permanently removed from the repository -/// - The tagged commit remains in the repository history -/// - Releases associated with this tag are not automatically deleted -/// -/// Returns success message on completion. #[utoipa::path( delete, - path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_id}", + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_name}", tag = "Repos", operation_id = "repoDeleteTag", params(PathParams), responses( - (status = 200, description = "Tag deleted successfully.", body = ApiResponse), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions (requires Write role or higher)", body = ApiErrorResponse), - (status = 404, description = "Repository, workspace, or tag not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error or Git operation failed", body = ApiErrorResponse), + (status = 200, description = "Tag deleted", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Tag not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn delete_tag( service: web::Data, @@ -52,8 +34,12 @@ pub async fn delete_tag( ) -> Result { service .repo - .repo_delete_tag(&session, &path.workspace_name, &path.repo_name, path.tag_id) + .git_delete_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) .await?; - - Ok(HttpResponse::Ok().json(ApiResponse::new("Tag deleted successfully".to_string()))) + Ok(HttpResponse::Ok().json(ApiResponse::new("Tag deleted".to_string()))) } diff --git a/api/repo/fork_repo.rs b/api/repo/fork_repo.rs index e5b8434..53466e8 100644 --- a/api/repo/fork_repo.rs +++ b/api/repo/fork_repo.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::session::Session; @@ -42,7 +43,7 @@ use crate::service::repo::fork::ForkRepoParams; content_type = "application/json" ), responses( - (status = 201, description = "Repository forked successfully. Returns the newly created fork with full metadata.", body = ApiResponse), + (status = 201, description = "Repository forked successfully. Returns the newly created fork with full metadata.", body = ApiResponse), (status = 400, description = "Invalid parameters: target name conflicts or invalid characters", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to fork or create in target workspace", body = ApiErrorResponse), @@ -70,5 +71,15 @@ pub async fn fork_repo( ) .await?; - Ok(HttpResponse::Created().json(ApiResponse::new(repo))) + let db = &service.ctx.db; + let users = resolve_users(db, &[repo.owner_id]).await?; + let workspaces = resolve_workspaces(db, &[repo.workspace_id]).await?; + let owner = users.get(&repo.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces + .get(&repo.workspace_id) + .cloned() + .unwrap_or_default(); + let detail = repo.into_detail(owner, workspace); + + Ok(HttpResponse::Created().json(ApiResponse::new(detail))) } diff --git a/api/repo/get.rs b/api/repo/get.rs index b7ca14c..df58e40 100644 --- a/api/repo/get.rs +++ b/api/repo/get.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::session::Session; @@ -32,7 +33,7 @@ pub struct PathParams { operation_id = "repoGet", params(PathParams), responses( - (status = 200, description = "Repository retrieved successfully. Returns complete repository metadata including visibility, default branch, creation date, and statistics.", body = ApiResponse), + (status = 200, description = "Repository retrieved successfully. Returns complete repository metadata including visibility, default branch, creation date, and statistics.", body = ApiResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse), (status = 404, description = "Repository not found or access denied", body = ApiErrorResponse), @@ -52,5 +53,15 @@ pub async fn get( .repo_get(&session, &path.workspace_name, &path.repo_name) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(repo))) + let db = &service.ctx.db; + let users = resolve_users(db, &[repo.owner_id]).await?; + let workspaces = resolve_workspaces(db, &[repo.workspace_id]).await?; + let owner = users.get(&repo.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces + .get(&repo.workspace_id) + .cloned() + .unwrap_or_default(); + let detail = repo.into_detail(owner, workspace); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/repo/get_branch.rs b/api/repo/get_branch.rs new file mode 100644 index 0000000..259fef6 --- /dev/null +++ b/api/repo/get_branch.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub branch_name: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}", + tag = "Repos", + operation_id = "repoGetBranch", + params(PathParams), + responses( + (status = 200, description = "Branch retrieved", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Branch not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_branch( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_get_branch( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/get_commit_status.rs b/api/repo/get_commit_status.rs new file mode 100644 index 0000000..5e5adf4 --- /dev/null +++ b/api/repo/get_commit_status.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoCommitStatus; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub push_commit_id: uuid::Uuid, + pub status_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/commits/{push_commit_id}/statuses/{status_id}", + tag = "Repos", + operation_id = "repoGetCommitStatus", + params(PathParams), + responses( + (status = 200, description = "Commit status retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Commit status not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_commit_status( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let statuses = service + .repo + .repo_commit_statuses( + &session, + &path.workspace_name, + &path.repo_name, + path.push_commit_id, + 1000, + 0, + ) + .await?; + + let status = statuses + .into_iter() + .find(|s| s.id == path.status_id) + .ok_or(AppError::NotFound("commit status not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(status))) +} diff --git a/api/repo/get_deploy_key.rs b/api/repo/get_deploy_key.rs new file mode 100644 index 0000000..c0e4fc5 --- /dev/null +++ b/api/repo/get_deploy_key.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoDeployKey; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub key_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/deploy-keys/{key_id}", + tag = "Repos", + operation_id = "repoGetDeployKey", + params(PathParams), + responses( + (status = 200, description = "Deploy key retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Deploy key not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_deploy_key( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let keys = service + .repo + .repo_deploy_keys(&session, &path.workspace_name, &path.repo_name, 1000, 0) + .await?; + + let key = keys + .into_iter() + .find(|k| k.id == path.key_id) + .ok_or(AppError::NotFound("deploy key not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(key))) +} diff --git a/api/repo/get_invitation.rs b/api/repo/get_invitation.rs new file mode 100644 index 0000000..b166583 --- /dev/null +++ b/api/repo/get_invitation.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoInvitation; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub invitation_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/invitations/{invitation_id}", + tag = "Repos", + operation_id = "repoGetInvitation", + params(PathParams), + responses( + (status = 200, description = "Invitation retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Invitation not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_invitation( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let invitations = service + .repo + .repo_invitations(&session, &path.workspace_name, &path.repo_name, 1000, 0) + .await?; + + let invitation = invitations + .into_iter() + .find(|i| i.id == path.invitation_id) + .ok_or(AppError::NotFound("invitation not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(invitation))) +} diff --git a/api/repo/get_member.rs b/api/repo/get_member.rs new file mode 100644 index 0000000..fe85341 --- /dev/null +++ b/api/repo/get_member.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoMember; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub member_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/members/{member_id}", + tag = "Repos", + operation_id = "repoGetMember", + params(PathParams), + responses( + (status = 200, description = "Member retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Member not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_member( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let members = service + .repo + .repo_members(&session, &path.workspace_name, &path.repo_name, 1000, 0) + .await?; + + let member = members + .into_iter() + .find(|m| m.id == path.member_id) + .ok_or(AppError::NotFound("member not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(member))) +} diff --git a/api/repo/get_release.rs b/api/repo/get_release.rs new file mode 100644 index 0000000..6b3fb4d --- /dev/null +++ b/api/repo/get_release.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoRelease; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub release_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}", + tag = "Repos", + operation_id = "repoGetRelease", + params(PathParams), + responses( + (status = 200, description = "Release retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Release not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_release( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let releases = service + .repo + .repo_releases(&session, &path.workspace_name, &path.repo_name, 1000, 0) + .await?; + + let release = releases + .into_iter() + .find(|r| r.id == path.release_id) + .ok_or(AppError::NotFound("release not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(release))) +} diff --git a/api/repo/get_tag.rs b/api/repo/get_tag.rs new file mode 100644 index 0000000..68c2e79 --- /dev/null +++ b/api/repo/get_tag.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub tag_name: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_name}", + tag = "Repos", + operation_id = "repoGetTag", + params(PathParams), + responses( + (status = 200, description = "Tag retrieved", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Tag not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_tag( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_get_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/get_webhook.rs b/api/repo/get_webhook.rs new file mode 100644 index 0000000..e6f4f88 --- /dev/null +++ b/api/repo/get_webhook.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoWebhook; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub webhook_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}", + tag = "Repos", + operation_id = "repoGetWebhook", + params(PathParams), + responses( + (status = 200, description = "Webhook retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Webhook not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn get_webhook( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let webhooks = service + .repo + .repo_webhooks(&session, &path.workspace_name, &path.repo_name, 1000, 0) + .await?; + + let webhook = webhooks + .into_iter() + .find(|w| w.id == path.webhook_id) + .ok_or(AppError::NotFound("webhook not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(webhook))) +} diff --git a/api/repo/git/git_blame.rs b/api/repo/git/git_blame.rs new file mode 100644 index 0000000..f76f94c --- /dev/null +++ b/api/repo/git/git_blame.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub revision: String, + pub path: String, + pub page_size: Option, +} + +/// Blame a file +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/blame", + tag = "Git", + operation_id = "gitBlame", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Blame retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_blame( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_blame( + &session, + &path.workspace_name, + &path.repo_name, + &query.revision, + &query.path, + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_blob.rs b/api/repo/git/git_blob.rs new file mode 100644 index 0000000..5404722 --- /dev/null +++ b/api/repo/git/git_blob.rs @@ -0,0 +1,55 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub revision: String, + pub path: String, +} + +/// Get blob content +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/blobs", + tag = "Git", + operation_id = "gitGetBlob", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Blob retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Blob not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_blob( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_get_blob( + &session, + &path.workspace_name, + &path.repo_name, + &query.revision, + &query.path, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_cherry_pick.rs b/api/repo/git/git_cherry_pick.rs new file mode 100644 index 0000000..b248f56 --- /dev/null +++ b/api/repo/git/git_cherry_pick.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::CherryPickParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Cherry-pick a commit +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/cherry-pick", + tag = "Git", + operation_id = "gitCherryPick", + params(PathParams), + request_body( + content = CherryPickParams, + description = "Cherry-pick parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Cherry-pick completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 409, description = "Cherry-pick conflict", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_cherry_pick( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .repo + .git_cherry_pick( + &session, + &path.workspace_name, + &path.repo_name, + params.into_inner(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_commit_extras2.rs b/api/repo/git/git_commit_extras2.rs new file mode 100644 index 0000000..fe35a08 --- /dev/null +++ b/api/repo/git/git_commit_extras2.rs @@ -0,0 +1,137 @@ +use crate::api::response::ApiResponse; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} +#[derive(Debug, Deserialize, IntoParams)] +pub struct RevisionPathParams { + pub workspace_name: String, + pub repo_name: String, + pub revision: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct FindCommitQuery { + pub revision: String, +} +#[utoipa::path(get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/find-commit", tag = "Git", operation_id = "gitFindCommit", params(PathParams, FindCommitQuery), responses((status=200,body=ApiResponse)), security(("session_cookie"=[])))] +pub async fn git_find_commit( + service: web::Data, + session: Session, + path: web::Path, + q: web::Query, +) -> Result { + let r = service + .repo + .git_find_commit(&session, &path.workspace_name, &path.repo_name, &q.revision) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(r))) +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct ListCommitsByOidBody { + pub oids: Vec, +} +#[utoipa::path(post, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/by-oid", tag = "Git", operation_id = "gitCommitsByOid", params(PathParams), request_body(content=ListCommitsByOidBody), responses((status=200,body=ApiResponse)), security(("session_cookie"=[])))] +pub async fn git_commits_by_oid( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let oids: Vec> = body + .oids + .iter() + .map(|s| hex::decode(s).unwrap_or_default()) + .collect(); + let r = service + .repo + .git_list_commits_by_oid(&session, &path.workspace_name, &path.repo_name, oids) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(r))) +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct AncestorQuery { + pub ancestor: String, + pub descendant: String, +} +#[utoipa::path(get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commit-is-ancestor", tag = "Git", operation_id = "gitCommitIsAncestor", params(PathParams, AncestorQuery), responses((status=200,body=ApiResponse)), security(("session_cookie"=[])))] +pub async fn git_commit_is_ancestor( + service: web::Data, + session: Session, + path: web::Path, + q: web::Query, +) -> Result { + let r = service + .repo + .git_commit_is_ancestor( + &session, + &path.workspace_name, + &path.repo_name, + &q.ancestor, + &q.descendant, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(r))) +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct LastCommitQuery { + pub path: String, + pub revision: Option, +} +#[utoipa::path(get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/last-commit", tag = "Git", operation_id = "gitLastCommitForPath", params(PathParams, LastCommitQuery), responses((status=200,body=ApiResponse)), security(("session_cookie"=[])))] +pub async fn git_last_commit( + service: web::Data, + session: Session, + path: web::Path, + q: web::Query, +) -> Result { + let r = service + .repo + .git_last_commit_for_path( + &session, + &path.workspace_name, + &path.repo_name, + &q.path, + q.revision.as_deref(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(r))) +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct CommitsByMsgQuery { + pub q: String, + pub revision: Option, + pub limit: Option, +} +#[utoipa::path(get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/search", tag = "Git", operation_id = "gitCommitsByMessage", params(PathParams, CommitsByMsgQuery), responses((status=200,body=ApiResponse)), security(("session_cookie"=[])))] +pub async fn git_commits_by_message( + service: web::Data, + session: Session, + path: web::Path, + q: web::Query, +) -> Result { + let r = service + .repo + .git_commits_by_message( + &session, + &path.workspace_name, + &path.repo_name, + &q.q, + q.revision.as_deref(), + q.limit.unwrap_or(20), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(r))) +} diff --git a/api/repo/git/git_commit_stats.rs b/api/repo/git/git_commit_stats.rs new file mode 100644 index 0000000..db4ad5a --- /dev/null +++ b/api/repo/git/git_commit_stats.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub revision: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/{revision}/stats", + tag = "Git", + operation_id = "gitCommitStats", + params(PathParams), + responses( + (status = 200, description = "Commit stats", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_commit_stats( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_commit_stats( + &session, + &path.workspace_name, + &path.repo_name, + &path.revision, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_compare.rs b/api/repo/git/git_compare.rs new file mode 100644 index 0000000..b02413a --- /dev/null +++ b/api/repo/git/git_compare.rs @@ -0,0 +1,59 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::CompareParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub base: String, + pub head: String, + pub page_size: Option, +} + +/// Compare two commits +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/compare", + tag = "Git", + operation_id = "gitCompare", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Comparison completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_compare( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_compare_commits( + &session, + &path.workspace_name, + &path.repo_name, + CompareParams { + base: query.base.clone(), + head: query.head.clone(), + page_size: query.page_size, + }, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_conflicts.rs b/api/repo/git/git_conflicts.rs new file mode 100644 index 0000000..67d4408 --- /dev/null +++ b/api/repo/git/git_conflicts.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub target: String, + pub source: String, + pub page_size: Option, +} + +/// List merge conflicts +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/conflicts", + tag = "Git", + operation_id = "gitListConflicts", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Conflicts listed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_conflicts( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_list_conflicts( + &session, + &path.workspace_name, + &path.repo_name, + &query.target, + &query.source, + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_count_commits.rs b/api/repo/git/git_count_commits.rs new file mode 100644 index 0000000..fc093c1 --- /dev/null +++ b/api/repo/git/git_count_commits.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub revision: Option, + pub path: Option, + pub since: Option, + pub until: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/count", + tag = "Git", + operation_id = "gitCountCommits", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Commit count", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_count_commits( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_count_commits( + &session, + &path.workspace_name, + &path.repo_name, + query.revision.as_deref(), + query.path.as_deref(), + query.since.as_deref(), + query.until.as_deref(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_count_diverging.rs b/api/repo/git/git_count_diverging.rs new file mode 100644 index 0000000..f0cf6fa --- /dev/null +++ b/api/repo/git/git_count_diverging.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub left: String, + pub right: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/compare/diverging", + tag = "Git", + operation_id = "gitCountDivergingCommits", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Diverging commit counts", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_count_diverging( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_count_diverging_commits( + &session, + &path.workspace_name, + &path.repo_name, + &query.left, + &query.right, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_create_branch.rs b/api/repo/git/git_create_branch.rs new file mode 100644 index 0000000..2add9f7 --- /dev/null +++ b/api/repo/git/git_create_branch.rs @@ -0,0 +1,59 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams, ToSchema)] +pub struct CreateBranchBody { + pub branch_name: String, + pub start_point: String, +} + +/// Create a branch +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches", + tag = "Git", + operation_id = "gitCreateBranch", + params(PathParams), + request_body( + content = CreateBranchBody, + description = "Branch creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Branch created successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_create_branch( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .repo + .git_create_branch( + &session, + &path.workspace_name, + &path.repo_name, + &body.branch_name, + &body.start_point, + ) + .await?; + + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_create_commit.rs b/api/repo/git/git_create_commit.rs new file mode 100644 index 0000000..c21aafa --- /dev/null +++ b/api/repo/git/git_create_commit.rs @@ -0,0 +1,53 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::CreateCommitParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Create a commit +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits", + tag = "Git", + operation_id = "gitCreateCommit", + params(PathParams), + request_body( + content = CreateCommitParams, + description = "Commit creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Commit created successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_create_commit( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .repo + .git_create_commit( + &session, + &path.workspace_name, + &path.repo_name, + params.into_inner(), + ) + .await?; + + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_create_tag.rs b/api/repo/git/git_create_tag.rs new file mode 100644 index 0000000..d7ef5e4 --- /dev/null +++ b/api/repo/git/git_create_tag.rs @@ -0,0 +1,63 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams, ToSchema)] +pub struct CreateTagBody { + pub tag_name: String, + pub target: String, + pub message: Option, + pub annotated: Option, +} + +/// Create a tag +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags", + tag = "Git", + operation_id = "gitCreateTag", + params(PathParams), + request_body( + content = CreateTagBody, + description = "Tag creation parameters", + content_type = "application/json" + ), + responses( + (status = 201, description = "Tag created successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_create_tag( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .repo + .git_create_tag( + &session, + &path.workspace_name, + &path.repo_name, + &body.tag_name, + &body.target, + body.message.clone(), + body.annotated.unwrap_or(false), + ) + .await?; + + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_delete_branch.rs b/api/repo/git/git_delete_branch.rs new file mode 100644 index 0000000..c76c2b7 --- /dev/null +++ b/api/repo/git/git_delete_branch.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub branch_name: String, +} + +/// Delete a branch +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches/{branch_name}", + tag = "Git", + operation_id = "gitDeleteBranch", + params(PathParams), + responses( + (status = 200, description = "Branch deleted successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_delete_branch( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .repo + .git_delete_branch( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new("Branch deleted successfully".to_string()))) +} diff --git a/api/repo/git/git_delete_tag.rs b/api/repo/git/git_delete_tag.rs new file mode 100644 index 0000000..470e4f5 --- /dev/null +++ b/api/repo/git/git_delete_tag.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub tag_name: String, +} + +/// Delete a tag +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags/{tag_name}", + tag = "Git", + operation_id = "gitDeleteTag", + params(PathParams), + responses( + (status = 200, description = "Tag deleted successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_delete_tag( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .repo + .git_delete_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new("Tag deleted successfully".to_string()))) +} diff --git a/api/repo/git/git_diff.rs b/api/repo/git/git_diff.rs new file mode 100644 index 0000000..dc828eb --- /dev/null +++ b/api/repo/git/git_diff.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub base: String, + pub head: String, + pub page_size: Option, +} + +/// Get diff between two revisions +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/diff", + tag = "Git", + operation_id = "gitDiff", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Diff retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_diff( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_diff( + &session, + &path.workspace_name, + &path.repo_name, + &query.base, + &query.head, + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_diff_stats.rs b/api/repo/git/git_diff_stats.rs new file mode 100644 index 0000000..9db56d4 --- /dev/null +++ b/api/repo/git/git_diff_stats.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub base: String, + pub head: String, +} + +/// Get diff statistics between two revisions +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/diff-stats", + tag = "Git", + operation_id = "gitDiffStats", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Diff stats retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_diff_stats( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_diff_stats( + &session, + &path.workspace_name, + &path.repo_name, + &query.base, + &query.head, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_exists.rs b/api/repo/git/git_exists.rs new file mode 100644 index 0000000..b843760 --- /dev/null +++ b/api/repo/git/git_exists.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Check if repository exists +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/exists", + tag = "Git", + operation_id = "gitRepoExists", + params(PathParams), + responses( + (status = 200, description = "Repository existence check completed", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_exists( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_repo_exists(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_gc.rs b/api/repo/git/git_gc.rs new file mode 100644 index 0000000..7251372 --- /dev/null +++ b/api/repo/git/git_gc.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Run garbage collection +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/garbage-collect", + tag = "Git", + operation_id = "gitGarbageCollect", + params(PathParams), + responses( + (status = 200, description = "Garbage collection completed", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_gc( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_garbage_collect(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_get_branch.rs b/api/repo/git/git_get_branch.rs new file mode 100644 index 0000000..590f45c --- /dev/null +++ b/api/repo/git/git_get_branch.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub branch_name: String, +} + +/// Get a branch +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches/{branch_name}", + tag = "Git", + operation_id = "gitGetBranch", + params(PathParams), + responses( + (status = 200, description = "Branch retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Branch not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_get_branch( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_get_branch( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_get_commit.rs b/api/repo/git/git_get_commit.rs new file mode 100644 index 0000000..6f84d57 --- /dev/null +++ b/api/repo/git/git_get_commit.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub revision: String, +} + +/// Get a single commit +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits/{revision}", + tag = "Git", + operation_id = "gitGetCommit", + params(PathParams), + responses( + (status = 200, description = "Commit retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Commit not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_get_commit( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_get_commit( + &session, + &path.workspace_name, + &path.repo_name, + &path.revision, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_get_tag.rs b/api/repo/git/git_get_tag.rs new file mode 100644 index 0000000..3acd763 --- /dev/null +++ b/api/repo/git/git_get_tag.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub tag_name: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags/{tag_name}", + tag = "Git", + operation_id = "gitGetTag", + params(PathParams), + responses( + (status = 200, description = "Tag details", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_get_tag( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_get_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_health.rs b/api/repo/git/git_health.rs new file mode 100644 index 0000000..0fd6dce --- /dev/null +++ b/api/repo/git/git_health.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Check repository health +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/health", + tag = "Git", + operation_id = "gitRepoHealth", + params(PathParams), + responses( + (status = 200, description = "Repository health check completed", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_health( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_repo_health(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_info.rs b/api/repo/git/git_info.rs new file mode 100644 index 0000000..e125e91 --- /dev/null +++ b/api/repo/git/git_info.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Get repository info +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/info", + tag = "Git", + operation_id = "gitRepoInfo", + params(PathParams), + responses( + (status = 200, description = "Repository info retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_info( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_repo_info(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_license.rs b/api/repo/git/git_license.rs new file mode 100644 index 0000000..b41c77e --- /dev/null +++ b/api/repo/git/git_license.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/license", + tag = "Git", + operation_id = "gitLicense", + params(PathParams), + responses( + (status = 200, description = "License detection result", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_license( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_find_license(&session, &path.workspace_name, &path.repo_name) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_list_branches.rs b/api/repo/git/git_list_branches.rs new file mode 100644 index 0000000..9d040c1 --- /dev/null +++ b/api/repo/git/git_list_branches.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub pattern: Option, + pub page_size: Option, + pub page_token: Option, +} + +/// List branches +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches", + tag = "Git", + operation_id = "gitListBranches", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Branches listed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_list_branches( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_list_branches( + &session, + &path.workspace_name, + &path.repo_name, + query.pattern.clone(), + query.page_size.unwrap_or(30), + query.page_token.clone(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_list_commits.rs b/api/repo/git/git_list_commits.rs new file mode 100644 index 0000000..2ca090e --- /dev/null +++ b/api/repo/git/git_list_commits.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub revision: String, + pub path: Option, + pub page_size: Option, +} + +/// List commits +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/commits", + tag = "Git", + operation_id = "gitListCommits", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Commits listed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_list_commits( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_list_commits( + &session, + &path.workspace_name, + &path.repo_name, + &query.revision, + query.path.clone(), + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_merge.rs b/api/repo/git/git_merge.rs new file mode 100644 index 0000000..39c891a --- /dev/null +++ b/api/repo/git/git_merge.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::MergeParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Merge branches +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/merge", + tag = "Git", + operation_id = "gitMerge", + params(PathParams), + request_body( + content = MergeParams, + description = "Merge parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Merge completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 409, description = "Merge conflict", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_merge( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .repo + .git_merge( + &session, + &path.workspace_name, + &path.repo_name, + params.into_inner(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_merge_check.rs b/api/repo/git/git_merge_check.rs new file mode 100644 index 0000000..9c08495 --- /dev/null +++ b/api/repo/git/git_merge_check.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub target: String, + pub source: String, +} + +/// Check if a merge is possible +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/merge-check", + tag = "Git", + operation_id = "gitMergeCheck", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Merge check completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_merge_check( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_check_merge( + &session, + &path.workspace_name, + &path.repo_name, + &query.target, + &query.source, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_rebase.rs b/api/repo/git/git_rebase.rs new file mode 100644 index 0000000..bdc8d13 --- /dev/null +++ b/api/repo/git/git_rebase.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::RebaseParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Rebase a branch +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/rebase", + tag = "Git", + operation_id = "gitRebase", + params(PathParams), + request_body( + content = RebaseParams, + description = "Rebase parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Rebase completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 409, description = "Rebase conflict", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_rebase( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .repo + .git_rebase( + &session, + &path.workspace_name, + &path.repo_name, + params.into_inner(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_rename_branch.rs b/api/repo/git/git_rename_branch.rs new file mode 100644 index 0000000..1bb5c59 --- /dev/null +++ b/api/repo/git/git_rename_branch.rs @@ -0,0 +1,53 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub branch_name: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct RenameBody { + pub new_name: String, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/branches/{branch_name}/rename", + tag = "Git", + operation_id = "gitRenameBranch", + params(PathParams), + request_body(content = RenameBody), + responses( + (status = 200, description = "Branch renamed", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_rename_branch( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .repo + .git_rename_branch( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, + &body.new_name, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_revert.rs b/api/repo/git/git_revert.rs new file mode 100644 index 0000000..12a008e --- /dev/null +++ b/api/repo/git/git_revert.rs @@ -0,0 +1,53 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::git::merge::RevertParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Revert a commit +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/revert", + tag = "Git", + operation_id = "gitRevert", + params(PathParams), + request_body( + content = RevertParams, + description = "Revert parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Revert completed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_revert( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let result = service + .repo + .git_revert( + &session, + &path.workspace_name, + &path.repo_name, + params.into_inner(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_search.rs b/api/repo/git/git_search.rs new file mode 100644 index 0000000..8621c27 --- /dev/null +++ b/api/repo/git/git_search.rs @@ -0,0 +1,98 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct SearchQueryParams { + pub q: String, + pub revision: Option, + pub max_results: Option, + pub case_sensitive: Option, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct SearchFilesQueryParams { + pub q: String, + pub revision: Option, + pub max_results: Option, + pub recursive: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/search/content", + tag = "Git", + operation_id = "gitSearchContent", + params(PathParams, SearchQueryParams), + responses( + (status = 200, description = "Search results", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_search_content( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_search_content( + &session, + &path.workspace_name, + &path.repo_name, + &query.q, + query.revision.as_deref(), + query.max_results.unwrap_or(100), + query.case_sensitive.unwrap_or(false), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/search/files", + tag = "Git", + operation_id = "gitSearchFiles", + params(PathParams, SearchFilesQueryParams), + responses( + (status = 200, description = "File search results", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_search_files( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_search_files( + &session, + &path.workspace_name, + &path.repo_name, + &query.q, + query.revision.as_deref(), + query.max_results.unwrap_or(100), + query.recursive.unwrap_or(true), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_stats.rs b/api/repo/git/git_stats.rs new file mode 100644 index 0000000..138f6cd --- /dev/null +++ b/api/repo/git/git_stats.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +/// Get repository statistics +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/stats", + tag = "Git", + operation_id = "gitRepoStats", + params(PathParams), + responses( + (status = 200, description = "Repository statistics retrieved successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_stats( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_repo_stats(&session, &path.workspace_name, &path.repo_name) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_tags.rs b/api/repo/git/git_tags.rs new file mode 100644 index 0000000..3bb13d7 --- /dev/null +++ b/api/repo/git/git_tags.rs @@ -0,0 +1,54 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub pattern: Option, + pub page_size: Option, +} + +/// List tags +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags", + tag = "Git", + operation_id = "gitListTags", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Tags listed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_tags( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_list_tags( + &session, + &path.workspace_name, + &path.repo_name, + query.pattern.clone(), + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_tree.rs b/api/repo/git/git_tree.rs new file mode 100644 index 0000000..1ebc0f0 --- /dev/null +++ b/api/repo/git/git_tree.rs @@ -0,0 +1,58 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub revision: String, + pub path: Option, + pub recursive: Option, + pub page_size: Option, +} + +/// List tree contents +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tree", + tag = "Git", + operation_id = "gitListTree", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Tree listed successfully", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_tree( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .git_list_tree( + &session, + &path.workspace_name, + &path.repo_name, + &query.revision, + query.path.clone(), + query.recursive.unwrap_or(false), + query.page_size.unwrap_or(30), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/git_verify_tag.rs b/api/repo/git/git_verify_tag.rs new file mode 100644 index 0000000..6ec7dfa --- /dev/null +++ b/api/repo/git/git_verify_tag.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub tag_name: String, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/git/tags/{tag_name}/verify", + tag = "Git", + operation_id = "gitVerifyTag", + params(PathParams), + responses( + (status = 200, description = "Tag signature verification result", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn git_verify_tag( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let result = service + .repo + .git_verify_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/repo/git/mod.rs b/api/repo/git/mod.rs new file mode 100644 index 0000000..7a12842 --- /dev/null +++ b/api/repo/git/mod.rs @@ -0,0 +1,218 @@ +pub mod git_archive; +pub mod git_blame; +pub mod git_blob; +pub mod git_cherry_pick; +pub mod git_commit_extras2; +pub mod git_commit_stats; +pub mod git_compare; +pub mod git_compare_branch; +pub mod git_conflicts; +pub mod git_count_commits; +pub mod git_count_diverging; +pub mod git_create_branch; +pub mod git_create_commit; +pub mod git_create_tag; +pub mod git_delete_branch; +pub mod git_delete_tag; +pub mod git_diff; +pub mod git_diff_extras; +pub mod git_diff_stats; +pub mod git_exists; +pub mod git_gc; +pub mod git_get_branch; +pub mod git_get_commit; +pub mod git_get_tag; +pub mod git_health; +pub mod git_info; +pub mod git_license; +pub mod git_list_branches; +pub mod git_list_commits; +pub mod git_merge; +pub mod git_merge_check; +pub mod git_rebase; +pub mod git_rename_branch; +pub mod git_repository_extras; +pub mod git_revert; +pub mod git_search; +pub mod git_stats; +pub mod git_tags; +pub mod git_tree; +pub mod git_tree_extras; +pub mod git_verify_tag; + +use actix_web::web; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/git") + .route( + "/commits", + web::get().to(git_list_commits::git_list_commits), + ) + .route( + "/commits/{revision}", + web::get().to(git_get_commit::git_get_commit), + ) + .route( + "/commits/{revision}/stats", + web::get().to(git_commit_stats::git_commit_stats), + ) + .route( + "/commits", + web::post().to(git_create_commit::git_create_commit), + ) + .route( + "/commits/count", + web::get().to(git_count_commits::git_count_commits), + ) + .route("/diff", web::get().to(git_diff::git_diff)) + .route("/diff-stats", web::get().to(git_diff_stats::git_diff_stats)) + .route("/compare", web::get().to(git_compare::git_compare)) + .route( + "/compare/diverging", + web::get().to(git_count_diverging::git_count_diverging), + ) + .route("/archive", web::get().to(git_archive::git_archive)) + .route("/license", web::get().to(git_license::git_license)) + .route( + "/search/content", + web::get().to(git_search::git_search_content), + ) + .route("/search/files", web::get().to(git_search::git_search_files)) + .route( + "/branches", + web::get().to(git_list_branches::git_list_branches), + ) + .route( + "/branches/{branch_name}", + web::get().to(git_get_branch::git_get_branch), + ) + .route( + "/branches/{branch_name}/compare", + web::get().to(git_compare_branch::git_compare_branch), + ) + .route( + "/branches/{branch_name}/rename", + web::post().to(git_rename_branch::git_rename_branch), + ) + .route( + "/branches", + web::post().to(git_create_branch::git_create_branch), + ) + .route( + "/branches/{branch_name}", + web::delete().to(git_delete_branch::git_delete_branch), + ) + .route( + "/merge-check", + web::get().to(git_merge_check::git_merge_check), + ) + .route("/merge", web::post().to(git_merge::git_merge)) + .route("/rebase", web::post().to(git_rebase::git_rebase)) + .route( + "/cherry-pick", + web::post().to(git_cherry_pick::git_cherry_pick), + ) + .route("/revert", web::post().to(git_revert::git_revert)) + .route("/conflicts", web::get().to(git_conflicts::git_conflicts)) + .route("/tree", web::get().to(git_tree::git_tree)) + .route("/blobs", web::get().to(git_blob::git_blob)) + .route("/blame", web::get().to(git_blame::git_blame)) + .route("/tags", web::get().to(git_tags::git_tags)) + .route("/tags", web::post().to(git_create_tag::git_create_tag)) + .route("/tags/{tag_name}", web::get().to(git_get_tag::git_get_tag)) + .route( + "/tags/{tag_name}/verify", + web::post().to(git_verify_tag::git_verify_tag), + ) + .route( + "/tags/{tag_name}", + web::delete().to(git_delete_tag::git_delete_tag), + ) + .route("/info", web::get().to(git_info::git_info)) + .route("/exists", web::get().to(git_exists::git_exists)) + .route("/stats", web::get().to(git_stats::git_stats)) + .route("/health", web::get().to(git_health::git_health)) + .route("/garbage-collect", web::post().to(git_gc::git_gc)) + .route( + "/default-branch", + web::get().to(git_repository_extras::git_default_branch), + ) + .route( + "/object-format", + web::get().to(git_repository_extras::git_object_format), + ) + .route( + "/size", + web::get().to(git_repository_extras::git_repository_size), + ) + .route( + "/objects-size", + web::post().to(git_repository_extras::git_objects_size), + ) + .route( + "/check-objects", + web::post().to(git_repository_extras::git_check_objects), + ) + .route( + "/merge-base", + web::get().to(git_repository_extras::git_merge_base), + ) + .route( + "/archive/entries", + web::get().to(git_repository_extras::git_archive_entries), + ) + .route( + "/commits/{revision}/ancestors", + web::get().to(git_repository_extras::git_commit_ancestors), + ) + .route( + "/find-commit", + web::get().to(git_commit_extras2::git_find_commit), + ) + .route( + "/commits/by-oid", + web::post().to(git_commit_extras2::git_commits_by_oid), + ) + .route( + "/commit-is-ancestor", + web::get().to(git_commit_extras2::git_commit_is_ancestor), + ) + .route( + "/last-commit", + web::get().to(git_commit_extras2::git_last_commit), + ) + .route( + "/commits/search", + web::get().to(git_commit_extras2::git_commits_by_message), + ) + .route("/raw-blob", web::get().to(git_tree_extras::git_raw_blob)) + .route( + "/file-metadata", + web::get().to(git_tree_extras::git_file_metadata), + ) + .route( + "/find-files", + web::get().to(git_tree_extras::git_find_files), + ) + .route("/get-tree", web::get().to(git_tree_extras::git_get_tree)) + .route( + "/commit-diff/{revision}", + web::get().to(git_diff_extras::git_commit_diff), + ) + .route("/patch", web::get().to(git_diff_extras::git_patch)) + .route("/raw-diff", web::get().to(git_diff_extras::git_raw_diff)) + .route( + "/changed-paths", + web::get().to(git_diff_extras::git_changed_paths), + ) + .route( + "/stream-blame", + web::get().to(git_diff_extras::git_stream_blame), + ) + .route( + "/resolve-conflicts", + web::post().to(git_diff_extras::git_resolve_conflicts), + ), + ); +} diff --git a/api/repo/list.rs b/api/repo/list.rs index c76ee7e..43d7ee0 100644 --- a/api/repo/list.rs +++ b/api/repo/list.rs @@ -1,10 +1,12 @@ use actix_web::{HttpResponse, web}; use serde::Deserialize; use utoipa::IntoParams; +use uuid::Uuid; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::session::Session; @@ -41,7 +43,7 @@ pub struct QueryParams { QueryParams, ), responses( - (status = 200, description = "Repositories listed successfully. Returns an array of repository objects with metadata.", body = ApiResponse>), + (status = 200, description = "Repositories listed successfully. Returns an array of repository objects with metadata.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions to access this workspace", body = ApiErrorResponse), (status = 404, description = "Workspace not found", body = ApiErrorResponse), @@ -67,5 +69,19 @@ pub async fn list( ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(repos))) + let db = &service.ctx.db; + let owner_ids: Vec = repos.iter().map(|r| r.owner_id).collect(); + let ws_ids: Vec = repos.iter().map(|r| r.workspace_id).collect(); + let users = resolve_users(db, &owner_ids).await?; + let workspaces = resolve_workspaces(db, &ws_ids).await?; + let details: Vec = repos + .into_iter() + .map(|r| { + let owner = users.get(&r.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces.get(&r.workspace_id).cloned().unwrap_or_default(); + r.into_detail(owner, workspace) + }) + .collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } diff --git a/api/repo/list_branches.rs b/api/repo/list_branches.rs index 89a1bca..cc6eb1c 100644 --- a/api/repo/list_branches.rs +++ b/api/repo/list_branches.rs @@ -4,36 +4,21 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::RepoBranch; use crate::service::AppService; use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, } #[derive(Debug, Deserialize, IntoParams)] pub struct QueryParams { - /// Maximum number of branches to return (default: 50, max: 100) pub limit: Option, - /// Number of branches to skip for pagination (default: 0) pub offset: Option, } -/// List branches in a repository -/// -/// Returns a paginated list of all branches in the repository, sorted by name alphabetically. -/// Includes branch metadata such as: -/// - Branch name and commit SHA -/// - Protected status -/// - Default branch flag -/// - Last push information -/// -/// Requires read access to the repository. #[utoipa::path( get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches", @@ -41,15 +26,11 @@ pub struct QueryParams { operation_id = "repoListBranches", params(PathParams, QueryParams), responses( - (status = 200, description = "Branches listed successfully. Returns an array of branch objects with metadata.", body = ApiResponse>), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse), - (status = 404, description = "Repository or workspace not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), + (status = 200, description = "Branches listed successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Repository not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn list_branches( service: web::Data, @@ -57,16 +38,25 @@ pub async fn list_branches( path: web::Path, query: web::Query, ) -> Result { - let branches = service + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let offset = query.offset.unwrap_or(0).max(0); + let page_size = limit as u32; + let page_token = if offset > 0 { + format!("{offset}") + } else { + String::new() + }; + + let result = service .repo - .repo_branches( + .git_list_branches( &session, &path.workspace_name, &path.repo_name, - query.limit.unwrap_or(50), - query.offset.unwrap_or(0), + None, + page_size, + Some(page_token), ) .await?; - - Ok(HttpResponse::Ok().json(ApiResponse::new(branches))) + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) } diff --git a/api/repo/list_tags.rs b/api/repo/list_tags.rs index 9a3599d..acff848 100644 --- a/api/repo/list_tags.rs +++ b/api/repo/list_tags.rs @@ -4,35 +4,21 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::RepoTag; use crate::service::AppService; use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, } #[derive(Debug, Deserialize, IntoParams)] pub struct QueryParams { - /// Maximum number of tags to return (default: 50, max: 100) pub limit: Option, - /// Number of tags to skip for pagination (default: 0) pub offset: Option, } -/// List tags in a repository -/// -/// Returns a paginated list of all tags in the repository, sorted by creation date (newest first). -/// Includes tag metadata such as: -/// - Tag name and commit SHA -/// - Tagger information and timestamp -/// - Tag message (for annotated tags) -/// -/// Requires read access to the repository. #[utoipa::path( get, path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags", @@ -40,15 +26,11 @@ pub struct QueryParams { operation_id = "repoListTags", params(PathParams, QueryParams), responses( - (status = 200, description = "Tags listed successfully. Returns an array of tag objects with metadata.", body = ApiResponse>), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse), - (status = 404, description = "Repository or workspace not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), + (status = 200, description = "Tags listed", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Repository not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn list_tags( service: web::Data, @@ -56,16 +38,18 @@ pub async fn list_tags( path: web::Path, query: web::Query, ) -> Result { - let tags = service + let limit = query.limit.unwrap_or(50).clamp(1, 100); + let page_size = limit as u32; + + let result = service .repo - .repo_tags( + .git_list_tags( &session, &path.workspace_name, &path.repo_name, - query.limit.unwrap_or(50), - query.offset.unwrap_or(0), + None, + page_size, ) .await?; - - Ok(HttpResponse::Ok().json(ApiResponse::new(tags))) + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) } diff --git a/api/repo/mod.rs b/api/repo/mod.rs index 90a4323..054ee3a 100644 --- a/api/repo/mod.rs +++ b/api/repo/mod.rs @@ -5,6 +5,7 @@ pub mod add_deploy_key; pub mod add_member; pub mod archive; pub mod check_branch_merge; +pub mod contributors; pub mod create; pub mod create_branch; pub mod create_commit_comment; @@ -17,14 +18,24 @@ pub mod create_webhook; pub mod delete; pub mod delete_branch; pub mod delete_deploy_key; +pub mod delete_fork; pub mod delete_protection_rule; pub mod delete_release; pub mod delete_tag; pub mod delete_webhook; pub mod fork_repo; pub mod get; +pub mod get_branch; +pub mod get_commit_status; +pub mod get_deploy_key; +pub mod get_invitation; +pub mod get_member; pub mod get_protection_rule; +pub mod get_release; pub mod get_stats; +pub mod get_tag; +pub mod get_webhook; +pub mod git; pub mod leave_repo; pub mod list; pub mod list_branches; @@ -42,27 +53,33 @@ pub mod list_watchers; pub mod list_webhooks; pub mod match_protection; pub mod refresh_stats; +pub mod release_assets; pub mod remove_member; +pub mod repo_webhook_deliveries; +pub mod repo_webhook_retry; pub mod resolve_commit_comment; pub mod revoke_invitation; pub mod set_branch_protection; pub mod set_default_branch; pub mod star_repo; pub mod sync_fork; +pub mod topics; pub mod transfer_owner; pub mod unarchive; pub mod unstar_repo; pub mod unwatch_repo; pub mod update; +pub mod update_commit_comment; pub mod update_member_role; pub mod update_protection_rule; pub mod update_release; +pub mod update_tag; pub mod update_webhook; pub mod watch_repo; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/workspaces/{workspace_name}/repos") + web::scope("") .route("", web::get().to(list::list)) .route("", web::post().to(create::create)) .route("/{repo_name}", web::get().to(get::get)) @@ -86,21 +103,33 @@ pub fn configure(cfg: &mut web::ServiceConfig) { web::post().to(create_branch::create_branch), ) .route( - "/{repo_name}/branches/{branch_id}/default", + "/{repo_name}/branches/{branch_name}/default", web::put().to(set_default_branch::set_default_branch), ) .route( - "/{repo_name}/branches/{branch_id}/protection", + "/{repo_name}/branches/{branch_name}/protection", web::put().to(set_branch_protection::set_branch_protection), ) .route( - "/{repo_name}/branches/{branch_id}", + "/{repo_name}/branches/{branch_name}", + web::get().to(get_branch::get_branch), + ) + .route( + "/{repo_name}/branches/{branch_name}", web::delete().to(delete_branch::delete_branch), ) .route("/{repo_name}/tags", web::get().to(list_tags::list_tags)) .route("/{repo_name}/tags", web::post().to(create_tag::create_tag)) .route( - "/{repo_name}/tags/{tag_id}", + "/{repo_name}/tags/{tag_name}", + web::get().to(get_tag::get_tag), + ) + .route( + "/{repo_name}/tags/{tag_name}", + web::put().to(update_tag::update_tag), + ) + .route( + "/{repo_name}/tags/{tag_name}", web::delete().to(delete_tag::delete_tag), ) .route( @@ -111,6 +140,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/releases", web::post().to(create_release::create_release), ) + .route( + "/{repo_name}/releases/{release_id}", + web::get().to(get_release::get_release), + ) .route( "/{repo_name}/releases/{release_id}", web::put().to(update_release::update_release), @@ -121,7 +154,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ) .route("/{repo_name}/forks", web::get().to(list_forks::list_forks)) .route("/{repo_name}/fork", web::post().to(fork_repo::fork_repo)) + .route( + "/{repo_name}/fork", + web::delete().to(delete_fork::delete_fork), + ) .route("/{repo_name}/sync", web::post().to(sync_fork::sync_fork)) + .route("/{repo_name}/topics", web::put().to(topics::update_topics)) .route("/{repo_name}/star", web::post().to(star_repo::star_repo)) .route( "/{repo_name}/star", @@ -152,6 +190,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/members/{member_id}/role", web::put().to(update_member_role::update_member_role), ) + .route( + "/{repo_name}/members/{member_id}", + web::get().to(get_member::get_member), + ) .route( "/{repo_name}/members/{member_id}", web::delete().to(remove_member::remove_member), @@ -165,6 +207,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/invitations", web::post().to(create_invitation::create_invitation), ) + .route( + "/{repo_name}/invitations/{invitation_id}", + web::get().to(get_invitation::get_invitation), + ) .route( "/{repo_name}/invitations/{invitation_id}", web::delete().to(revoke_invitation::revoke_invitation), @@ -177,6 +223,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/deploy-keys", web::post().to(add_deploy_key::add_deploy_key), ) + .route( + "/{repo_name}/deploy-keys/{key_id}", + web::get().to(get_deploy_key::get_deploy_key), + ) .route( "/{repo_name}/deploy-keys/{key_id}", web::delete().to(delete_deploy_key::delete_deploy_key), @@ -189,6 +239,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/webhooks", web::post().to(create_webhook::create_webhook), ) + .route( + "/{repo_name}/webhooks/{webhook_id}", + web::get().to(get_webhook::get_webhook), + ) .route( "/{repo_name}/webhooks/{webhook_id}", web::put().to(update_webhook::update_webhook), @@ -197,6 +251,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/webhooks/{webhook_id}", web::delete().to(delete_webhook::delete_webhook), ) + .route( + "/{repo_name}/webhooks/{webhook_id}/deliveries", + web::get().to(repo_webhook_deliveries::repo_webhook_deliveries), + ) + .route( + "/{repo_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry", + web::post().to(repo_webhook_retry::repo_webhook_retry), + ) .route( "/{repo_name}/protection-rules", web::get().to(list_protection_rules::list_protection_rules), @@ -229,6 +291,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/commits/{push_commit_id}/statuses", web::get().to(list_commit_statuses::list_commit_statuses), ) + .route( + "/{repo_name}/commits/{push_commit_id}/statuses/{status_id}", + web::get().to(get_commit_status::get_commit_status), + ) .route( "/{repo_name}/commit-statuses", web::post().to(create_commit_status::create_commit_status), @@ -245,14 +311,34 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{repo_name}/commit-comments/{comment_id}/resolve", web::post().to(resolve_commit_comment::resolve_commit_comment), ) + .route( + "/{repo_name}/commit-comments/{comment_id}", + web::put().to(update_commit_comment::update_commit_comment), + ) .route("/{repo_name}/stats", web::get().to(get_stats::get_stats)) .route( "/{repo_name}/stats/refresh", web::post().to(refresh_stats::refresh_stats), + ) + .route( + "/{repo_name}/contributors", + web::get().to(contributors::list_contributors), + ) + .route( + "/{repo_name}/releases/{release_id}/assets", + web::post().to(release_assets::upload_asset), + ) + .route( + "/{repo_name}/releases/{release_id}/assets", + web::get().to(release_assets::list_assets), + ) + .route( + "/{repo_name}/releases/{release_id}/assets/{asset_id}", + web::delete().to(release_assets::delete_asset), + ) + .route( + "/{repo_name}/releases/{release_id}/assets/{asset_id}/download", + web::get().to(release_assets::download_asset), ), - ) - .route( - "/repos/invitations/accept", - web::post().to(accept_invitation::accept_invitation), ); } diff --git a/api/repo/release_assets.rs b/api/repo/release_assets.rs new file mode 100644 index 0000000..cc281df --- /dev/null +++ b/api/repo/release_assets.rs @@ -0,0 +1,165 @@ +use actix_multipart::Multipart; +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::api::user::upload_avatar::parse_avatar_field; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub release_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct AssetPathParams { + pub workspace_name: String, + pub repo_name: String, + pub release_id: uuid::Uuid, + pub asset_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct ListQueryParams { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}/assets", + tag = "Repos", + operation_id = "repoUploadReleaseAsset", + params(PathParams), + request_body(content_type = "multipart/form-data"), + responses( + (status = 201, description = "Asset uploaded", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Release not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn upload_asset( + service: web::Data, + session: Session, + path: web::Path, + payload: Multipart, +) -> Result { + let (data, content_type, file_name) = parse_avatar_field(payload).await?; + let filename = file_name.unwrap_or_else(|| "asset.bin".to_string()); + let result = service + .repo + .repo_upload_release_asset( + &session, + &path.workspace_name, + &path.repo_name, + path.release_id, + &filename, + data, + content_type + .as_deref() + .unwrap_or("application/octet-stream"), + ) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}/assets", + tag = "Repos", + operation_id = "repoListReleaseAssets", + params(PathParams, ListQueryParams), + responses( + (status = 200, description = "List of release assets", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn list_assets( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let result = service + .repo + .repo_list_release_assets( + &session, + &path.workspace_name, + &path.repo_name, + path.release_id, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}/assets/{asset_id}", + tag = "Repos", + operation_id = "repoDeleteReleaseAsset", + params(AssetPathParams), + responses( + (status = 200, description = "Asset deleted", body = ApiResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn delete_asset( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .repo + .repo_delete_release_asset( + &session, + &path.workspace_name, + &path.repo_name, + path.release_id, + path.asset_id, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new("asset deleted".to_string()))) +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/releases/{release_id}/assets/{asset_id}/download", + tag = "Repos", + operation_id = "repoDownloadReleaseAsset", + params(AssetPathParams), + responses( + (status = 302, description = "Redirect to download URL"), + (status = 404, description = "Not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn download_asset( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let url = service + .repo + .repo_get_release_asset_download_url( + &session, + &path.workspace_name, + &path.repo_name, + path.release_id, + path.asset_id, + ) + .await?; + Ok(HttpResponse::Found() + .insert_header(("Location", url)) + .finish()) +} diff --git a/api/repo/repo_webhook_deliveries.rs b/api/repo/repo_webhook_deliveries.rs new file mode 100644 index 0000000..ce38826 --- /dev/null +++ b/api/repo/repo_webhook_deliveries.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub webhook_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}/deliveries", + tag = "Repos", + operation_id = "repoListWebhookDeliveries", + params(PathParams, QueryParams), + responses( + (status = 200, description = "Webhook deliveries listed successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Webhook not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn repo_webhook_deliveries( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let deliveries = service + .repo + .repo_webhook_deliveries( + &session, + &path.workspace_name, + &path.repo_name, + path.webhook_id, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(deliveries))) +} diff --git a/api/repo/repo_webhook_retry.rs b/api/repo/repo_webhook_retry.rs new file mode 100644 index 0000000..291e351 --- /dev/null +++ b/api/repo/repo_webhook_retry.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub webhook_id: uuid::Uuid, + pub delivery_id: uuid::Uuid, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry", + tag = "Repos", + operation_id = "repoRetryWebhookDelivery", + params(PathParams), + responses( + (status = 200, description = "Webhook delivery retried successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Webhook or delivery not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn repo_webhook_retry( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .repo + .repo_retry_webhook_delivery( + &session, + &path.workspace_name, + &path.repo_name, + path.webhook_id, + path.delivery_id, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new( + "Webhook delivery retried successfully".to_string(), + ))) +} diff --git a/api/repo/set_branch_protection.rs b/api/repo/set_branch_protection.rs index d1e0803..edde836 100644 --- a/api/repo/set_branch_protection.rs +++ b/api/repo/set_branch_protection.rs @@ -9,52 +9,29 @@ use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, - /// Branch ID (UUID) - pub branch_id: uuid::Uuid, + pub branch_name: String, } #[derive(Debug, Deserialize, ToSchema)] pub struct SetBranchProtectionParams { - /// Whether to enable branch protection pub protected: bool, } -/// Set branch protection -/// -/// Enables or disables protection for a specific branch. -/// Requires Admin role or higher in the repository. -/// -/// Effects: -/// - When enabled: prevents force pushes and branch deletion -/// - When disabled: allows force pushes and branch deletion -/// -/// Returns success message on completion. #[utoipa::path( put, - path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}/protection", + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}/protection", tag = "Repos", operation_id = "repoSetBranchProtection", params(PathParams), - request_body( - content = SetBranchProtectionParams, - description = "Branch protection parameters", - content_type = "application/json" - ), + request_body(content = SetBranchProtectionParams), responses( - (status = 200, description = "Branch protection rules set successfully.", body = ApiResponse), - (status = 400, description = "Invalid parameters: negative approvals count or conflicting protection settings", body = ApiErrorResponse), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse), - (status = 404, description = "Repository, workspace, or branch not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), + (status = 200, description = "Branch protection set", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Branch not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn set_branch_protection( service: web::Data, @@ -62,18 +39,28 @@ pub async fn set_branch_protection( path: web::Path, params: web::Json, ) -> Result { - service + // Verify branch exists via gRPC + let _ = service .repo - .repo_set_branch_protection( + .git_get_branch( &session, &path.workspace_name, &path.repo_name, - path.branch_id, + &path.branch_name, + ) + .await?; + + // Update DB protection flag (platform metadata, no gRPC equivalent) + service + .repo + .repo_set_branch_protection_by_name( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, params.protected, ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new( - "Branch protection rules set successfully".to_string(), - ))) + Ok(HttpResponse::Ok().json(ApiResponse::new("Branch protection updated".to_string()))) } diff --git a/api/repo/set_default_branch.rs b/api/repo/set_default_branch.rs index 2cffb0b..adca246 100644 --- a/api/repo/set_default_branch.rs +++ b/api/repo/set_default_branch.rs @@ -9,57 +9,50 @@ use crate::session::Session; #[derive(Debug, Deserialize, IntoParams)] pub struct PathParams { - /// Workspace name (unique identifier) pub workspace_name: String, - /// Repository name (unique within the workspace) pub repo_name: String, - /// Branch ID (UUID) - pub branch_id: uuid::Uuid, + pub branch_name: String, } -/// Set default branch -/// -/// Sets a branch as the repository's default branch. The default branch is used for: -/// - New pull requests base branch -/// - Repository cloning -/// - New branch creation base -/// -/// Requires Admin role or higher in the repository. -/// -/// Returns success message on completion. #[utoipa::path( put, - path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_id}/default", + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/branches/{branch_name}/default", tag = "Repos", operation_id = "repoSetDefaultBranch", params(PathParams), responses( - (status = 200, description = "Default branch set successfully. All new operations will use this branch as the default.", body = ApiResponse), - (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), - (status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse), - (status = 404, description = "Repository, workspace, or branch not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), + (status = 200, description = "Default branch set", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Branch not found", body = ApiErrorResponse), ), - security( - ("session_cookie" = []) - ) + security(("session_cookie" = [])) )] pub async fn set_default_branch( service: web::Data, session: Session, path: web::Path, ) -> Result { - service + // 1. Call gRPC to update the actual git HEAD ref + let _ = service .repo - .repo_set_default_branch( + .git_set_default_branch( &session, &path.workspace_name, &path.repo_name, - path.branch_id, + &path.branch_name, + ) + .await; + + // 2. Update DB metadata (platform-level default branch tracking) + service + .repo + .repo_set_default_branch_by_name( + &session, + &path.workspace_name, + &path.repo_name, + &path.branch_name, ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new( - "Default branch set successfully".to_string(), - ))) + Ok(HttpResponse::Ok().json(ApiResponse::new("Default branch set".to_string()))) } diff --git a/api/repo/topics.rs b/api/repo/topics.rs new file mode 100644 index 0000000..98eb542 --- /dev/null +++ b/api/repo/topics.rs @@ -0,0 +1,67 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::repo::core::UpdateRepoParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct TopicsBody { + pub topics: Vec, +} + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/topics", + tag = "Repos", + operation_id = "repoUpdateTopics", + params(PathParams), + request_body(content = TopicsBody), + responses( + (status = 200, description = "Topics updated", body = ApiResponse>), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Repo not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn update_topics( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let result = service + .repo + .repo_update( + &session, + &path.workspace_name, + &path.repo_name, + UpdateRepoParams { + name: None, + description: None, + visibility: None, + default_branch: None, + topics: Some(body.topics.clone()), + homepage: None, + has_issues: None, + has_wiki: None, + has_pull_requests: None, + allow_forking: None, + allow_merge_commit: None, + allow_squash_merge: None, + allow_rebase_merge: None, + delete_branch_on_merge: None, + }, + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(result.topics))) +} diff --git a/api/repo/transfer_owner.rs b/api/repo/transfer_owner.rs index 9b63a5b..c1b2be2 100644 --- a/api/repo/transfer_owner.rs +++ b/api/repo/transfer_owner.rs @@ -4,7 +4,8 @@ use utoipa::{IntoParams, ToSchema}; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::session::Session; @@ -46,7 +47,7 @@ pub struct TransferOwnerParams { content_type = "application/json" ), responses( - (status = 200, description = "Ownership transferred successfully. Returns the repository with updated owner information.", body = ApiResponse), + (status = 200, description = "Ownership transferred successfully. Returns the repository with updated owner information.", body = ApiResponse), (status = 400, description = "Invalid new owner ID or user is not a repository member", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (requires Owner role)", body = ApiErrorResponse), @@ -73,5 +74,15 @@ pub async fn transfer_owner( ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(repo))) + let db = &service.ctx.db; + let users = resolve_users(db, &[repo.owner_id]).await?; + let workspaces = resolve_workspaces(db, &[repo.workspace_id]).await?; + let owner = users.get(&repo.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces + .get(&repo.workspace_id) + .cloned() + .unwrap_or_default(); + let detail = repo.into_detail(owner, workspace); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/repo/update.rs b/api/repo/update.rs index fa0f2f5..e2f1add 100644 --- a/api/repo/update.rs +++ b/api/repo/update.rs @@ -4,7 +4,8 @@ use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::repos::Repo; +use crate::models::base_info::{resolve_users, resolve_workspaces}; +use crate::models::repos::RepoDetail; use crate::service::AppService; use crate::service::repo::core::UpdateRepoParams; use crate::session::Session; @@ -42,7 +43,7 @@ pub struct PathParams { content_type = "application/json" ), responses( - (status = 200, description = "Repository updated successfully. Returns the updated repository with full metadata.", body = ApiResponse), + (status = 200, description = "Repository updated successfully. Returns the updated repository with full metadata.", body = ApiResponse), (status = 400, description = "Invalid parameters: name too long, invalid characters, default branch doesn't exist, or public repos disabled", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 403, description = "Insufficient permissions (requires Admin role or higher)", body = ApiErrorResponse), @@ -70,5 +71,15 @@ pub async fn update( ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(repo))) + let db = &service.ctx.db; + let users = resolve_users(db, &[repo.owner_id]).await?; + let workspaces = resolve_workspaces(db, &[repo.workspace_id]).await?; + let owner = users.get(&repo.owner_id).cloned().unwrap_or_default(); + let workspace = workspaces + .get(&repo.workspace_id) + .cloned() + .unwrap_or_default(); + let detail = repo.into_detail(owner, workspace); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/repo/update_commit_comment.rs b/api/repo/update_commit_comment.rs new file mode 100644 index 0000000..6fb2e7c --- /dev/null +++ b/api/repo/update_commit_comment.rs @@ -0,0 +1,61 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::repos::RepoCommitComment; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub comment_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateCommitCommentParams { + pub body: String, +} + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/commit-comments/{comment_id}", + tag = "Repos", + operation_id = "repoUpdateCommitComment", + params(PathParams), + request_body( + content = UpdateCommitCommentParams, + description = "Commit comment update parameters", + content_type = "application/json" + ), + responses( + (status = 200, description = "Commit comment updated successfully", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Commit comment not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn update_commit_comment( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let comment = service + .repo + .repo_update_commit_comment( + &session, + &path.workspace_name, + &path.repo_name, + path.comment_id, + ¶ms.body, + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(comment))) +} diff --git a/api/repo/update_tag.rs b/api/repo/update_tag.rs new file mode 100644 index 0000000..0dad538 --- /dev/null +++ b/api/repo/update_tag.rs @@ -0,0 +1,82 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub repo_name: String, + pub tag_name: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateTagBody { + pub new_name: Option, + pub message: Option, +} + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/tags/{tag_name}", + tag = "Repos", + operation_id = "repoUpdateTag", + params(PathParams), + request_body(content = UpdateTagBody), + responses( + (status = 200, description = "Tag updated (delete+recreate if renamed)", body = ApiResponse), + (status = 400, description = "Invalid parameters", body = ApiErrorResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Tag not found", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn update_tag( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let msg = body.message.clone(); + let new_name = body.new_name.as_deref().unwrap_or(&path.tag_name); + + let tag = service + .repo + .git_get_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + + service + .repo + .git_delete_tag( + &session, + &path.workspace_name, + &path.repo_name, + &path.tag_name, + ) + .await?; + + let target_hex = tag.target_oid.map(|o| o.hex).unwrap_or_default(); + let result = service + .repo + .git_create_tag( + &session, + &path.workspace_name, + &path.repo_name, + new_name, + &target_hex, + msg.clone(), + msg.is_some(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(result))) +} diff --git a/api/routes.rs b/api/routes.rs index 044fae9..b1b3f54 100644 --- a/api/routes.rs +++ b/api/routes.rs @@ -2,29 +2,24 @@ use actix_web::web; use actix_web::web::scope; use crate::api::auth; -use crate::api::issue; -use crate::api::pr; -use crate::api::repo; +use crate::api::im; +use crate::api::internal; +use crate::api::notify; +use crate::api::repo::accept_invitation; use crate::api::user; -use crate::api::wiki; -use crate::api::workspace; pub fn init_routes(cfg: &mut web::ServiceConfig) { cfg.service( scope("/api/v1") .configure(auth::configure) .configure(user::configure) - .configure(workspace::configure) - .configure(repo::configure) - .service( - scope("/workspaces/{workspace_name}") - .configure(issue::configure) - .service( - scope("/repos/{repo_name}") - .configure(issue::configure_repo_level) - .configure(pr::configure) - .configure(wiki::configure), - ), + .configure(notify::configure) + .configure(im::configure) + .configure(internal::configure) + .configure(crate::api::workspace::configure) + .route( + "/repos/invitations/accept", + web::post().to(accept_invitation::accept_invitation), ), ); } diff --git a/api/user/block_user.rs b/api/user/block_user.rs new file mode 100644 index 0000000..d79f3a6 --- /dev/null +++ b/api/user/block_user.rs @@ -0,0 +1,56 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::users::UserBlock; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + /// User ID to block + pub target_user_id: uuid::Uuid, +} + +#[derive(Debug, Deserialize, IntoParams, utoipa::ToSchema)] +pub struct BlockBody { + /// Optional reason for blocking + pub reason: Option, +} + +/// Block a user +/// +/// Blocks the specified user. Once blocked, neither user can see the other's +/// content or interact in shared channels. Requires authentication. +#[utoipa::path( + post, + path = "/api/v1/user/blocks/{target_user_id}", + tag = "User", + operation_id = "userBlockUser", + params(PathParams), + responses( + (status = 201, description = "User blocked successfully.", body = ApiResponse), + (status = 400, description = "Invalid request (e.g., blocking yourself)", body = ApiErrorResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 409, description = "User is already blocked", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn block_user( + service: web::Data, + session: Session, + path: web::Path, + body: Option>, +) -> Result { + let reason = body.and_then(|b| b.reason.clone()); + let block = service + .user + .user_block_create(&session, path.target_user_id, reason) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(block))) +} diff --git a/api/user/create_personal_access_token.rs b/api/user/create_personal_access_token.rs new file mode 100644 index 0000000..a3bfb2a --- /dev/null +++ b/api/user/create_personal_access_token.rs @@ -0,0 +1,60 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::user::security::{ + CreatePersonalAccessTokenParams, CreatePersonalAccessTokenResponse, +}; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams, utoipa::ToSchema)] +pub struct CreateTokenBody { + /// Display name for the token (e.g., "My CLI Token") + pub name: String, + /// List of permission scopes assigned to the token + pub scopes: Vec, + /// Optional expiration date (UTC). If not set, the token never expires. + pub expires_at: Option>, +} + +/// Create a personal access token +/// +/// Creates a new personal access token (PAT) for the authenticated user. +/// The full token value is returned in the response — this is the ONLY time it will be shown. +/// Store it securely; it cannot be retrieved again. +/// Requires authentication. +#[utoipa::path( + post, + path = "/api/v1/user/security/tokens", + tag = "User", + operation_id = "userCreateToken", + params(CreateTokenBody), + responses( + (status = 201, description = "Personal access token created successfully. The raw token value is included in the response and will never be shown again.", body = ApiResponse), + (status = 400, description = "Invalid request body (e.g., missing name or scopes)", body = ApiErrorResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn create_token( + service: web::Data, + session: Session, + body: web::Json, +) -> Result { + let params = CreatePersonalAccessTokenParams { + name: body.name.clone(), + scopes: body.scopes.clone(), + expires_at: body.expires_at, + }; + let token = service + .user + .user_create_personal_access_token(&session, params) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(token))) +} diff --git a/api/user/delete_account.rs b/api/user/delete_account.rs index 78bf495..3833b1b 100644 --- a/api/user/delete_account.rs +++ b/api/user/delete_account.rs @@ -7,18 +7,21 @@ use crate::session::Session; /// Delete user account /// -/// Permanently deletes the authenticated user's account and all associated data. -/// Requires authentication. +/// Marks the authenticated user's account and all associated data for deletion. +/// The user's data is soft-deleted (marked as deleted, not physically removed). +/// A restore link is sent to the user's verified email, valid for 30 days. +/// Requires authentication and a verified email address. /// /// Preconditions: +/// - User must have at least one verified email address /// - User must transfer or delete all owned workspaces /// - User must transfer or delete all owned repositories /// /// Effects: -/// - All user data is removed (SSH keys, GPG keys, sessions, devices, OAuth links, etc.) -/// - User is soft-deleted (marked as deleted, not physically removed) +/// - All user data is soft-deleted (SSH keys, GPG keys, sessions, devices, etc.) /// - Current session is cleared -/// - Account cannot be recovered +/// - A restore token is generated and sent via email +/// - Account can be restored within 30 days using the restore link /// /// Returns success message on completion. #[utoipa::path( @@ -27,8 +30,8 @@ use crate::session::Session; tag = "User", operation_id = "userDeleteAccount", responses( - (status = 200, description = "Account deleted successfully. All user data has been removed.", body = ApiResponse), - (status = 400, description = "Cannot delete: user still owns workspaces or repositories", body = ApiErrorResponse), + (status = 200, description = "Account marked for deletion. A restore link has been sent to your email.", body = ApiResponse), + (status = 400, description = "Cannot delete: user still owns workspaces or repositories, or no verified email", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 404, description = "User not found", body = ApiErrorResponse), (status = 500, description = "Internal server error", body = ApiErrorResponse), @@ -42,5 +45,7 @@ pub async fn delete_account( session: Session, ) -> Result { service.user.user_delete_account(&session).await?; - Ok(HttpResponse::Ok().json(ApiResponse::new("Account deleted successfully".to_string()))) + Ok(HttpResponse::Ok().json(ApiResponse::new( + "Account deletion scheduled. A restore link has been sent to your email.".to_string(), + ))) } diff --git a/api/user/follow_user.rs b/api/user/follow_user.rs new file mode 100644 index 0000000..974cd68 --- /dev/null +++ b/api/user/follow_user.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::users::UserFollow; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + /// User ID to follow + pub target_user_id: uuid::Uuid, +} + +/// Follow a user +/// +/// Starts following the specified user. Requires authentication. +#[utoipa::path( + post, + path = "/api/v1/user/follows/{target_user_id}", + tag = "User", + operation_id = "userFollowUser", + params(PathParams), + responses( + (status = 201, description = "User followed successfully.", body = ApiResponse), + (status = 400, description = "Invalid request (e.g., following yourself)", body = ApiErrorResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 409, description = "Already following this user", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn follow_user( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let follow = service + .user + .user_follow_create(&session, path.target_user_id) + .await?; + Ok(HttpResponse::Created().json(ApiResponse::new(follow))) +} diff --git a/api/user/get_presence.rs b/api/user/get_presence.rs new file mode 100644 index 0000000..d72c69c --- /dev/null +++ b/api/user/get_presence.rs @@ -0,0 +1,35 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::users::UserPresence; +use crate::service::AppService; +use crate::session::Session; + +/// Get user presence +/// +/// Returns the current presence status for the authenticated user, +/// including online/offline status, custom status text, and device information. +/// Requires authentication. +#[utoipa::path( + get, + path = "/api/v1/user/presence", + tag = "User", + operation_id = "userGetPresence", + responses( + (status = 200, description = "Presence retrieved successfully.", body = ApiResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Presence not found for this user", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn get_presence( + service: web::Data, + session: Session, +) -> Result { + let presence = service.user.user_presence_get(&session).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(presence))) +} diff --git a/api/user/list_blocks.rs b/api/user/list_blocks.rs new file mode 100644 index 0000000..354e081 --- /dev/null +++ b/api/user/list_blocks.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::users::UserBlock; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + /// Maximum number of blocks to return (default: 50, max: 100) + pub limit: Option, + /// Number of blocks to skip for pagination (default: 0) + pub offset: Option, +} + +/// List blocked users +/// +/// Returns a paginated list of users blocked by the authenticated user. +/// Requires authentication. +#[utoipa::path( + get, + path = "/api/v1/user/blocks", + tag = "User", + operation_id = "userListBlocks", + params(QueryParams), + responses( + (status = 200, description = "Blocked users listed successfully.", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_blocks( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let blocks = service + .user + .user_blocks_list( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(blocks))) +} diff --git a/api/user/list_devices.rs b/api/user/list_devices.rs index 690f40c..20602d3 100644 --- a/api/user/list_devices.rs +++ b/api/user/list_devices.rs @@ -1,4 +1,6 @@ use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; @@ -6,6 +8,12 @@ use crate::models::users::UserDevice; use crate::service::AppService; use crate::session::Session; +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + /// List user devices /// /// Returns all registered devices for the authenticated user. @@ -17,6 +25,7 @@ use crate::session::Session; path = "/api/v1/user/security/devices", tag = "User", operation_id = "userListDevices", + params(QueryParams), responses( (status = 200, description = "Devices listed successfully. Returns array of device objects with metadata.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), @@ -29,7 +38,15 @@ use crate::session::Session; pub async fn list_devices( service: web::Data, session: Session, + query: web::Query, ) -> Result { - let devices = service.user.user_devices(&session).await?; + let devices = service + .user + .user_devices( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; Ok(HttpResponse::Ok().json(ApiResponse::new(devices))) } diff --git a/api/user/list_follows.rs b/api/user/list_follows.rs new file mode 100644 index 0000000..671d7ff --- /dev/null +++ b/api/user/list_follows.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::users::UserFollow; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + /// Maximum number of follows to return (default: 50, max: 100) + pub limit: Option, + /// Number of follows to skip for pagination (default: 0) + pub offset: Option, +} + +/// List followed users +/// +/// Returns a paginated list of users followed by the authenticated user. +/// Requires authentication. +#[utoipa::path( + get, + path = "/api/v1/user/follows", + tag = "User", + operation_id = "userListFollows", + params(QueryParams), + responses( + (status = 200, description = "Follows listed successfully.", body = ApiResponse>), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn list_follows( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let follows = service + .user + .user_follows_list( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(follows))) +} diff --git a/api/user/list_gpg_keys.rs b/api/user/list_gpg_keys.rs index c84476c..dc6bc3f 100644 --- a/api/user/list_gpg_keys.rs +++ b/api/user/list_gpg_keys.rs @@ -1,4 +1,6 @@ use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; @@ -6,6 +8,12 @@ use crate::models::users::UserGpgKey; use crate::service::AppService; use crate::session::Session; +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + /// List user GPG keys /// /// Returns all GPG public keys registered by the authenticated user. @@ -17,6 +25,7 @@ use crate::session::Session; path = "/api/v1/user/keys/gpg", tag = "User", operation_id = "userListGpgKeys", + params(QueryParams), responses( (status = 200, description = "GPG keys listed successfully. Returns array of GPG key objects with fingerprints and metadata.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), @@ -29,7 +38,15 @@ use crate::session::Session; pub async fn list_gpg_keys( service: web::Data, session: Session, + query: web::Query, ) -> Result { - let keys = service.user.user_gpg_keys(&session).await?; + let keys = service + .user + .user_gpg_keys( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; Ok(HttpResponse::Ok().json(ApiResponse::new(keys))) } diff --git a/api/user/list_oauth_accounts.rs b/api/user/list_oauth_accounts.rs index 48e0a64..f5a5e00 100644 --- a/api/user/list_oauth_accounts.rs +++ b/api/user/list_oauth_accounts.rs @@ -1,4 +1,6 @@ use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; @@ -6,6 +8,12 @@ use crate::service::AppService; use crate::service::user::security::UserOAuthInfo; use crate::session::Session; +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + /// List OAuth accounts /// /// Returns all linked OAuth/third-party login accounts for the authenticated user. @@ -17,6 +25,7 @@ use crate::session::Session; path = "/api/v1/user/security/oauth", tag = "User", operation_id = "userListOAuthAccounts", + params(QueryParams), responses( (status = 200, description = "OAuth accounts listed successfully. Returns array of linked OAuth accounts with provider details.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), @@ -29,7 +38,15 @@ use crate::session::Session; pub async fn list_oauth_accounts( service: web::Data, session: Session, + query: web::Query, ) -> Result { - let accounts = service.user.user_oauth_accounts(&session).await?; + let accounts = service + .user + .user_oauth_accounts( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; Ok(HttpResponse::Ok().json(ApiResponse::new(accounts))) } diff --git a/api/user/list_ssh_keys.rs b/api/user/list_ssh_keys.rs index 23c1bea..b163cdc 100644 --- a/api/user/list_ssh_keys.rs +++ b/api/user/list_ssh_keys.rs @@ -1,4 +1,6 @@ use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; @@ -6,6 +8,12 @@ use crate::models::users::UserSshKey; use crate::service::AppService; use crate::session::Session; +#[derive(Debug, Deserialize, IntoParams)] +pub struct QueryParams { + pub limit: Option, + pub offset: Option, +} + /// List user SSH keys /// /// Returns all SSH public keys registered by the authenticated user. @@ -17,6 +25,7 @@ use crate::session::Session; path = "/api/v1/user/keys/ssh", tag = "User", operation_id = "userListSshKeys", + params(QueryParams), responses( (status = 200, description = "SSH keys listed successfully. Returns array of SSH key objects with fingerprints and metadata.", body = ApiResponse>), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), @@ -29,7 +38,15 @@ use crate::session::Session; pub async fn list_ssh_keys( service: web::Data, session: Session, + query: web::Query, ) -> Result { - let keys = service.user.user_ssh_keys(&session).await?; + let keys = service + .user + .user_ssh_keys( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; Ok(HttpResponse::Ok().json(ApiResponse::new(keys))) } diff --git a/api/user/mod.rs b/api/user/mod.rs index ce8e957..155a66a 100644 --- a/api/user/mod.rs +++ b/api/user/mod.rs @@ -1,26 +1,36 @@ pub mod add_gpg_key; pub mod add_ssh_key; +pub mod block_user; +pub mod create_personal_access_token; pub mod delete_account; pub mod delete_device; pub mod delete_gpg_key; pub mod delete_ssh_key; +pub mod follow_user; pub mod get_account; pub mod get_appearance; pub mod get_notifications; +pub mod get_presence; pub mod get_profile; +pub mod list_blocks; pub mod list_devices; +pub mod list_follows; pub mod list_gpg_keys; pub mod list_oauth_accounts; pub mod list_personal_access_tokens; pub mod list_security_logs; pub mod list_sessions; pub mod list_ssh_keys; +pub mod restore_account; pub mod revoke_personal_access_token; pub mod revoke_session; +pub mod unblock_user; +pub mod unfollow_user; pub mod unlink_oauth; pub mod update_account; pub mod update_appearance; pub mod update_notifications; +pub mod update_presence; pub mod update_profile; pub mod upload_avatar; @@ -37,6 +47,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { web::post().to(upload_avatar::upload_avatar), ) .route("/account", web::delete().to(delete_account::delete_account)) + .route( + "/account/restore", + web::post().to(restore_account::restore_account), + ) // Appearance .route("/appearance", web::get().to(get_appearance::get_appearance)) .route( @@ -106,9 +120,36 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/security/tokens", web::get().to(list_personal_access_tokens::list_tokens), ) + .route( + "/security/tokens", + web::post().to(create_personal_access_token::create_token), + ) .route( "/security/tokens/{token_id}", web::delete().to(revoke_personal_access_token::revoke_token), + ) + // Presence + .route("/presence", web::get().to(get_presence::get_presence)) + .route("/presence", web::put().to(update_presence::update_presence)) + // Blocks + .route("/blocks", web::get().to(list_blocks::list_blocks)) + .route( + "/blocks/{target_user_id}", + web::post().to(block_user::block_user), + ) + .route( + "/blocks/{target_user_id}", + web::delete().to(unblock_user::unblock_user), + ) + // Follows + .route("/follows", web::get().to(list_follows::list_follows)) + .route( + "/follows/{target_user_id}", + web::post().to(follow_user::follow_user), + ) + .route( + "/follows/{target_user_id}", + web::delete().to(unfollow_user::unfollow_user), ), ); } diff --git a/api/user/restore_account.rs b/api/user/restore_account.rs new file mode 100644 index 0000000..b8fcb95 --- /dev/null +++ b/api/user/restore_account.rs @@ -0,0 +1,43 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RestoreAccountParams { + pub token: String, +} + +/// Restore a deleted user account +/// +/// Restores a user account that was marked for deletion. +/// The restore token is sent to the user's verified email when the account is deleted. +/// Tokens are valid for 30 days. After expiry, the data is preserved but the user +/// must contact support to restore. +/// +/// This endpoint does not require authentication (the restore token serves as proof of identity). +#[utoipa::path( + post, + path = "/api/v1/user/account/restore", + tag = "User", + operation_id = "userRestoreAccount", + request_body( + content = RestoreAccountParams, + description = "Restore token received via email." + ), + responses( + (status = 200, description = "Account restored successfully.", body = ApiResponse), + (status = 404, description = "Invalid or expired restore link.", body = ApiErrorResponse), + ) +)] +pub async fn restore_account( + service: web::Data, + body: web::Json, +) -> Result { + service.user.user_restore(&body.token).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new( + "account restored successfully".to_string(), + ))) +} diff --git a/api/user/unblock_user.rs b/api/user/unblock_user.rs new file mode 100644 index 0000000..aad782f --- /dev/null +++ b/api/user/unblock_user.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + /// User ID to unblock + pub target_user_id: uuid::Uuid, +} + +/// Unblock a user +/// +/// Removes a previously applied block on the specified user. Requires authentication. +#[utoipa::path( + delete, + path = "/api/v1/user/blocks/{target_user_id}", + tag = "User", + operation_id = "userUnblockUser", + params(PathParams), + responses( + (status = 200, description = "User unblocked successfully.", body = ApiEmptyResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Block not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn unblock_user( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .user + .user_block_delete(&session, path.target_user_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("User unblocked successfully"))) +} diff --git a/api/user/unfollow_user.rs b/api/user/unfollow_user.rs new file mode 100644 index 0000000..e271e18 --- /dev/null +++ b/api/user/unfollow_user.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + /// User ID to unfollow + pub target_user_id: uuid::Uuid, +} + +/// Unfollow a user +/// +/// Stops following the specified user. Requires authentication. +#[utoipa::path( + delete, + path = "/api/v1/user/follows/{target_user_id}", + tag = "User", + operation_id = "userUnfollowUser", + params(PathParams), + responses( + (status = 200, description = "User unfollowed successfully.", body = ApiEmptyResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 404, description = "Follow not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn unfollow_user( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + service + .user + .user_follow_delete(&session, path.target_user_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("User unfollowed successfully"))) +} diff --git a/api/user/update_presence.rs b/api/user/update_presence.rs new file mode 100644 index 0000000..90ef001 --- /dev/null +++ b/api/user/update_presence.rs @@ -0,0 +1,63 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::common::{DeviceType, PresenceStatus}; +use crate::models::users::UserPresence; +use crate::service::AppService; +use crate::service::user::social::UpdatePresenceParams; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams, utoipa::ToSchema)] +pub struct UpdatePresenceBody { + /// New presence status + pub status: PresenceStatus, + /// Optional custom status text (e.g., "In a meeting") + pub custom_status_text: Option, + /// Optional custom status emoji (e.g., ":palm_tree:") + pub custom_status_emoji: Option, + /// Device type the user is currently using + pub device_type: Option, + /// IP address of the current session + pub ip_address: Option, +} + +/// Update user presence +/// +/// Updates the presence status for the authenticated user. +/// Supports custom status text, emoji, device type, and IP address. +/// Creates a new presence record if one does not exist. +/// Requires authentication. +#[utoipa::path( + put, + path = "/api/v1/user/presence", + tag = "User", + operation_id = "userUpdatePresence", + params(UpdatePresenceBody), + responses( + (status = 200, description = "Presence updated successfully.", body = ApiResponse), + (status = 400, description = "Invalid request body", body = ApiErrorResponse), + (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security( + ("session_cookie" = []) + ) +)] +pub async fn update_presence( + service: web::Data, + session: Session, + body: web::Json, +) -> Result { + let params = UpdatePresenceParams { + status: body.status, + custom_status_text: body.custom_status_text.clone(), + custom_status_emoji: body.custom_status_emoji.clone(), + device_type: body.device_type, + ip_address: body.ip_address.clone(), + }; + let presence = service.user.user_presence_update(&session, params).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(presence))) +} diff --git a/api/user/upload_avatar.rs b/api/user/upload_avatar.rs index 93ea950..ef3b7bb 100644 --- a/api/user/upload_avatar.rs +++ b/api/user/upload_avatar.rs @@ -1,20 +1,46 @@ +use actix_multipart::Multipart; use actix_web::{HttpResponse, web}; +use futures_util::StreamExt; +use serde::Serialize; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; use crate::service::AppService; -use crate::service::user::account::{UploadUserAvatarParams, UserAvatarResponse}; use crate::session::Session; +#[derive(Serialize, utoipa::ToSchema)] +pub struct AvatarData { + pub avatar_url: String, + pub storage_key: String, +} + +pub async fn parse_avatar_field( + mut payload: Multipart, +) -> Result<(Vec, Option, Option), AppError> { + while let Some(Ok(mut field)) = payload.next().await { + if field.name() == Some("avatar") { + let content_type = field.content_type().map(|m| m.to_string()); + let file_name = field + .content_disposition() + .and_then(|cd| cd.get_filename().map(|s| s.to_string())); + let mut data: Vec = Vec::new(); + while let Some(Ok(chunk)) = field.next().await { + data.extend_from_slice(&chunk); + } + return Ok((data, content_type, file_name)); + } + } + Err(AppError::BadRequest( + "missing 'avatar' field in multipart form".into(), + )) +} + /// Upload user avatar /// /// Uploads a new avatar image for the authenticated user. -/// Requires authentication. +/// Requires authentication. Accepts multipart/form-data with a single "avatar" field. /// -/// Parameters: -/// - data: Raw avatar image bytes (PNG, JPEG, or WebP, max 5MB) -/// - content_type: MIME type of the image (e.g., "image/png") -/// - file_name: Original file name (used to infer file extension) +/// Supported formats: PNG, JPEG, WebP, GIF (max 5MB). /// /// Effects: /// - Avatar image is stored in S3-compatible object storage @@ -28,12 +54,11 @@ use crate::session::Session; tag = "User", operation_id = "userUploadAvatar", request_body( - content = UploadUserAvatarParams, - description = "Avatar upload parameters", - content_type = "application/json" + content_type = "multipart/form-data", + description = "Avatar image file in a multipart form field named 'avatar'." ), responses( - (status = 200, description = "Avatar uploaded successfully. Returns the new avatar URL and storage key.", body = ApiResponse), + (status = 200, description = "Avatar uploaded successfully", body = ApiResponse), (status = 400, description = "Invalid parameters: unsupported file type or image too large", body = ApiErrorResponse), (status = 401, description = "Authentication required or session expired", body = ApiErrorResponse), (status = 404, description = "User not found", body = ApiErrorResponse), @@ -46,11 +71,15 @@ use crate::session::Session; pub async fn upload_avatar( service: web::Data, session: Session, - params: web::Json, + payload: Multipart, ) -> Result { - let response = service + let (data, content_type, file_name) = parse_avatar_field(payload).await?; + let (avatar_url, storage_key) = service .user - .user_upload_avatar(&session, params.into_inner()) + .user_upload_avatar(&session, data, content_type, file_name) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(response))) + Ok(HttpResponse::Ok().json(ApiResponse::new(AvatarData { + avatar_url, + storage_key, + }))) } diff --git a/api/wiki/mod.rs b/api/wiki/mod.rs index 70bce79..29ae5d4 100644 --- a/api/wiki/mod.rs +++ b/api/wiki/mod.rs @@ -13,7 +13,7 @@ use actix_web::web; /// Configure wiki routes under `/workspaces/{workspace_name}/repos/{repo_name}/wiki` pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( - web::scope("/wiki") + web::scope("") // Pages .route("", web::get().to(list_pages::list_pages)) .route("", web::post().to(create_page::create_page)) diff --git a/api/workspace/billing_history.rs b/api/workspace/billing_history.rs new file mode 100644 index 0000000..21a82aa --- /dev/null +++ b/api/workspace/billing_history.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiListResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct BillingHistoryQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/billing/history", + tag = "Workspaces", + operation_id = "workspaceBillingHistory", + summary = "List billing history", + description = "Return billing history for a workspace. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + BillingHistoryQuery + ), + responses( + (status = 200, description = "List of billing history entries (currently empty).", body = ApiListResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Internal server error.", body = ApiErrorResponse) + ) +)] +pub async fn billing_history( + _service: web::Data, + _session: Session, + _path: web::Path, + _query: web::Query, +) -> Result { + Ok(HttpResponse::Ok().json(ApiListResponse::::empty())) +} diff --git a/api/workspace/create.rs b/api/workspace/create.rs index fd4543a..fbf8215 100644 --- a/api/workspace/create.rs +++ b/api/workspace/create.rs @@ -2,7 +2,8 @@ use actix_web::{HttpResponse, web}; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::workspaces::Workspace; +use crate::models::base_info::resolve_users; +use crate::models::workspaces::WorkspaceDetail; use crate::service::AppService; use crate::service::workspace::core::CreateWorkspaceParams; use crate::session::Session; @@ -20,7 +21,7 @@ use crate::session::Session; content_type = "application/json" ), responses( - (status = 200, description = "Workspace created.", body = ApiResponse), + (status = 200, description = "Workspace created.", body = ApiResponse), (status = 400, description = "Name is required or visibility is invalid.", body = ApiErrorResponse), (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) @@ -35,5 +36,11 @@ pub async fn handle( .workspace .workspace_create(&session, params.into_inner()) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + + let db = &service.ctx.db; + let users = resolve_users(db, &[data.owner_id]).await?; + let owner = users.get(&data.owner_id).cloned().unwrap_or_default(); + let detail = data.into_detail(owner); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/workspace/get.rs b/api/workspace/get.rs index a7e65eb..147e46e 100644 --- a/api/workspace/get.rs +++ b/api/workspace/get.rs @@ -2,7 +2,8 @@ use actix_web::{HttpResponse, web}; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::workspaces::Workspace; +use crate::models::base_info::resolve_users; +use crate::models::workspaces::WorkspaceDetail; use crate::service::AppService; use crate::session::Session; @@ -17,7 +18,7 @@ use crate::session::Session; ("workspace_name" = String, Path, description = "Workspace name.") ), responses( - (status = 200, description = "Workspace data.", body = ApiResponse), + (status = 200, description = "Workspace data.", body = ApiResponse), (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), (status = 404, description = "Workspace not found or not accessible.", body = ApiErrorResponse), (status = 500, description = "Database read failed.", body = ApiErrorResponse) @@ -30,5 +31,11 @@ pub async fn handle( ) -> Result { let ws = service.workspace.find_workspace_by_name(&path).await?; let data = service.workspace.workspace_get(&session, &ws).await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + + let db = &service.ctx.db; + let users = resolve_users(db, &[data.owner_id]).await?; + let owner = users.get(&data.owner_id).cloned().unwrap_or_default(); + let detail = data.into_detail(owner); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/workspace/get_approval.rs b/api/workspace/get_approval.rs new file mode 100644 index 0000000..d4bb7ea --- /dev/null +++ b/api/workspace/get_approval.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspacePendingApproval; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub approval_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/approvals/{approval_id}", + tag = "Workspaces", + operation_id = "workspaceGetApproval", + params(PathParams), + responses( + (status = 200, description = "Approval retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Approval not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let approvals = service + .workspace + .workspace_pending_approvals(&session, &ws, 1000, 0) + .await?; + + let approval = approvals + .into_iter() + .find(|a| a.id == path.approval_id) + .ok_or(AppError::NotFound("approval not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(approval))) +} diff --git a/api/workspace/get_domain.rs b/api/workspace/get_domain.rs new file mode 100644 index 0000000..d080704 --- /dev/null +++ b/api/workspace/get_domain.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceDomain; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub domain_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/domains/{domain_id}", + tag = "Workspaces", + operation_id = "workspaceGetDomain", + params(PathParams), + responses( + (status = 200, description = "Domain retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Domain not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let domains = service + .workspace + .workspace_domains(&session, &ws, 1000, 0) + .await?; + + let domain = domains + .into_iter() + .find(|d| d.id == path.domain_id) + .ok_or(AppError::NotFound("domain not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(domain))) +} diff --git a/api/workspace/get_integration.rs b/api/workspace/get_integration.rs new file mode 100644 index 0000000..5a184fb --- /dev/null +++ b/api/workspace/get_integration.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceIntegration; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub integration_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/integrations/{integration_id}", + tag = "Workspaces", + operation_id = "workspaceGetIntegration", + params(PathParams), + responses( + (status = 200, description = "Integration retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Integration not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let integrations = service + .workspace + .workspace_integrations(&session, &ws, 1000, 0) + .await?; + + let integration = integrations + .into_iter() + .find(|i| i.id == path.integration_id) + .ok_or(AppError::NotFound("integration not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(integration))) +} diff --git a/api/workspace/get_invitation.rs b/api/workspace/get_invitation.rs new file mode 100644 index 0000000..e06d368 --- /dev/null +++ b/api/workspace/get_invitation.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceInvitation; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub invitation_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/invitations/{invitation_id}", + tag = "Workspaces", + operation_id = "workspaceGetInvitation", + params(PathParams), + responses( + (status = 200, description = "Invitation retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Invitation not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let invitations = service + .workspace + .workspace_invitations(&session, &ws, 1000, 0) + .await?; + + let invitation = invitations + .into_iter() + .find(|i| i.id == path.invitation_id) + .ok_or(AppError::NotFound("invitation not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(invitation))) +} diff --git a/api/workspace/get_member.rs b/api/workspace/get_member.rs new file mode 100644 index 0000000..71258d0 --- /dev/null +++ b/api/workspace/get_member.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceMember; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub member_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/members/{member_id}", + tag = "Workspaces", + operation_id = "workspaceGetMember", + params(PathParams), + responses( + (status = 200, description = "Member retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Member not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let members = service + .workspace + .workspace_members(&session, &ws, 1000, 0) + .await?; + + let member = members + .into_iter() + .find(|m| m.id == path.member_id) + .ok_or(AppError::NotFound("member not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(member))) +} diff --git a/api/workspace/get_webhook.rs b/api/workspace/get_webhook.rs new file mode 100644 index 0000000..bc86292 --- /dev/null +++ b/api/workspace/get_webhook.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceWebhook; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Debug, Deserialize, IntoParams)] +pub struct PathParams { + pub workspace_name: String, + pub webhook_id: uuid::Uuid, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}", + tag = "Workspaces", + operation_id = "workspaceGetWebhook", + params(PathParams), + responses( + (status = 200, description = "Webhook retrieved successfully", body = ApiResponse), + (status = 401, description = "Authentication required", body = ApiErrorResponse), + (status = 404, description = "Webhook not found", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ), + security(("session_cookie" = [])) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service + .workspace + .find_workspace_by_name(&path.workspace_name) + .await?; + let webhooks = service + .workspace + .workspace_webhooks(&session, &ws, 1000, 0) + .await?; + + let webhook = webhooks + .into_iter() + .find(|w| w.id == path.webhook_id) + .ok_or(AppError::NotFound("webhook not found".into()))?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(webhook))) +} diff --git a/api/workspace/list.rs b/api/workspace/list.rs index 9a524da..093d5d9 100644 --- a/api/workspace/list.rs +++ b/api/workspace/list.rs @@ -1,9 +1,11 @@ use actix_web::{HttpResponse, web}; use serde::Deserialize; +use uuid::Uuid; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::workspaces::Workspace; +use crate::models::base_info::resolve_users; +use crate::models::workspaces::WorkspaceDetail; use crate::service::AppService; use crate::session::Session; @@ -22,7 +24,7 @@ pub struct ListQuery { description = "Return workspaces owned by, joined by, or publicly accessible to the current user.", params(ListQuery), responses( - (status = 200, description = "List of workspaces.", body = ApiResponse>), + (status = 200, description = "List of workspaces.", body = ApiResponse>), (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), (status = 500, description = "Database read failed.", body = ApiErrorResponse) ) @@ -40,5 +42,17 @@ pub async fn handle( query.offset.unwrap_or(0), ) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + + let db = &service.ctx.db; + let owner_ids: Vec = data.iter().map(|w| w.owner_id).collect(); + let users = resolve_users(db, &owner_ids).await?; + let details: Vec = data + .into_iter() + .map(|w| { + let owner = users.get(&w.owner_id).cloned().unwrap_or_default(); + w.into_detail(owner) + }) + .collect(); + + Ok(HttpResponse::Ok().json(ApiResponse::new(details))) } diff --git a/api/workspace/list_webhook_deliveries.rs b/api/workspace/list_webhook_deliveries.rs new file mode 100644 index 0000000..22de2f4 --- /dev/null +++ b/api/workspace/list_webhook_deliveries.rs @@ -0,0 +1,41 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiListResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct DeliveriesQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}/deliveries", + tag = "Workspaces", + operation_id = "workspaceListWebhookDeliveries", + summary = "List webhook deliveries", + description = "Return delivery logs for a webhook. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("webhook_id" = Uuid, Path, description = "Webhook ID."), + DeliveriesQuery + ), + responses( + (status = 200, description = "List of deliveries (currently empty).", body = ApiListResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Internal server error.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + _service: web::Data, + _session: Session, + _path: web::Path<(String, Uuid)>, + _query: web::Query, +) -> Result { + Ok(HttpResponse::Ok().json(ApiListResponse::::empty())) +} diff --git a/api/workspace/mod.rs b/api/workspace/mod.rs index e97745b..ec1bf07 100644 --- a/api/workspace/mod.rs +++ b/api/workspace/mod.rs @@ -3,6 +3,7 @@ pub mod add_domain; pub mod add_member; pub mod archive; pub mod audit_logs; +pub mod billing_history; pub mod create; pub mod create_integration; pub mod create_invitation; @@ -12,10 +13,16 @@ pub mod delete_domain; pub mod delete_integration; pub mod delete_webhook; pub mod get; +pub mod get_approval; pub mod get_billing; pub mod get_branding; +pub mod get_domain; +pub mod get_integration; +pub mod get_invitation; +pub mod get_member; pub mod get_settings; pub mod get_stats; +pub mod get_webhook; pub mod leave; pub mod list; pub mod list_approvals; @@ -23,10 +30,13 @@ pub mod list_domains; pub mod list_integrations; pub mod list_invitations; pub mod list_members; +pub mod list_webhook_deliveries; pub mod list_webhooks; pub mod refresh_stats; pub mod remove_member; pub mod request_approval; +pub mod restore; +pub mod retry_webhook_delivery; pub mod review_approval; pub mod revoke_invitation; pub mod set_primary_domain; @@ -35,6 +45,7 @@ pub mod unarchive; pub mod update; pub mod update_billing; pub mod update_branding; +pub mod update_domain; pub mod update_integration; pub mod update_member_role; pub mod update_settings; @@ -47,7 +58,6 @@ use actix_web::web; pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/workspaces") - // Core .route("", web::get().to(list::handle)) .route("", web::post().to(create::handle)) .route( @@ -57,6 +67,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route("/{workspace_name}", web::get().to(get::handle)) .route("/{workspace_name}", web::put().to(update::handle)) .route("/{workspace_name}", web::delete().to(delete::handle)) + .route( + "/{workspace_name}/restore", + web::post().to(restore::restore_workspace), + ) .route("/{workspace_name}/archive", web::post().to(archive::handle)) .route( "/{workspace_name}/unarchive", @@ -83,6 +97,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/members/{member_id}/role", web::put().to(update_member_role::handle), ) + .route( + "/{workspace_name}/members/{member_id}", + web::get().to(get_member::handle), + ) .route( "/{workspace_name}/members/{member_id}", web::delete().to(remove_member::handle), @@ -97,6 +115,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/invitations", web::post().to(create_invitation::handle), ) + .route( + "/{workspace_name}/invitations/{invitation_id}", + web::get().to(get_invitation::handle), + ) .route( "/{workspace_name}/invitations/{invitation_id}", web::delete().to(revoke_invitation::handle), @@ -110,6 +132,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/billing", web::put().to(update_billing::handle), ) + .route( + "/{workspace_name}/billing/history", + web::get().to(billing_history::billing_history), + ) // Branding .route( "/{workspace_name}/branding", @@ -143,6 +169,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/integrations", web::post().to(create_integration::handle), ) + .route( + "/{workspace_name}/integrations/{integration_id}", + web::get().to(get_integration::handle), + ) .route( "/{workspace_name}/integrations/{integration_id}", web::put().to(update_integration::handle), @@ -160,6 +190,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/webhooks", web::post().to(create_webhook::handle), ) + .route( + "/{workspace_name}/webhooks/{webhook_id}", + web::get().to(get_webhook::handle), + ) .route( "/{workspace_name}/webhooks/{webhook_id}", web::put().to(update_webhook::handle), @@ -168,6 +202,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/webhooks/{webhook_id}", web::delete().to(delete_webhook::handle), ) + .route( + "/{workspace_name}/webhooks/{webhook_id}/deliveries", + web::get().to(list_webhook_deliveries::handle), + ) + .route( + "/{workspace_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry", + web::post().to(retry_webhook_delivery::handle), + ) // Domains .route( "/{workspace_name}/domains", @@ -185,6 +227,14 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/domains/{domain_id}/primary", web::put().to(set_primary_domain::handle), ) + .route( + "/{workspace_name}/domains/{domain_id}", + web::get().to(get_domain::handle), + ) + .route( + "/{workspace_name}/domains/{domain_id}", + web::put().to(update_domain::update_domain), + ) .route( "/{workspace_name}/domains/{domain_id}", web::delete().to(delete_domain::handle), @@ -198,6 +248,10 @@ pub fn configure(cfg: &mut web::ServiceConfig) { "/{workspace_name}/approvals", web::post().to(request_approval::handle), ) + .route( + "/{workspace_name}/approvals/{approval_id}", + web::get().to(get_approval::handle), + ) .route( "/{workspace_name}/approvals/{approval_id}", web::put().to(review_approval::handle), @@ -206,6 +260,18 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route( "/{workspace_name}/audit-logs", web::get().to(audit_logs::handle), + ) + // Issues + .service(web::scope("/{workspace_name}/issues").configure(crate::api::issue::configure)) + // Repos + .service(web::scope("/{workspace_name}/repos").configure(crate::api::repo::configure)) + // Repo-level: PRs, Wiki, Issue labels/milestones/templates, Git + .service( + web::scope("/{workspace_name}/repos/{repo_name}") + .configure(crate::api::issue::configure_repo_level) + .configure(crate::api::pr::configure) + .configure(crate::api::wiki::configure) + .configure(crate::api::repo::git::configure), ), ); } diff --git a/api/workspace/restore.rs b/api/workspace/restore.rs new file mode 100644 index 0000000..c35930e --- /dev/null +++ b/api/workspace/restore.rs @@ -0,0 +1,36 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/restore", + tag = "Workspaces", + operation_id = "workspaceRestore", + summary = "Restore a soft-deleted workspace", + description = "Restore a workspace that was previously soft-deleted. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace restored.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found or not deleted.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn restore_workspace( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws_name = path.into_inner(); + service + .workspace + .workspace_restore(&session, &ws_name) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("workspace restored"))) +} diff --git a/api/workspace/retry_webhook_delivery.rs b/api/workspace/retry_webhook_delivery.rs new file mode 100644 index 0000000..366dbd0 --- /dev/null +++ b/api/workspace/retry_webhook_delivery.rs @@ -0,0 +1,34 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry", + tag = "Workspaces", + operation_id = "workspaceRetryWebhookDelivery", + summary = "Retry a webhook delivery", + description = "Retry a failed webhook delivery. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("webhook_id" = Uuid, Path, description = "Webhook ID."), + ("delivery_id" = Uuid, Path, description = "Delivery ID.") + ), + responses( + (status = 202, description = "Retry scheduled.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Delivery not found.", body = ApiErrorResponse), + (status = 500, description = "Internal server error.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + _service: web::Data, + _session: Session, + _path: web::Path<(String, Uuid, Uuid)>, +) -> Result { + Ok(HttpResponse::Accepted().json(ApiEmptyResponse::ok("retry scheduled"))) +} diff --git a/api/workspace/transfer_owner.rs b/api/workspace/transfer_owner.rs index c2418b5..b8c5760 100644 --- a/api/workspace/transfer_owner.rs +++ b/api/workspace/transfer_owner.rs @@ -4,7 +4,8 @@ use uuid::Uuid; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::workspaces::Workspace; +use crate::models::base_info::resolve_users; +use crate::models::workspaces::WorkspaceDetail; use crate::service::AppService; use crate::session::Session; @@ -30,7 +31,7 @@ pub struct TransferOwnerRequest { content_type = "application/json" ), responses( - (status = 200, description = "Ownership transferred.", body = ApiResponse), + (status = 200, description = "Ownership transferred.", body = ApiResponse), (status = 400, description = "New owner must be an active member and different from current owner.", body = ApiErrorResponse), (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) @@ -47,5 +48,11 @@ pub async fn handle( .workspace .workspace_transfer_owner(&session, &ws, params.new_owner_id) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + + let db = &service.ctx.db; + let users = resolve_users(db, &[data.owner_id]).await?; + let owner = users.get(&data.owner_id).cloned().unwrap_or_default(); + let detail = data.into_detail(owner); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/workspace/update.rs b/api/workspace/update.rs index 0d51dbb..67a6759 100644 --- a/api/workspace/update.rs +++ b/api/workspace/update.rs @@ -2,7 +2,8 @@ use actix_web::{HttpResponse, web}; use crate::api::response::{ApiErrorResponse, ApiResponse}; use crate::error::AppError; -use crate::models::workspaces::Workspace; +use crate::models::base_info::resolve_users; +use crate::models::workspaces::WorkspaceDetail; use crate::service::AppService; use crate::service::workspace::core::UpdateWorkspaceParams; use crate::session::Session; @@ -23,7 +24,7 @@ use crate::session::Session; content_type = "application/json" ), responses( - (status = 200, description = "Workspace updated.", body = ApiResponse), + (status = 200, description = "Workspace updated.", body = ApiResponse), (status = 400, description = "Bad request — invalid visibility or default_role.", body = ApiErrorResponse), (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), (status = 404, description = "Workspace not found.", body = ApiErrorResponse), @@ -41,5 +42,11 @@ pub async fn handle( .workspace .workspace_update(&session, &ws, params.into_inner()) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + + let db = &service.ctx.db; + let users = resolve_users(db, &[data.owner_id]).await?; + let owner = users.get(&data.owner_id).cloned().unwrap_or_default(); + let detail = data.into_detail(owner); + + Ok(HttpResponse::Ok().json(ApiResponse::new(detail))) } diff --git a/api/workspace/update_domain.rs b/api/workspace/update_domain.rs new file mode 100644 index 0000000..9519495 --- /dev/null +++ b/api/workspace/update_domain.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceDomain; +use crate::service::AppService; +use crate::service::workspace::domains::UpdateDomainParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/domains/{domain_id}", + tag = "Workspaces", + operation_id = "workspaceUpdateDomain", + summary = "Update a domain", + description = "Update a domain name. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("domain_id" = Uuid, Path, description = "Domain record ID.") + ), + request_body( + content = UpdateDomainParams, + description = "Updated domain name.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Domain updated.", body = ApiResponse), + (status = 400, description = "Domain is empty.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Domain not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn update_domain( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, + params: web::Json, +) -> Result { + let (ws_name, domain_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + let data = service + .workspace + .workspace_update_domain(&session, &ws, domain_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/upload_avatar.rs b/api/workspace/upload_avatar.rs index e505b1c..e8ccbef 100644 --- a/api/workspace/upload_avatar.rs +++ b/api/workspace/upload_avatar.rs @@ -1,34 +1,26 @@ +use actix_multipart::Multipart; use actix_web::{HttpResponse, web}; -use serde::Deserialize; use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::api::user::upload_avatar::parse_avatar_field; use crate::error::AppError; use crate::models::workspaces::Workspace; use crate::service::AppService; use crate::session::Session; -#[derive(Deserialize, utoipa::IntoParams)] -pub struct UploadAvatarQuery { - pub content_type: Option, - pub file_name: Option, -} - #[utoipa::path( post, path = "/api/v1/workspaces/{workspace_name}/avatar", tag = "Workspaces", operation_id = "workspaceUploadAvatar", summary = "Upload workspace avatar", - description = "Upload an avatar image for a workspace. Requires admin role. Maximum size 5 MB. Supported: png, jpg, gif, webp.", + description = "Upload an avatar image for a workspace. Requires admin role. Maximum size 5 MB. Supported: png, jpg, gif, webp. Accepts multipart/form-data with a single 'avatar' field.", params( ("workspace_name" = String, Path, description = "Workspace name."), - ("content_type" = Option, Query, description = "MIME type of the uploaded image."), - ("file_name" = Option, Query, description = "Original file name for extension detection.") ), request_body( - content = Vec, - description = "Raw image bytes.", - content_type = "application/octet-stream" + content_type = "multipart/form-data", + description = "Avatar image file in a multipart form field named 'avatar'." ), responses( (status = 200, description = "Avatar uploaded.", body = ApiResponse), @@ -42,19 +34,13 @@ pub async fn handle( service: web::Data, session: Session, path: web::Path, - query: web::Query, - body: web::Bytes, + payload: Multipart, ) -> Result { let ws = service.workspace.find_workspace_by_name(&path).await?; - let data = service + let (data, content_type, file_name) = parse_avatar_field(payload).await?; + let ws = service .workspace - .workspace_upload_avatar( - &session, - &ws, - body.to_vec(), - query.content_type.clone(), - query.file_name.clone(), - ) + .workspace_upload_avatar(&session, &ws, data, content_type, file_name) .await?; - Ok(HttpResponse::Ok().json(ApiResponse::new(data))) + Ok(HttpResponse::Ok().json(ApiResponse::new(ws))) }