1use std::sync::Arc;
7
8use anyhow::{Context, Result};
9use serde::Serialize;
10use tokio::sync::Semaphore;
11
12use raps_kernel::auth::AuthClient;
13use raps_kernel::config::Config;
14use raps_kernel::http::{self, HttpClientConfig};
15
16use crate::types::{PaginatedResponse, ProductAccess, ProjectUser};
17
18#[derive(Clone)]
22pub struct ProjectUsersClient {
23 config: Config,
24 auth: AuthClient,
25 http_client: reqwest::Client,
26}
27
28#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct AddProjectUserRequest {
32 pub email: String,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub role_id: Option<String>,
37 #[serde(skip_serializing_if = "Vec::is_empty")]
39 pub products: Vec<ProductAccess>,
40}
41
42#[derive(Debug, Clone, Serialize, Default)]
44#[serde(rename_all = "camelCase")]
45pub struct UpdateProjectUserRequest {
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub role_id: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub products: Option<Vec<ProductAccess>>,
52}
53
54#[derive(Debug, Clone, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ImportUserRequest {
58 pub email: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub role_id: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub products: Option<Vec<ProductAccess>>,
66}
67
68#[derive(Debug, Clone)]
70pub struct ImportUsersResult {
71 pub total: usize,
73 pub imported: usize,
75 pub failed: usize,
77 pub errors: Vec<ImportUserError>,
79 pub successes: Vec<ImportUserSuccess>,
81}
82
83#[derive(Debug, Clone)]
85pub struct ImportUserError {
86 pub email: String,
88 pub error: String,
90}
91
92#[derive(Debug, Clone)]
94pub struct ImportUserSuccess {
95 pub email: String,
97 pub user_id: Option<String>,
99}
100
101impl ProjectUsersClient {
102 pub fn new(config: Config, auth: AuthClient) -> Self {
104 Self::new_with_http_config(config, auth, HttpClientConfig::default())
105 }
106
107 pub fn new_with_http_config(
109 config: Config,
110 auth: AuthClient,
111 http_config: HttpClientConfig,
112 ) -> Self {
113 let http_client = http_config
114 .create_client()
115 .unwrap_or_else(|_| reqwest::Client::new());
116
117 Self {
118 config,
119 auth,
120 http_client,
121 }
122 }
123
124 fn project_url(&self, project_id: &str) -> String {
126 let project_id = crate::strip_project_prefix(project_id);
127 format!(
128 "{}/construction/admin/v1/projects/{}",
129 self.config.base_url, project_id
130 )
131 }
132
133 pub async fn list_project_users(
140 &self,
141 project_id: &str,
142 limit: Option<usize>,
143 offset: Option<usize>,
144 ) -> Result<PaginatedResponse<ProjectUser>> {
145 let token = self.auth.get_3leg_token().await?;
146
147 let mut url = format!("{}/users", self.project_url(project_id));
148
149 let mut params = Vec::new();
151 if let Some(l) = limit {
152 params.push(format!("limit={}", l.min(200)));
153 }
154 if let Some(o) = offset {
155 params.push(format!("offset={}", o));
156 }
157 if !params.is_empty() {
158 url = format!("{}?{}", url, params.join("&"));
159 }
160
161 let response = http::send_with_retry(&self.config.http_config, || {
162 self.http_client.get(&url).bearer_auth(&token)
163 })
164 .await?;
165
166 if !response.status().is_success() {
167 let status = response.status();
168 let error_text = response.text().await.unwrap_or_default();
169 anyhow::bail!("Failed to list project users ({status}): {error_text}");
170 }
171
172 let users_response: PaginatedResponse<ProjectUser> = response
173 .json()
174 .await
175 .context("Failed to parse project users response")?;
176
177 Ok(users_response)
178 }
179
180 pub async fn get_project_user(&self, project_id: &str, user_id: &str) -> Result<ProjectUser> {
186 let token = self.auth.get_3leg_token().await?;
187
188 let url = format!("{}/users/{}", self.project_url(project_id), user_id);
189
190 let response = http::send_with_retry(&self.config.http_config, || {
191 self.http_client.get(&url).bearer_auth(&token)
192 })
193 .await?;
194
195 if !response.status().is_success() {
196 let status = response.status();
197 let error_text = response.text().await.unwrap_or_default();
198 anyhow::bail!("Failed to get project user ({status}): {error_text}");
199 }
200
201 let user: ProjectUser = response
202 .json()
203 .await
204 .context("Failed to parse project user response")?;
205
206 Ok(user)
207 }
208
209 pub async fn add_user(
218 &self,
219 project_id: &str,
220 request: AddProjectUserRequest,
221 ) -> Result<ProjectUser> {
222 let token = self.auth.get_3leg_token().await?;
223
224 let url = format!("{}/users", self.project_url(project_id));
225
226 let response = http::send_with_retry(&self.config.http_config, || {
227 self.http_client
228 .post(&url)
229 .bearer_auth(&token)
230 .header("Content-Type", "application/json")
231 .json(&request)
232 })
233 .await?;
234
235 if !response.status().is_success() {
236 let status = response.status();
237 let error_text = response.text().await.unwrap_or_default();
238 anyhow::bail!("Failed to add user to project ({status}): {error_text}");
239 }
240
241 let user: ProjectUser = response
242 .json()
243 .await
244 .context("Failed to parse add user response")?;
245
246 Ok(user)
247 }
248
249 pub async fn update_user(
256 &self,
257 project_id: &str,
258 user_id: &str,
259 request: UpdateProjectUserRequest,
260 ) -> Result<ProjectUser> {
261 let token = self.auth.get_3leg_token().await?;
262
263 let url = format!("{}/users/{}", self.project_url(project_id), user_id);
264
265 let response = http::send_with_retry(&self.config.http_config, || {
266 self.http_client
267 .patch(&url)
268 .bearer_auth(&token)
269 .header("Content-Type", "application/json")
270 .json(&request)
271 })
272 .await?;
273
274 if !response.status().is_success() {
275 let status = response.status();
276 let error_text = response.text().await.unwrap_or_default();
277 anyhow::bail!("Failed to update project user ({status}): {error_text}");
278 }
279
280 let user: ProjectUser = response
281 .json()
282 .await
283 .context("Failed to parse update user response")?;
284
285 Ok(user)
286 }
287
288 pub async fn remove_user(&self, project_id: &str, user_id: &str) -> Result<()> {
294 let token = self.auth.get_3leg_token().await?;
295
296 let url = format!("{}/users/{}", self.project_url(project_id), user_id);
297
298 let response = http::send_with_retry(&self.config.http_config, || {
299 self.http_client.delete(&url).bearer_auth(&token)
300 })
301 .await?;
302
303 if !response.status().is_success() {
304 let status = response.status();
305 let error_text = response.text().await.unwrap_or_default();
306 anyhow::bail!("Failed to remove user from project ({status}): {error_text}");
307 }
308
309 Ok(())
310 }
311
312 pub async fn user_exists(&self, project_id: &str, user_id: &str) -> Result<bool> {
321 let token = self.auth.get_3leg_token().await?;
322
323 let url = format!("{}/users/{}", self.project_url(project_id), user_id);
324
325 let response = http::send_with_retry(&self.config.http_config, || {
326 self.http_client.get(&url).bearer_auth(&token)
327 })
328 .await?;
329
330 Ok(response.status().is_success())
331 }
332
333 pub async fn list_all_project_users(&self, project_id: &str) -> Result<Vec<ProjectUser>> {
335 let mut all_users = Vec::new();
336 let mut offset = 0;
337 let limit = 200;
338
339 loop {
340 let response = self
341 .list_project_users(project_id, Some(limit), Some(offset))
342 .await?;
343 let has_more = response.has_more();
344 let next_offset = response.next_offset();
345 all_users.extend(response.results);
346
347 if !has_more {
348 break;
349 }
350 offset = next_offset;
351 }
352
353 Ok(all_users)
354 }
355
356 pub async fn import_users(
368 &self,
369 project_id: &str,
370 users: Vec<ImportUserRequest>,
371 ) -> Result<ImportUsersResult> {
372 let total = users.len();
373 let semaphore = Arc::new(Semaphore::new(10));
374 let mut join_set = tokio::task::JoinSet::new();
375
376 for user in users {
377 let client = self.clone();
378 let sem = semaphore.clone();
379 let pid = project_id.to_string();
380 let email = user.email.clone();
381
382 join_set.spawn(async move {
383 let _permit = sem.acquire().await.expect("semaphore closed unexpectedly");
384 let request = AddProjectUserRequest {
385 email: user.email.clone(),
386 role_id: user.role_id,
387 products: user.products.unwrap_or_default(),
388 };
389 let result = client.add_user(&pid, request).await;
390 (email, result)
391 });
392 }
393
394 let mut imported = 0;
395 let mut failed = 0;
396 let mut errors = Vec::new();
397 let mut successes = Vec::new();
398
399 while let Some(join_result) = join_set.join_next().await {
400 match join_result {
401 Ok((email, Ok(project_user))) => {
402 imported += 1;
403 successes.push(ImportUserSuccess {
404 email,
405 user_id: Some(project_user.id),
406 });
407 }
408 Ok((email, Err(e))) => {
409 failed += 1;
410 errors.push(ImportUserError {
411 email,
412 error: e.to_string(),
413 });
414 }
415 Err(e) => {
416 failed += 1;
417 errors.push(ImportUserError {
418 email: "unknown".to_string(),
419 error: format!("Task join error: {e}"),
420 });
421 }
422 }
423 }
424
425 Ok(ImportUsersResult {
426 total,
427 imported,
428 failed,
429 errors,
430 successes,
431 })
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_add_request_serialization() {
441 let request = AddProjectUserRequest {
442 email: "user@example.com".to_string(),
443 role_id: Some("role-456".to_string()),
444 products: vec![ProductAccess {
445 key: "docs".to_string(),
446 access: "member".to_string(),
447 }],
448 };
449
450 let json = serde_json::to_string(&request).unwrap();
451 assert!(json.contains("\"email\":\"user@example.com\""));
452 assert!(json.contains("role-456"));
453 assert!(json.contains("docs"));
454 }
455
456 #[test]
457 fn test_update_request_serialization() {
458 let request = UpdateProjectUserRequest {
459 role_id: Some("new-role".to_string()),
460 products: None,
461 };
462
463 let json = serde_json::to_string(&request).unwrap();
464 assert!(json.contains("new-role"));
465 assert!(!json.contains("products"));
467 }
468
469 #[test]
470 fn test_import_users_result_aggregation() {
471 let result = ImportUsersResult {
472 total: 5,
473 imported: 3,
474 failed: 2,
475 errors: vec![
476 ImportUserError {
477 email: "bad1@test.com".to_string(),
478 error: "Not found".to_string(),
479 },
480 ImportUserError {
481 email: "bad2@test.com".to_string(),
482 error: "Conflict".to_string(),
483 },
484 ],
485 successes: vec![
486 ImportUserSuccess {
487 email: "ok1@test.com".to_string(),
488 user_id: Some("u1".to_string()),
489 },
490 ImportUserSuccess {
491 email: "ok2@test.com".to_string(),
492 user_id: Some("u2".to_string()),
493 },
494 ImportUserSuccess {
495 email: "ok3@test.com".to_string(),
496 user_id: Some("u3".to_string()),
497 },
498 ],
499 };
500
501 assert_eq!(result.total, 5);
502 assert_eq!(result.imported + result.failed, result.total);
503 assert_eq!(result.errors.len(), 2);
504 assert_eq!(result.successes.len(), 3);
505 assert_eq!(result.errors[0].email, "bad1@test.com");
506 assert_eq!(result.successes[0].email, "ok1@test.com");
507 }
508}