feat(api): expand API endpoints for repo, PR, user, workspace management

- Add git operation endpoints: archive, compare branches, diff, tree,
  repository extras
- Add repo endpoints: contributors, delete fork, get branch/commit
  status/deploy key/invitation/member/release/tag/webhook, topics,
  release assets, webhook deliveries/retry
- Add PR endpoints: review requests, templates
- Add user endpoints: block/unblock, follow/unfollow, presence,
  personal access tokens, account restore
- Add workspace endpoints: billing history, approvals, domains,
  integrations, invitations, members, webhooks, restore
- Add internal API, notification API, IM API modules
- Update route configuration and OpenAPI spec
This commit is contained in:
zhenyi
2026-06-10 18:49:27 +08:00
parent 4586b79cb8
commit cec6dce955
161 changed files with 7522 additions and 349 deletions
+56
View File
@@ -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<String>,
}
/// 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<UserBlock>),
(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<AppService>,
session: Session,
path: web::Path<PathParams>,
body: Option<web::Json<BlockBody>>,
) -> Result<HttpResponse, AppError> {
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)))
}
+60
View File
@@ -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<crate::models::common::Scope>,
/// Optional expiration date (UTC). If not set, the token never expires.
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 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<CreatePersonalAccessTokenResponse>),
(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<AppService>,
session: Session,
body: web::Json<CreateTokenBody>,
) -> Result<HttpResponse, AppError> {
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)))
}
+13 -8
View File
@@ -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<String>),
(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<String>),
(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<HttpResponse, AppError> {
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(),
)))
}
+47
View File
@@ -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<UserFollow>),
(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<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let follow = service
.user
.user_follow_create(&session, path.target_user_id)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(follow)))
}
+35
View File
@@ -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<UserPresence>),
(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<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let presence = service.user.user_presence_get(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(presence)))
}
+52
View File
@@ -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<i64>,
/// Number of blocks to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// 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<Vec<UserBlock>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+18 -1
View File
@@ -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<i64>,
pub offset: Option<i64>,
}
/// 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<Vec<UserDevice>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+52
View File
@@ -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<i64>,
/// Number of follows to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// 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<Vec<UserFollow>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+18 -1
View File
@@ -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<i64>,
pub offset: Option<i64>,
}
/// 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<Vec<UserGpgKey>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+18 -1
View File
@@ -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<i64>,
pub offset: Option<i64>,
}
/// 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<Vec<UserOAuthInfo>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+18 -1
View File
@@ -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<i64>,
pub offset: Option<i64>,
}
/// 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<Vec<UserSshKey>>),
(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<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
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)))
}
+41
View File
@@ -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),
),
);
}
+43
View File
@@ -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<String>),
(status = 404, description = "Invalid or expired restore link.", body = ApiErrorResponse),
)
)]
pub async fn restore_account(
service: web::Data<AppService>,
body: web::Json<RestoreAccountParams>,
) -> Result<HttpResponse, AppError> {
service.user.user_restore(&body.token).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(
"account restored successfully".to_string(),
)))
}
+45
View File
@@ -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<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_block_delete(&session, path.target_user_id)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("User unblocked successfully")))
}
+45
View File
@@ -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<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_follow_delete(&session, path.target_user_id)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("User unfollowed successfully")))
}
+63
View File
@@ -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<String>,
/// Optional custom status emoji (e.g., ":palm_tree:")
pub custom_status_emoji: Option<String>,
/// Device type the user is currently using
pub device_type: Option<DeviceType>,
/// IP address of the current session
pub ip_address: Option<String>,
}
/// 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<UserPresence>),
(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<AppService>,
session: Session,
body: web::Json<UpdatePresenceBody>,
) -> Result<HttpResponse, AppError> {
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)))
}
+43 -14
View File
@@ -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<u8>, Option<String>, Option<String>), 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<u8> = 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<UserAvatarResponse>),
(status = 200, description = "Avatar uploaded successfully", body = ApiResponse<AvatarData>),
(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<AppService>,
session: Session,
params: web::Json<UploadUserAvatarParams>,
payload: Multipart,
) -> Result<HttpResponse, AppError> {
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,
})))
}