Skip to main content

api_handler/
handlers.rs

1//! # HTTPリクエストハンドラーモジュール
2//!
3//! このモジュールは、コンタクトフォームAPIのHTTPリクエストハンドラーを提供します。
4//! [`crate::main`] のルーター (`function_handler`) から呼び出され、
5//! データベース操作を行ってJSONレスポンスを構築します。
6//!
7//! ## 提供するハンドラー
8//!
9//! | 関数 | HTTPメソッド | パス | 説明 |
10//! |------|------------|------|------|
11//! | [`handle_get_inquiries`] | GET | /inquiries | お問い合わせ一覧取得 |
12//! | [`handle_post_inquiry`] | POST | /inquiries | 新規お問い合わせ作成 |
13//!
14//! ## 認可モデル
15//!
16//! 全ハンドラーは認証済みユーザーのみ操作でき、JWTクレームから取得した
17//! `email` と `cognito_sub`(Cognito ユーザーの UUID)でデータをフィルタリングします。
18//! これにより、ユーザーは自分自身のお問い合わせにのみアクセス・作成できます。
19//!
20//! ## データモデル
21//!
22//! お問い合わせデータは [`sea_orm_entities::entity::inquiries`] エンティティで管理され、
23//! PostgreSQL テーブルに永続化されます。
24
25use crate::models::{
26    CreateInquiryRequest, CreateInquiryResponse, Inquiry, InquiryListResponse, Response,
27};
28#[cfg(feature = "openapi")]
29use crate::models::ErrorResponseBody;
30use lambda_runtime::Error;
31use sea_orm::{
32    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
33};
34use sea_orm_entities::entity::inquiries::{self, Column, Entity as Inquiries};
35
36#[cfg_attr(
37    feature = "openapi",
38    utoipa::path(
39        get,
40        path = "/inquiries",
41        tag = "inquiries",
42        responses(
43            (status = 200, description = "Get inquiries", body = InquiryListResponse),
44            (status = 401, description = "Unauthorized", body = ErrorResponseBody),
45            (status = 500, description = "Internal server error", body = ErrorResponseBody),
46        ),
47        security(
48            ("CognitoAuthorizer" = []),
49            ("BearerAuth" = []),
50        )
51    )
52)]
53/// 認証済みユーザーのお問い合わせ一覧を取得する
54///
55/// JWTクレームから取得した `email` と `cognito_sub` でデータベースをフィルタリングし、
56/// 当該ユーザーが送信したお問い合わせを作成日時の降順(新しい順)で返します。
57///
58/// ## データベースクエリ
59///
60/// ```sql
61/// SELECT id, cognito_sub, email, subject, body, created_at
62/// FROM inquiries
63/// WHERE email = $1 AND cognito_sub = $2
64/// ORDER BY created_at DESC
65/// ```
66///
67/// # Arguments
68///
69/// * `db` - SeaORM データベース接続。Aurora DSQL への接続が確立済みである必要があります。
70/// * `email` - JWTクレームから取得した認証済みユーザーのメールアドレス。
71///   このアドレスに一致するお問い合わせのみが返されます。
72/// * `cognito_sub` - JWTクレームの `sub` フィールドから取得した Cognito ユーザーの UUID。
73///   `email` と組み合わせることでユーザーを一意に識別します。
74/// * `cors_origin` - レスポンスの `Access-Control-Allow-Origin` ヘッダーに設定するオリジン。
75///
76/// # Returns
77///
78/// * `Ok(Response)` - HTTP 200 と [`InquiryListResponse`] のJSON(`email`, `count`, `inquiries` フィールドを含む)
79/// * `Err(Error)` - データベースクエリエラーまたはJSONシリアライズエラー
80///
81/// # Errors
82///
83/// - データベースクエリ失敗時: `"Database query failed: ..."` をログに記録し `Err` を返します。
84/// - JSONシリアライズ失敗時: [`serde_json::to_value`] のエラーを `?` で伝播します。
85pub(crate) async fn handle_get_inquiries(
86    db: &DatabaseConnection,
87    email: &str,
88    cognito_sub: uuid::Uuid,
89    cors_origin: &str,
90) -> Result<Response, Error> {
91    tracing::info!("Querying inquiries for email: {}", email);
92
93    let inquiries: Vec<Inquiry> = Inquiries::find()
94        .filter(Column::Email.eq(email))
95        .filter(Column::CognitoSub.eq(cognito_sub))
96        .order_by_desc(Column::CreatedAt)
97        .into_model::<Inquiry>()
98        .all(db)
99        .await
100        .map_err(|e| {
101            tracing::error!("Database query failed: {}", e);
102            anyhow::anyhow!("Database query failed: {}", e)
103        })?;
104
105    let response_body = InquiryListResponse {
106        email: email.to_string(),
107        count: inquiries.len() as u64,
108        inquiries,
109    };
110
111    Ok(Response::new(
112        200,
113        serde_json::to_value(response_body)?,
114        cors_origin,
115    ))
116}
117
118#[cfg_attr(
119    feature = "openapi",
120    utoipa::path(
121        post,
122        path = "/inquiries",
123        tag = "inquiries",
124        request_body = CreateInquiryRequest,
125        responses(
126            (status = 201, description = "Create inquiry", body = CreateInquiryResponse),
127            (status = 401, description = "Unauthorized", body = ErrorResponseBody),
128            (status = 500, description = "Internal server error", body = ErrorResponseBody),
129        ),
130        security(
131            ("CognitoAuthorizer" = []),
132            ("BearerAuth" = []),
133        )
134    )
135)]
136/// 新規お問い合わせを作成する
137///
138/// リクエストボディから [`CreateInquiryRequest`] をデシリアライズし、
139/// UUID v7 の ID と現在時刻を付与してデータベースに保存します。
140/// 保存したお問い合わせを [`CreateInquiryResponse`] として HTTP 201 で返します。
141///
142/// ## データベース操作
143///
144/// ```sql
145/// INSERT INTO inquiries (id, cognito_sub, email, subject, body, created_at)
146/// VALUES ($1, $2, $3, $4, $5, $6)
147/// ```
148///
149/// ## ID の生成
150///
151/// お問い合わせ ID には UUID v7 ([`uuid::Uuid::now_v7`]) を使用します。
152/// UUID v7 はタイムスタンプベースのため、作成順ソートが可能です。
153///
154/// # Arguments
155///
156/// * `db` - SeaORM データベース接続。Aurora DSQL への接続が確立済みである必要があります。
157/// * `email` - JWTクレームから取得した認証済みユーザーのメールアドレス。
158///   お問い合わせのオーナーとして `inquiries.email` 列に保存されます。
159/// * `cognito_sub` - JWTクレームの `sub` フィールドから取得した Cognito ユーザーの UUID。
160///   お問い合わせのオーナーとして `inquiries.cognito_sub` 列に保存されます。
161/// * `body` - リクエストボディの文字列(JSON形式)。[`CreateInquiryRequest`] にデシリアライズされます。
162///   `subject`(件名)と `body`(本文)フィールドを含む必要があります。
163/// * `cors_origin` - レスポンスの `Access-Control-Allow-Origin` ヘッダーに設定するオリジン。
164///
165/// # Returns
166///
167/// * `Ok(Response)` - HTTP 201 と [`CreateInquiryResponse`] のJSON(作成されたお問い合わせ情報を含む)
168/// * `Err(Error)` - リクエストボディのパースエラー、データベース挿入エラー、またはJSONシリアライズエラー
169///
170/// # Errors
171///
172/// - リクエストボディのJSON解析失敗時: `"Failed to parse request body: ..."` をログに記録し `Err` を返します。
173/// - データベース挿入失敗時: `"Failed to insert inquiry: ..."` をログに記録し `Err` を返します。
174/// - JSONシリアライズ失敗時: [`serde_json::to_value`] のエラーを `?` で伝播します。
175pub(crate) async fn handle_post_inquiry(
176    db: &DatabaseConnection,
177    email: &str,
178    cognito_sub: uuid::Uuid,
179    body: &str,
180    cors_origin: &str,
181) -> Result<Response, Error> {
182    tracing::info!("Creating inquiry for email: {}", email);
183
184    let create_request: CreateInquiryRequest = serde_json::from_str(body).map_err(|e| {
185        tracing::error!("Failed to parse request body: {}", e);
186        anyhow::anyhow!("Invalid request body: {}", e)
187    })?;
188
189    let id = uuid::Uuid::now_v7();
190    let now = chrono::Utc::now().fixed_offset();
191
192    let new_inquiry = inquiries::ActiveModel {
193        id: Set(id),
194        cognito_sub: Set(cognito_sub),
195        email: Set(email.to_string()),
196        subject: Set(create_request.subject.clone()),
197        body: Set(create_request.body.clone()),
198        created_at: Set(now),
199        reply: Set(None),
200        respondent: Set(None),
201        reply_at: Set(None),
202    };
203
204    new_inquiry.insert(db).await.map_err(|e| {
205        tracing::error!("Failed to insert inquiry: {}", e);
206        anyhow::anyhow!("Failed to insert inquiry: {}", e)
207    })?;
208
209    let inquiry = Inquiry {
210        id,
211        cognito_sub,
212        email: email.to_string(),
213        subject: create_request.subject,
214        body: create_request.body,
215        created_at: now,
216    };
217
218    let response_body = CreateInquiryResponse { inquiry };
219
220    Ok(Response::new(
221        201,
222        serde_json::to_value(response_body)?,
223        cors_origin,
224    ))
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use chrono::Duration;
231    use sea_orm::{ActiveModelTrait, ColumnTrait, Database, EntityTrait, QueryFilter, Set};
232    use sea_orm_entities::entity::inquiries::{Column, Entity as Inquiries};
233
234    const ORDERING_OFFSET_SECS: i64 = 1;
235
236    async fn connect_local_test_db() -> DatabaseConnection {
237        let database_url = std::env::var("LOCAL_TEST_DATABASE_URL").unwrap_or_else(|_| {
238            "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable".to_string()
239        });
240        Database::connect(database_url)
241            .await
242            .expect("Failed to connect local PostgreSQL test DB")
243    }
244
245    async fn cleanup_test_inquiries(db: &DatabaseConnection, email: &str) {
246        Inquiries::delete_many()
247            .filter(Column::Email.eq(email))
248            .exec(db)
249            .await
250            .expect("test inquiry cleanup should succeed");
251    }
252
253    #[tokio::test]
254    #[ignore = "ローカルのDBが必要なためデフォルトでは実行しない"]
255    async fn test_handle_post_inquiry_with_local_postgres() {
256        let db = connect_local_test_db().await;
257        let email = format!("local-post-{}@example.com", uuid::Uuid::now_v7());
258        let cognito_sub = uuid::Uuid::now_v7();
259        let body = r#"{"subject":"subject from test","body":"body from test"}"#;
260        cleanup_test_inquiries(&db, &email).await;
261
262        let db_for_test = db.clone();
263        let email_for_test = email.clone();
264        let test_result = tokio::spawn(async move {
265            let response =
266                handle_post_inquiry(&db_for_test, &email_for_test, cognito_sub, body, "https://example.com")
267                    .await
268                    .expect("handle_post_inquiry should succeed");
269
270            assert_eq!(response.status_code, 201);
271            let response_body: serde_json::Value =
272                serde_json::from_str(&response.body).expect("response body should be valid JSON");
273            assert_eq!(response_body["inquiry"]["email"], email_for_test);
274            assert_eq!(response_body["inquiry"]["subject"], "subject from test");
275            assert_eq!(response_body["inquiry"]["body"], "body from test");
276
277            let inquiry_id = uuid::Uuid::parse_str(
278                response_body["inquiry"]["id"]
279                    .as_str()
280                    .expect("response should include inquiry id"),
281            )
282            .expect("inquiry id should be valid UUID");
283            let saved = Inquiries::find_by_id(inquiry_id)
284                .one(&db_for_test)
285                .await
286                .expect("DB query should succeed")
287                .expect("inserted inquiry should exist");
288            assert_eq!(saved.email, email_for_test);
289            assert_eq!(saved.cognito_sub, cognito_sub);
290        })
291        .await;
292
293        cleanup_test_inquiries(&db, &email).await;
294
295        if let Err(err) = test_result {
296            if err.is_panic() {
297                std::panic::resume_unwind(err.into_panic());
298            }
299            panic!("test task failed: {err}");
300        }
301    }
302
303    #[tokio::test]
304    #[ignore = "ローカルのDBが必要なためデフォルトでは実行しない"]
305    async fn test_handle_get_inquiries_with_local_postgres() {
306        let db = connect_local_test_db().await;
307        let cognito_sub = uuid::Uuid::now_v7();
308        let email = format!("local-get-{}@example.com", uuid::Uuid::now_v7());
309        let now = chrono::Utc::now().fixed_offset();
310        cleanup_test_inquiries(&db, &email).await;
311
312        inquiries::ActiveModel {
313            id: Set(uuid::Uuid::now_v7()),
314            cognito_sub: Set(cognito_sub),
315            email: Set(email.clone()),
316            subject: Set("older subject".to_string()),
317            body: Set("older body".to_string()),
318            reply: Set(None),
319            respondent: Set(None),
320            created_at: Set(now - Duration::seconds(ORDERING_OFFSET_SECS)),
321            reply_at: Set(None),
322        }
323        .insert(&db)
324        .await
325        .expect("older test record insert should succeed");
326
327        inquiries::ActiveModel {
328            id: Set(uuid::Uuid::now_v7()),
329            cognito_sub: Set(cognito_sub),
330            email: Set(email.clone()),
331            subject: Set("newer subject".to_string()),
332            body: Set("newer body".to_string()),
333            reply: Set(None),
334            respondent: Set(None),
335            created_at: Set(now),
336            reply_at: Set(None),
337        }
338        .insert(&db)
339        .await
340        .expect("newer test record insert should succeed");
341
342        let response = handle_get_inquiries(&db, &email, cognito_sub, "https://example.com")
343            .await
344            .expect("handle_get_inquiries should succeed");
345
346        assert_eq!(response.status_code, 200);
347        let response_body: serde_json::Value =
348            serde_json::from_str(&response.body).expect("response body should be valid JSON");
349        assert_eq!(response_body["email"], email);
350        assert_eq!(response_body["count"], 2);
351        let inquiries = response_body["inquiries"]
352            .as_array()
353            .expect("inquiries should be an array");
354        assert_eq!(inquiries.len(), 2);
355        assert_eq!(inquiries[0]["subject"], "newer subject");
356        assert_eq!(inquiries[1]["subject"], "older subject");
357        cleanup_test_inquiries(&db, &email).await;
358    }
359}