Skip to content

Commit 3f8b738

Browse files
feat: include organization info in /admin/users endpoint (#153)
1 parent 7cd21da commit 3f8b738

8 files changed

Lines changed: 664 additions & 51 deletions

File tree

crates/api/src/conversions.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@ pub fn db_user_to_admin_user(user: &database::User) -> AdminUserResponse {
540540
created_at: user.created_at,
541541
last_login_at: user.last_login_at,
542542
is_active: user.is_active,
543+
organizations: None,
543544
}
544545
}
545546

crates/api/src/models.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,16 @@ pub struct PublicUserResponse {
952952
pub created_at: DateTime<Utc>,
953953
}
954954

955+
/// Organization details with spend limit (for admin user listing)
956+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
957+
pub struct AdminUserOrganizationDetails {
958+
pub id: String,
959+
pub name: String,
960+
pub description: Option<String>,
961+
#[serde(rename = "spendLimit", skip_serializing_if = "Option::is_none")]
962+
pub spend_limit: Option<SpendLimit>,
963+
}
964+
955965
/// Admin user response model (for owners/admins)
956966
/// Contains sensitive information only visible to organization owners/admins
957967
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -964,6 +974,8 @@ pub struct AdminUserResponse {
964974
pub created_at: DateTime<Utc>,
965975
pub last_login_at: Option<DateTime<Utc>>,
966976
pub is_active: bool,
977+
#[serde(skip_serializing_if = "Option::is_none")]
978+
pub organizations: Option<Vec<AdminUserOrganizationDetails>>,
967979
}
968980

969981
/// User response model (full user profile)
@@ -1337,7 +1349,7 @@ pub struct SpendLimitRequest {
13371349
/// Examples:
13381350
/// $100.00 USD: amount=100000000000, scale=9, currency="USD"
13391351
/// $0.01 USD: amount=10000000, scale=9, currency="USD"
1340-
#[derive(Debug, Serialize, Deserialize, ToSchema)]
1352+
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
13411353
pub struct SpendLimit {
13421354
pub amount: i64,
13431355
pub scale: i64,

crates/api/src/routes/admin.rs

Lines changed: 111 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use crate::middleware::AdminUser;
22
use crate::models::{
3-
AdminAccessTokenResponse, AdminUserResponse, BatchUpdateModelApiRequest,
4-
CreateAdminAccessTokenRequest, DecimalPrice, DeleteAdminAccessTokenRequest, ErrorResponse,
5-
ListUsersResponse, ModelHistoryEntry, ModelHistoryResponse, ModelMetadata, ModelWithPricing,
6-
OrgLimitsHistoryEntry, OrgLimitsHistoryResponse, SpendLimit, UpdateOrganizationLimitsRequest,
3+
AdminAccessTokenResponse, AdminUserOrganizationDetails, AdminUserResponse,
4+
BatchUpdateModelApiRequest, CreateAdminAccessTokenRequest, DecimalPrice,
5+
DeleteAdminAccessTokenRequest, ErrorResponse, ListUsersResponse, ModelHistoryEntry,
6+
ModelHistoryResponse, ModelMetadata, ModelWithPricing, OrgLimitsHistoryEntry,
7+
OrgLimitsHistoryResponse, SpendLimit, UpdateOrganizationLimitsRequest,
78
UpdateOrganizationLimitsResponse,
89
};
910
use axum::{
@@ -159,8 +160,8 @@ pub async fn batch_upsert_models(
159160
tag = "Admin",
160161
params(
161162
("model_name" = String, Path, description = "Model name to get complete history for (URL-encode if it contains slashes)"),
162-
("limit" = i64, Query, description = "Maximum number of history entries to return (default: 50)"),
163-
("offset" = i64, Query, description = "Number of history entries to skip (default: 0)")
163+
("limit" = Option<i64>, Query, description = "Maximum number of history entries to return (default: 50)"),
164+
("offset" = Option<i64>, Query, description = "Number of history entries to skip (default: 0)")
164165
),
165166
responses(
166167
(status = 200, description = "Model history retrieved successfully", body = ModelHistoryResponse),
@@ -372,8 +373,8 @@ pub async fn update_organization_limits(
372373
tag = "Admin",
373374
params(
374375
("organization_id" = String, Path, description = "The organization's ID (as a UUID)"),
375-
("limit" = i64, Query, description = "Maximum number of history records to return (default: 50)"),
376-
("offset" = i64, Query, description = "Number of records to skip (default: 0)")
376+
("limit" = Option<i64>, Query, description = "Maximum number of history records to return (default: 50)"),
377+
("offset" = Option<i64>, Query, description = "Number of records to skip (default: 0)")
377378
),
378379
responses(
379380
(status = 200, description = "Limits history retrieved successfully", body = OrgLimitsHistoryResponse),
@@ -543,8 +544,9 @@ pub async fn delete_model(
543544
path = "/admin/users",
544545
tag = "Admin",
545546
params(
546-
("limit" = i64, Query, description = "Maximum number of users to return (default: 50)"),
547-
("offset" = i64, Query, description = "Number of users to skip (default: 0)")
547+
("limit" = Option<i64>, Query, description = "Maximum number of users to return (default: 100)"),
548+
("offset" = Option<i64>, Query, description = "Number of users to skip (default: 0)"),
549+
("include_organizations" = Option<bool>, Query, description = "Whether to include organization information and spend limits for the first organization owned by each user (default: false)")
548550
),
549551
responses(
550552
(status = 200, description = "Users retrieved successfully", body = ListUsersResponse),
@@ -563,44 +565,104 @@ pub async fn list_users(
563565
crate::routes::common::validate_limit_offset(params.limit, params.offset)?;
564566

565567
debug!(
566-
"List users request with limit={}, offset={}",
567-
params.limit, params.offset
568+
"List users request with limit={}, offset={}, include_organizations={}",
569+
params.limit, params.offset, params.include_organizations
568570
);
569571

570-
let (users, total) = app_state
571-
.admin_service
572-
.list_users(params.limit, params.offset)
573-
.await
574-
.map_err(|e| {
575-
error!("Failed to list users");
576-
match e {
577-
services::admin::AdminError::Unauthorized(msg) => (
578-
StatusCode::UNAUTHORIZED,
579-
ResponseJson(ErrorResponse::new(msg, "unauthorized".to_string())),
580-
),
581-
_ => (
582-
StatusCode::INTERNAL_SERVER_ERROR,
583-
ResponseJson(ErrorResponse::new(
584-
"Failed to retrieve users".to_string(),
585-
"internal_server_error".to_string(),
586-
)),
587-
),
588-
}
589-
})?;
590-
591-
let user_responses: Vec<AdminUserResponse> = users
592-
.into_iter()
593-
.map(|u| AdminUserResponse {
594-
id: u.id.to_string(),
595-
email: u.email,
596-
username: Some(u.username),
597-
display_name: u.display_name,
598-
avatar_url: u.avatar_url,
599-
created_at: u.created_at,
600-
last_login_at: u.last_login_at,
601-
is_active: u.is_active,
602-
})
603-
.collect();
572+
let (user_responses, total) = if params.include_organizations {
573+
// Fetch users with their default organization and spend limit
574+
let (users_with_orgs, total) = app_state
575+
.admin_service
576+
.list_users_with_organizations(params.limit, params.offset)
577+
.await
578+
.map_err(|e| {
579+
error!("Failed to list users with organizations");
580+
match e {
581+
services::admin::AdminError::Unauthorized(msg) => (
582+
StatusCode::UNAUTHORIZED,
583+
ResponseJson(ErrorResponse::new(msg, "unauthorized".to_string())),
584+
),
585+
_ => (
586+
StatusCode::INTERNAL_SERVER_ERROR,
587+
ResponseJson(ErrorResponse::new(
588+
"Failed to retrieve users".to_string(),
589+
"internal_server_error".to_string(),
590+
)),
591+
),
592+
}
593+
})?;
594+
595+
let responses: Vec<AdminUserResponse> = users_with_orgs
596+
.into_iter()
597+
.map(|(u, org_data)| {
598+
let organizations = org_data.map(|org_info| {
599+
vec![AdminUserOrganizationDetails {
600+
id: org_info.id.to_string(),
601+
name: org_info.name,
602+
description: org_info.description,
603+
spend_limit: org_info.spend_limit.map(|amount| SpendLimit {
604+
amount,
605+
scale: 9,
606+
currency: "USD".to_string(),
607+
}),
608+
}]
609+
});
610+
611+
AdminUserResponse {
612+
id: u.id.to_string(),
613+
email: u.email,
614+
username: Some(u.username),
615+
display_name: u.display_name,
616+
avatar_url: u.avatar_url,
617+
created_at: u.created_at,
618+
last_login_at: u.last_login_at,
619+
is_active: u.is_active,
620+
organizations,
621+
}
622+
})
623+
.collect();
624+
625+
(responses, total)
626+
} else {
627+
// Return users data only
628+
let (users, total) = app_state
629+
.admin_service
630+
.list_users(params.limit, params.offset)
631+
.await
632+
.map_err(|e| {
633+
error!("Failed to list users");
634+
match e {
635+
services::admin::AdminError::Unauthorized(msg) => (
636+
StatusCode::UNAUTHORIZED,
637+
ResponseJson(ErrorResponse::new(msg, "unauthorized".to_string())),
638+
),
639+
_ => (
640+
StatusCode::INTERNAL_SERVER_ERROR,
641+
ResponseJson(ErrorResponse::new(
642+
"Failed to retrieve users".to_string(),
643+
"internal_server_error".to_string(),
644+
)),
645+
),
646+
}
647+
})?;
648+
649+
let responses: Vec<AdminUserResponse> = users
650+
.into_iter()
651+
.map(|u| AdminUserResponse {
652+
id: u.id.to_string(),
653+
email: u.email,
654+
username: Some(u.username),
655+
display_name: u.display_name,
656+
avatar_url: u.avatar_url,
657+
created_at: u.created_at,
658+
last_login_at: u.last_login_at,
659+
is_active: u.is_active,
660+
organizations: None,
661+
})
662+
.collect();
663+
664+
(responses, total)
665+
};
604666

605667
let response = ListUsersResponse {
606668
users: user_responses,
@@ -703,7 +765,7 @@ pub async fn create_admin_access_token(
703765
path = "/admin/access-tokens",
704766
tag = "Admin",
705767
params(
706-
("limit" = Option<i64>, Query, description = "Number of records to return (default: 50)"),
768+
("limit" = Option<i64>, Query, description = "Number of records to return (default: 100)"),
707769
("offset" = Option<i64>, Query, description = "Number of records to skip (default: 0)")
708770
),
709771
responses(
@@ -858,6 +920,8 @@ pub struct ListUsersQueryParams {
858920
pub limit: i64,
859921
#[serde(default)]
860922
pub offset: i64,
923+
#[serde(default)]
924+
pub include_organizations: bool,
861925
}
862926

863927
#[derive(Debug, serde::Deserialize)]

0 commit comments

Comments
 (0)