From d1bc5eb830148c12a4a5ee4160ed62d0d7ffa899 Mon Sep 17 00:00:00 2001 From: phoenix Date: Wed, 17 Jun 2026 17:51:33 -0400 Subject: [PATCH] Schedule message (#15) Reviewed-on: http://git.kundeng.us/phoenix/textsender_api/pulls/15 --- Cargo.lock | 6 +- Cargo.toml | 4 +- migrations/20260613211440_init.sql | 8 + src/caller/{message.rs => message/mod.rs} | 2 + src/caller/message/scheduling.rs | 104 +++++++++++++ src/caller/mod.rs | 2 + src/config/mod.rs | 17 ++- src/repo/mod.rs | 1 + src/repo/scheduling.rs | 27 ++++ tests/test.rs | 177 +++++++++++----------- 10 files changed, 256 insertions(+), 92 deletions(-) rename src/caller/{message.rs => message/mod.rs} (99%) create mode 100644 src/caller/message/scheduling.rs create mode 100644 src/repo/scheduling.rs diff --git a/Cargo.lock b/Cargo.lock index 993ac87..3989e6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2293,7 +2293,7 @@ dependencies = [ [[package]] name = "textsender_api" -version = "0.1.9" +version = "0.1.10" dependencies = [ "axum", "axum-extra", @@ -2320,8 +2320,8 @@ dependencies = [ [[package]] name = "textsender_models" -version = "0.4.0" -source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.4.0#2722af5589023db819554a97c1f596d9e735fe96" +version = "0.4.1" +source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.4.1-29-ad632fca16-111#ad632fca168ec817e61dd364716f8030c9cfb0b3" dependencies = [ "const_format", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index 44b5dc4..c5beac2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "textsender_api" -version = "0.1.9" +version = "0.1.10" edition = "2024" rust-version = "1.96" @@ -22,7 +22,7 @@ jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } josekit = { version = "0.10.3" } utoipa = { version = "5.5.0", features = ["axum_extras"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } -textsender_models = { git = "ssh://git@git.kundeng.us/phoenix/textsender_models.git", tag = "v0.4.0" } +textsender_models = { git = "ssh://git@git.kundeng.us/phoenix/textsender_models.git", tag = "v0.4.1-29-ad632fca16-111" } [dev-dependencies] common-multipart-rfc7578 = { version = "0.7.0" } diff --git a/migrations/20260613211440_init.sql b/migrations/20260613211440_init.sql index caf9db4..48d8e47 100644 --- a/migrations/20260613211440_init.sql +++ b/migrations/20260613211440_init.sql @@ -16,3 +16,11 @@ CREATE TABLE IF NOT EXISTS "messages" ( content TEXT NOT NULL, user_id UUID NOT NULL ); + +CREATE TABLE IF NOT EXISTS "scheduled_messages" ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scheduled timestamptz NOT NULL, + created timestamptz DEFAULT now(), + status TEXT CHECK (status IN ('PENDING', 'READY', 'PROCESSING', 'DONE')), + user_id UUID NOT NULL +); diff --git a/src/caller/message.rs b/src/caller/message/mod.rs similarity index 99% rename from src/caller/message.rs rename to src/caller/message/mod.rs index 3e07c0a..3d28da6 100644 --- a/src/caller/message.rs +++ b/src/caller/message/mod.rs @@ -1,3 +1,5 @@ +pub mod scheduling; + pub mod request { use serde::{Deserialize, Serialize}; use utoipa::ToSchema; diff --git a/src/caller/message/scheduling.rs b/src/caller/message/scheduling.rs new file mode 100644 index 0000000..de9f85e --- /dev/null +++ b/src/caller/message/scheduling.rs @@ -0,0 +1,104 @@ +pub mod request { + use serde::{Deserialize, Serialize}; + use utoipa::ToSchema; + + #[derive(Debug, Deserialize, Serialize, ToSchema)] + pub struct ScheduleMessageRequest { + #[serde(with = "time::serde::rfc3339::option")] + pub scheduled: Option, + pub status: String, + pub user_id: uuid::Uuid, + } + + impl ScheduleMessageRequest { + pub fn is_valid(&self) -> bool { + self.scheduled.is_some() || !self.status.is_empty() || !self.user_id.is_nil() + } + } +} + +pub mod response { + use serde::{Deserialize, Serialize}; + use utoipa::ToSchema; + + #[derive(Debug, Default, Deserialize, Serialize, ToSchema)] + pub struct ScheduleMessageResponse { + pub message: String, + pub data: Vec, + } +} + +pub mod endpoint { + use crate::repo::scheduling as scheduling_repo; + + /// Endpoint to create a Scheudled Message + #[utoipa::path( + post, + path = crate::caller::endpoints::SCHEDULE_MESSAGE, + request_body( + content = super::request::ScheduleMessageRequest, + description = "Data needed to create a Scheduled Message", + content_type = "application/json" + ), + responses( + (status = 201, description = "Scheduled Message created", body = super::response::ScheduleMessageResponse), + (status = 400, description = "Error", body = super::response::ScheduleMessageResponse), + (status = 500, description = "Error creating Scheduled Message", body = super::response::ScheduleMessageResponse) + ) +)] + pub async fn schedule_message( + axum::Extension(pool): axum::Extension, + axum::Json(payload): axum::Json, + ) -> ( + axum::http::StatusCode, + axum::Json, + ) { + let mut response = super::response::ScheduleMessageResponse::default(); + + if payload.is_valid() { + if payload.status != textsender_models::message::scheduling::PENDING { + response.message = String::from("scheduled message must be pending"); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } else { + if can_schedule(&payload.scheduled) { + let mut scheduled_message = + textsender_models::message::scheduling::ScheduledMessage { + scheduled: payload.scheduled, + status: payload.status, + ..Default::default() + }; + match scheduling_repo::insert(&pool, &scheduled_message, &payload.user_id).await + { + Ok((id, created)) => { + scheduled_message.id = id; + scheduled_message.created = Some(created); + scheduled_message.user_id = payload.user_id; + response.message = String::from("Message scheduled"); + response.data.push(scheduled_message); + (axum::http::StatusCode::CREATED, axum::Json(response)) + } + Err(err) => { + eprintln!("Error: {err:?}"); + response.message = String::from("Error inserting"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(response), + ) + } + } + } else { + response.message = String::from("Cannot schedule message"); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + } + } else { + response.message = String::from("Request body is not valid"); + (axum::http::StatusCode::BAD_REQUEST, axum::Json(response)) + } + } + + fn can_schedule(scheduled_time: &Option) -> bool { + scheduled_time + .is_some_and(|t| t - time::OffsetDateTime::now_utc() >= time::Duration::minutes(10)) + } +} diff --git a/src/caller/mod.rs b/src/caller/mod.rs index 3a9e62a..d436f86 100644 --- a/src/caller/mod.rs +++ b/src/caller/mod.rs @@ -14,6 +14,8 @@ pub mod endpoints { pub const ADD_MESSAGE: &str = "/api/v1/message/new"; /// Constant for getting messages endpoint pub const GET_MESSAGE: &str = "/api/v1/message"; + /// Constant for scheduling message endpoint + pub const SCHEDULE_MESSAGE: &str = "/api/v1/schedule/message"; } pub mod response { diff --git a/src/config/mod.rs b/src/config/mod.rs index 111e1b0..7199ff2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,10 +16,14 @@ pub mod init { use crate::caller::contact as contact_caller; use crate::caller::message as message_caller; + use crate::caller::message::scheduling as scheduling_caller; + use contact_caller::endpoint as contact_endpoints; use contact_caller::response as contact_responses; use message_caller::endpoint as message_endpoints; use message_caller::response as message_responses; + use scheduling_caller::endpoint as scheduling_endpoints; + use scheduling_caller::response as scheduling_responses; mod cors { pub async fn configure_cors() -> tower_http::cors::CorsLayer { @@ -77,8 +81,11 @@ pub mod init { #[derive(utoipa::OpenApi)] #[openapi( paths(contact_endpoints::create_contact, - message_endpoints::create_message), - components(schemas(contact_responses::AddContactResponse, message_responses::AddMessageResponse)), + message_endpoints::create_message, + scheduling_endpoints::schedule_message, + ), + components(schemas(contact_responses::AddContactResponse, message_responses::AddMessageResponse, + scheduling_responses::ScheduleMessageResponse)), tags( (name = "textsender API", description = "Web API to manage texting") ) @@ -117,6 +124,12 @@ pub mod init { crate::auth::auth::, )), ) + .route( + crate::caller::endpoints::SCHEDULE_MESSAGE, + post(scheduling_endpoints::schedule_message).route_layer( + axum::middleware::from_fn(crate::auth::auth::), + ), + ) .layer(cors::configure_cors().await) } diff --git a/src/repo/mod.rs b/src/repo/mod.rs index 2af9758..d185221 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -1,2 +1,3 @@ pub mod contact; pub mod message; +pub mod scheduling; diff --git a/src/repo/scheduling.rs b/src/repo/scheduling.rs new file mode 100644 index 0000000..b03e93c --- /dev/null +++ b/src/repo/scheduling.rs @@ -0,0 +1,27 @@ +use sqlx::Row; + +pub async fn insert( + pool: &sqlx::PgPool, + scheduled_message: &textsender_models::message::scheduling::ScheduledMessage, + user_id: &uuid::Uuid, +) -> Result<(uuid::Uuid, time::OffsetDateTime), sqlx::Error> { + match sqlx::query( + r#" + INSERT INTO "scheduled_messages" (scheduled, status, user_id) + VALUES($1, $2, $3) RETURNING id, created; + "#, + ) + .bind(scheduled_message.scheduled) + .bind(&scheduled_message.status) + .bind(user_id) + .fetch_one(pool) + .await + { + Ok(row) => { + let id: uuid::Uuid = row.try_get("id")?; + let created: time::OffsetDateTime = row.try_get("created")?; + Ok((id, created)) + } + Err(_) => Err(sqlx::Error::RowNotFound), + } +} diff --git a/tests/test.rs b/tests/test.rs index 6bb0b09..90e8749 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,8 +1,6 @@ // use std::io::Write; // use common_multipart_rfc7578::client::multipart::{Body as MultipartBody, Form as MultipartForm}; -// use tower::ServiceExt; - use textsender_api::db; mod db_mgr { @@ -43,6 +41,29 @@ mod db_mgr { } } + pub async fn get_database_ready() -> ( + sqlx::Pool, + String, + sqlx::Pool, + ) { + let tm_pool = get_pool().await.unwrap(); + let db_name = generate_db_name().await; + + match create_database(&tm_pool, &db_name).await { + Ok(_) => { + println!("Success"); + } + Err(err) => { + assert!(false, "Error: {:?}", err); + } + } + + let pool = connect_to_db(&db_name).await.unwrap(); + migrations(&pool).await; + + (tm_pool, db_name, pool) + } + // Function to drop a database pub async fn drop_database( template_pool: &sqlx::PgPool, @@ -73,16 +94,9 @@ mod db_mgr { } } - /* pub async fn migrations(pool: &sqlx::PgPool) { - // Run migrations using the sqlx::migrate! macro - // Assumes your test migrations are in a ./test_migrations folder relative to Cargo.toml - sqlx::migrate!("./migrations") - .run(pool) - .await - .expect("Failed to run migrations"); + super::db::migrations(pool).await; } - */ } mod init { @@ -301,6 +315,46 @@ mod request { Err(err) => Err(err), } } + + pub mod scheduling { + use time::format_description::well_known::Iso8601; + use tower::ServiceExt; + + pub async fn create_scheduled_message( + app: &axum::Router, + ) -> Result { + let now = time::OffsetDateTime::now_utc(); + let scheduled = now + std::time::Duration::from_secs(60 * 60); + let scheduled = match scheduled.format(&Iso8601::DEFAULT) { + Ok(t) => t, + Err(err) => { + assert!(false, "Error: {err:?}"); + String::new() + } + }; + + let payload = serde_json::json!({ + "scheduled": scheduled, + "status": textsender_models::message::scheduling::PENDING, + "user_id": super::super::super::TEST_USER_ID, + }); + + match super::super::run_post( + Some(&payload), + textsender_api::caller::endpoints::SCHEDULE_MESSAGE, + axum::http::Method::POST, + true, + ) + .await + { + Ok(req) => match app.clone().oneshot(req).await { + Ok(response) => Ok(response), + Err(err) => Err(axum::http::Error::from(err)), + }, + Err(err) => Err(err), + } + } + } } pub async fn run_post( @@ -337,21 +391,7 @@ mod request { #[tokio::test] async fn test_create_contact() { - let tm_pool = db_mgr::get_pool().await.unwrap(); - let db_name = db_mgr::generate_db_name().await; - - match db_mgr::create_database(&tm_pool, &db_name).await { - Ok(_) => { - println!("Success"); - } - Err(err) => { - assert!(false, "Error: {:?}", err); - } - } - - let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); - db::migrations(&pool).await; - + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; let app = init::app(pool).await; // Send request @@ -373,21 +413,7 @@ async fn test_create_contact() { #[tokio::test] async fn test_get_contact() { - let tm_pool = db_mgr::get_pool().await.unwrap(); - let db_name = db_mgr::generate_db_name().await; - - match db_mgr::create_database(&tm_pool, &db_name).await { - Ok(_) => { - println!("Success"); - } - Err(err) => { - assert!(false, "Error: {:?}", err); - } - } - - let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); - db::migrations(&pool).await; - + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; let app = init::app(pool).await; // Send request @@ -428,21 +454,7 @@ async fn test_get_contact() { #[tokio::test] async fn test_update_contact_names() { - let tm_pool = db_mgr::get_pool().await.unwrap(); - let db_name = db_mgr::generate_db_name().await; - - match db_mgr::create_database(&tm_pool, &db_name).await { - Ok(_) => { - println!("Success"); - } - Err(err) => { - assert!(false, "Error: {:?}", err); - } - } - - let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); - db::migrations(&pool).await; - + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; let app = init::app(pool).await; let mut opt_contact: Option = None; @@ -519,21 +531,7 @@ async fn test_update_contact_names() { #[tokio::test] async fn test_create_message() { - let tm_pool = db_mgr::get_pool().await.unwrap(); - let db_name = db_mgr::generate_db_name().await; - - match db_mgr::create_database(&tm_pool, &db_name).await { - Ok(_) => { - println!("Success"); - } - Err(err) => { - assert!(false, "Error: {:?}", err); - } - } - - let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); - db::migrations(&pool).await; - + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; let app = init::app(pool).await; // Send request @@ -560,21 +558,7 @@ async fn test_create_message() { #[tokio::test] async fn test_get_message() { - let tm_pool = db_mgr::get_pool().await.unwrap(); - let db_name = db_mgr::generate_db_name().await; - - match db_mgr::create_database(&tm_pool, &db_name).await { - Ok(_) => { - println!("Success"); - } - Err(err) => { - assert!(false, "Error: {:?}", err); - } - } - - let pool = db_mgr::connect_to_db(&db_name).await.unwrap(); - db::migrations(&pool).await; - + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; let app = init::app(pool).await; let mut message_result: Option = None; @@ -617,3 +601,26 @@ async fn test_get_message() { let _ = db_mgr::drop_database(&tm_pool, &db_name).await; } + +#[tokio::test] +async fn test_schedule_message() { + let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await; + let app = init::app(pool).await; + + match request::message::scheduling::create_scheduled_message(&app).await { + Ok(response) => { + eprintln!("Response: {response:?}"); + println!("Response: {response:?}"); + let resp = util::get_resp_data::< + textsender_api::caller::message::scheduling::response::ScheduleMessageResponse, + >(response) + .await; + assert_eq!(false, resp.data.is_empty(), "Should not be empty"); + } + Err(err) => { + assert!(false, "Error: {err:?}"); + } + } + + let _ = db_mgr::drop_database(&tm_pool, &db_name).await; +}