mas_handlers/admin/v1/user_emails/
list.rs

1// Copyright 2024, 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4// Please see LICENSE files in the repository root for full details.
5
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{Json, response::IntoResponse};
8use axum_extra::extract::{Query, QueryRejection};
9use axum_macros::FromRequestParts;
10use hyper::StatusCode;
11use mas_axum_utils::record_error;
12use mas_storage::{Page, user::UserEmailFilter};
13use schemars::JsonSchema;
14use serde::Deserialize;
15use ulid::Ulid;
16
17use crate::{
18    admin::{
19        call_context::CallContext,
20        model::{Resource, UserEmail},
21        params::{IncludeCount, Pagination},
22        response::{ErrorResponse, PaginatedResponse},
23    },
24    impl_from_error_for_route,
25};
26
27#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
28#[serde(rename = "UserEmailFilter")]
29#[aide(input_with = "Query<FilterParams>")]
30#[from_request(via(Query), rejection(RouteError))]
31pub struct FilterParams {
32    /// Retrieve the items for the given user
33    #[serde(rename = "filter[user]")]
34    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
35    user: Option<Ulid>,
36
37    /// Retrieve the user email with the given email address
38    #[serde(rename = "filter[email]")]
39    email: Option<String>,
40}
41
42impl std::fmt::Display for FilterParams {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        let mut sep = '?';
45
46        if let Some(user) = self.user {
47            write!(f, "{sep}filter[user]={user}")?;
48            sep = '&';
49        }
50
51        if let Some(email) = &self.email {
52            write!(f, "{sep}filter[email]={email}")?;
53            sep = '&';
54        }
55
56        let _ = sep;
57        Ok(())
58    }
59}
60
61#[derive(Debug, thiserror::Error, OperationIo)]
62#[aide(output_with = "Json<ErrorResponse>")]
63pub enum RouteError {
64    #[error(transparent)]
65    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
66
67    #[error("User ID {0} not found")]
68    UserNotFound(Ulid),
69
70    #[error("Invalid filter parameters")]
71    InvalidFilter(#[from] QueryRejection),
72}
73
74impl_from_error_for_route!(mas_storage::RepositoryError);
75
76impl IntoResponse for RouteError {
77    fn into_response(self) -> axum::response::Response {
78        let error = ErrorResponse::from_error(&self);
79        let sentry_event_id = record_error!(self, Self::Internal(_));
80        let status = match self {
81            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
82            Self::UserNotFound(_) => StatusCode::NOT_FOUND,
83            Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
84        };
85        (status, sentry_event_id, Json(error)).into_response()
86    }
87}
88
89pub fn doc(operation: TransformOperation) -> TransformOperation {
90    operation
91        .id("listUserEmails")
92        .summary("List user emails")
93        .description("Retrieve a list of user emails.")
94        .tag("user-email")
95        .response_with::<200, Json<PaginatedResponse<UserEmail>>, _>(|t| {
96            let emails = UserEmail::samples();
97            let pagination = mas_storage::Pagination::first(emails.len());
98            let page = Page {
99                edges: emails
100                    .into_iter()
101                    .map(|node| mas_storage::pagination::Edge {
102                        cursor: node.id(),
103                        node,
104                    })
105                    .collect(),
106                has_next_page: true,
107                has_previous_page: false,
108            };
109
110            t.description("Paginated response of user emails")
111                .example(PaginatedResponse::for_page(
112                    page,
113                    pagination,
114                    Some(42),
115                    UserEmail::PATH,
116                ))
117        })
118        .response_with::<404, RouteError, _>(|t| {
119            let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
120            t.description("User was not found").example(response)
121        })
122}
123
124#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all)]
125pub async fn handler(
126    CallContext { mut repo, .. }: CallContext,
127    Pagination(pagination, include_count): Pagination,
128    params: FilterParams,
129) -> Result<Json<PaginatedResponse<UserEmail>>, RouteError> {
130    let base = format!("{path}{params}", path = UserEmail::PATH);
131    let base = include_count.add_to_base(&base);
132    let filter = UserEmailFilter::default();
133
134    // Load the user from the filter
135    let user = if let Some(user_id) = params.user {
136        let user = repo
137            .user()
138            .lookup(user_id)
139            .await?
140            .ok_or(RouteError::UserNotFound(user_id))?;
141
142        Some(user)
143    } else {
144        None
145    };
146
147    let filter = match &user {
148        Some(user) => filter.for_user(user),
149        None => filter,
150    };
151
152    let filter = match &params.email {
153        Some(email) => filter.for_email(email),
154        None => filter,
155    };
156
157    let response = match include_count {
158        IncludeCount::True => {
159            let page = repo
160                .user_email()
161                .list(filter, pagination)
162                .await?
163                .map(UserEmail::from);
164            let count = repo.user_email().count(filter).await?;
165            PaginatedResponse::for_page(page, pagination, Some(count), &base)
166        }
167        IncludeCount::False => {
168            let page = repo
169                .user_email()
170                .list(filter, pagination)
171                .await?
172                .map(UserEmail::from);
173            PaginatedResponse::for_page(page, pagination, None, &base)
174        }
175        IncludeCount::Only => {
176            let count = repo.user_email().count(filter).await?;
177            PaginatedResponse::for_count_only(count, &base)
178        }
179    };
180
181    Ok(Json(response))
182}
183
184#[cfg(test)]
185mod tests {
186    use hyper::{Request, StatusCode};
187    use sqlx::PgPool;
188
189    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
190
191    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
192    async fn test_list(pool: PgPool) {
193        setup();
194        let mut state = TestState::from_pool(pool).await.unwrap();
195        let token = state.token_with_scope("urn:mas:admin").await;
196        let mut rng = state.rng();
197
198        // Provision two users, two emails
199        let mut repo = state.repository().await.unwrap();
200        let alice = repo
201            .user()
202            .add(&mut rng, &state.clock, "alice".to_owned())
203            .await
204            .unwrap();
205        let bob = repo
206            .user()
207            .add(&mut rng, &state.clock, "bob".to_owned())
208            .await
209            .unwrap();
210
211        repo.user_email()
212            .add(
213                &mut rng,
214                &state.clock,
215                &alice,
216                "alice@example.com".to_owned(),
217            )
218            .await
219            .unwrap();
220        repo.user_email()
221            .add(&mut rng, &state.clock, &bob, "bob@example.com".to_owned())
222            .await
223            .unwrap();
224        repo.save().await.unwrap();
225
226        let request = Request::get("/api/admin/v1/user-emails")
227            .bearer(&token)
228            .empty();
229        let response = state.request(request).await;
230        response.assert_status(StatusCode::OK);
231        let body: serde_json::Value = response.json();
232        insta::assert_json_snapshot!(body, @r#"
233        {
234          "meta": {
235            "count": 2
236          },
237          "data": [
238            {
239              "type": "user-email",
240              "id": "01FSHN9AG09NMZYX8MFYH578R9",
241              "attributes": {
242                "created_at": "2022-01-16T14:40:00Z",
243                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
244                "email": "alice@example.com"
245              },
246              "links": {
247                "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
248              },
249              "meta": {
250                "page": {
251                  "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
252                }
253              }
254            },
255            {
256              "type": "user-email",
257              "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
258              "attributes": {
259                "created_at": "2022-01-16T14:40:00Z",
260                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
261                "email": "bob@example.com"
262              },
263              "links": {
264                "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z"
265              },
266              "meta": {
267                "page": {
268                  "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z"
269                }
270              }
271            }
272          ],
273          "links": {
274            "self": "/api/admin/v1/user-emails?page[first]=10",
275            "first": "/api/admin/v1/user-emails?page[first]=10",
276            "last": "/api/admin/v1/user-emails?page[last]=10"
277          }
278        }
279        "#);
280
281        // Filter by user
282        let request = Request::get(format!(
283            "/api/admin/v1/user-emails?filter[user]={}",
284            alice.id
285        ))
286        .bearer(&token)
287        .empty();
288        let response = state.request(request).await;
289        response.assert_status(StatusCode::OK);
290        let body: serde_json::Value = response.json();
291        insta::assert_json_snapshot!(body, @r#"
292        {
293          "meta": {
294            "count": 1
295          },
296          "data": [
297            {
298              "type": "user-email",
299              "id": "01FSHN9AG09NMZYX8MFYH578R9",
300              "attributes": {
301                "created_at": "2022-01-16T14:40:00Z",
302                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
303                "email": "alice@example.com"
304              },
305              "links": {
306                "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
307              },
308              "meta": {
309                "page": {
310                  "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
311                }
312              }
313            }
314          ],
315          "links": {
316            "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
317            "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
318            "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
319          }
320        }
321        "#);
322
323        // Filter by email
324        let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com")
325            .bearer(&token)
326            .empty();
327        let response = state.request(request).await;
328        response.assert_status(StatusCode::OK);
329        let body: serde_json::Value = response.json();
330        insta::assert_json_snapshot!(body, @r#"
331        {
332          "meta": {
333            "count": 1
334          },
335          "data": [
336            {
337              "type": "user-email",
338              "id": "01FSHN9AG09NMZYX8MFYH578R9",
339              "attributes": {
340                "created_at": "2022-01-16T14:40:00Z",
341                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
342                "email": "alice@example.com"
343              },
344              "links": {
345                "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
346              },
347              "meta": {
348                "page": {
349                  "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
350                }
351              }
352            }
353          ],
354          "links": {
355            "self": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
356            "first": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10",
357            "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10"
358          }
359        }
360        "#);
361
362        // Test count=false
363        let request = Request::get("/api/admin/v1/user-emails?count=false")
364            .bearer(&token)
365            .empty();
366        let response = state.request(request).await;
367        response.assert_status(StatusCode::OK);
368        let body: serde_json::Value = response.json();
369        insta::assert_json_snapshot!(body, @r#"
370        {
371          "data": [
372            {
373              "type": "user-email",
374              "id": "01FSHN9AG09NMZYX8MFYH578R9",
375              "attributes": {
376                "created_at": "2022-01-16T14:40:00Z",
377                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
378                "email": "alice@example.com"
379              },
380              "links": {
381                "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
382              },
383              "meta": {
384                "page": {
385                  "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
386                }
387              }
388            },
389            {
390              "type": "user-email",
391              "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
392              "attributes": {
393                "created_at": "2022-01-16T14:40:00Z",
394                "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
395                "email": "bob@example.com"
396              },
397              "links": {
398                "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z"
399              },
400              "meta": {
401                "page": {
402                  "cursor": "01FSHN9AG0KEPHYQQXW9XPTX6Z"
403                }
404              }
405            }
406          ],
407          "links": {
408            "self": "/api/admin/v1/user-emails?count=false&page[first]=10",
409            "first": "/api/admin/v1/user-emails?count=false&page[first]=10",
410            "last": "/api/admin/v1/user-emails?count=false&page[last]=10"
411          }
412        }
413        "#);
414
415        // Test count=only
416        let request = Request::get("/api/admin/v1/user-emails?count=only")
417            .bearer(&token)
418            .empty();
419        let response = state.request(request).await;
420        response.assert_status(StatusCode::OK);
421        let body: serde_json::Value = response.json();
422        insta::assert_json_snapshot!(body, @r###"
423        {
424          "meta": {
425            "count": 2
426          },
427          "links": {
428            "self": "/api/admin/v1/user-emails?count=only"
429          }
430        }
431        "###);
432
433        // Test count=false with filtering
434        let request = Request::get(format!(
435            "/api/admin/v1/user-emails?count=false&filter[user]={}",
436            alice.id
437        ))
438        .bearer(&token)
439        .empty();
440        let response = state.request(request).await;
441        response.assert_status(StatusCode::OK);
442        let body: serde_json::Value = response.json();
443        insta::assert_json_snapshot!(body, @r#"
444        {
445          "data": [
446            {
447              "type": "user-email",
448              "id": "01FSHN9AG09NMZYX8MFYH578R9",
449              "attributes": {
450                "created_at": "2022-01-16T14:40:00Z",
451                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
452                "email": "alice@example.com"
453              },
454              "links": {
455                "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9"
456              },
457              "meta": {
458                "page": {
459                  "cursor": "01FSHN9AG09NMZYX8MFYH578R9"
460                }
461              }
462            }
463          ],
464          "links": {
465            "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
466            "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[first]=10",
467            "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=false&page[last]=10"
468          }
469        }
470        "#);
471
472        // Test count=only with filtering
473        let request = Request::get(format!(
474            "/api/admin/v1/user-emails?count=only&filter[user]={}",
475            alice.id
476        ))
477        .bearer(&token)
478        .empty();
479        let response = state.request(request).await;
480        response.assert_status(StatusCode::OK);
481        let body: serde_json::Value = response.json();
482        insta::assert_json_snapshot!(body, @r#"
483        {
484          "meta": {
485            "count": 1
486          },
487          "links": {
488            "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&count=only"
489          }
490        }
491        "#);
492    }
493}