Skip to main content

raps_acc/
users.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Project Users API client for ACC/BIM 360
5
6use 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/// Client for ACC Project Users API
19///
20/// Provides operations for managing users within individual projects.
21#[derive(Clone)]
22pub struct ProjectUsersClient {
23    config: Config,
24    auth: AuthClient,
25    http_client: reqwest::Client,
26}
27
28/// Request to add a user to a project
29#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct AddProjectUserRequest {
32    /// User email address
33    pub email: String,
34    /// Role ID to assign (optional)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub role_id: Option<String>,
37    /// Product access configurations
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub products: Vec<ProductAccess>,
40}
41
42/// Request to update a project user
43#[derive(Debug, Clone, Serialize, Default)]
44#[serde(rename_all = "camelCase")]
45pub struct UpdateProjectUserRequest {
46    /// New role ID to assign
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub role_id: Option<String>,
49    /// Updated product access configurations
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub products: Option<Vec<ProductAccess>>,
52}
53
54/// User import request item
55#[derive(Debug, Clone, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ImportUserRequest {
58    /// User email address
59    pub email: String,
60    /// Optional role ID to assign
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub role_id: Option<String>,
63    /// Optional product access configurations
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub products: Option<Vec<ProductAccess>>,
66}
67
68/// Result of a bulk user import operation
69#[derive(Debug, Clone)]
70pub struct ImportUsersResult {
71    /// Total number of users attempted
72    pub total: usize,
73    /// Number of users successfully imported
74    pub imported: usize,
75    /// Number of users that failed to import
76    pub failed: usize,
77    /// Individual errors for failed imports
78    pub errors: Vec<ImportUserError>,
79    /// Successfully imported users
80    pub successes: Vec<ImportUserSuccess>,
81}
82
83/// Error details for a failed user import
84#[derive(Debug, Clone)]
85pub struct ImportUserError {
86    /// Email of the user that failed to import
87    pub email: String,
88    /// Error message describing why the import failed
89    pub error: String,
90}
91
92/// Success details for an imported user
93#[derive(Debug, Clone)]
94pub struct ImportUserSuccess {
95    /// Email of the successfully imported user
96    pub email: String,
97    /// User ID if available
98    pub user_id: Option<String>,
99}
100
101impl ProjectUsersClient {
102    /// Create a new Project Users client
103    pub fn new(config: Config, auth: AuthClient) -> Self {
104        Self::new_with_http_config(config, auth, HttpClientConfig::default())
105    }
106
107    /// Create client with custom HTTP configuration
108    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    /// Get the base URL for Project Admin API
125    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    /// List members of a project (paginated)
134    ///
135    /// # Arguments
136    /// * `project_id` - The project ID
137    /// * `limit` - Maximum results per page (max: 200)
138    /// * `offset` - Starting index
139    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        // Build query parameters
150        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    /// Get a specific user's membership in a project
181    ///
182    /// # Arguments
183    /// * `project_id` - The project ID
184    /// * `user_id` - The user ID
185    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    /// Add a user to a project
210    ///
211    /// # Arguments
212    /// * `project_id` - The project ID
213    /// * `request` - Add user request with user ID, role, and products
214    ///
215    /// # Returns
216    /// The newly created project user membership
217    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    /// Update a user's role or product access in a project
250    ///
251    /// # Arguments
252    /// * `project_id` - The project ID
253    /// * `user_id` - The user ID to update
254    /// * `request` - Update request with new role or products
255    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    /// Remove a user from a project
289    ///
290    /// # Arguments
291    /// * `project_id` - The project ID
292    /// * `user_id` - The user ID to remove
293    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    /// Check if a user exists in a project
313    ///
314    /// # Arguments
315    /// * `project_id` - The project ID
316    /// * `user_id` - The user ID to check
317    ///
318    /// # Returns
319    /// True if the user is a member of the project, false otherwise
320    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    /// Fetch all users in a project (handles pagination automatically)
334    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    /// Import multiple users to a project concurrently
357    ///
358    /// Adds each user individually via concurrent requests bounded by a semaphore
359    /// (max 10 concurrent) for rate-limit safety. Collects per-user results.
360    ///
361    /// # Arguments
362    /// * `project_id` - The project ID
363    /// * `users` - List of users to import
364    ///
365    /// # Returns
366    /// An `ImportUsersResult` containing the overall summary and individual results
367    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        // products should be skipped when None
466        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}