Schedule message #15

Merged
phoenix merged 19 commits from schedule_message into alot_of_changes 2026-06-17 17:51:34 -04:00
10 changed files with 256 additions and 92 deletions
Generated
+3 -3
View File
@@ -2293,7 +2293,7 @@ dependencies = [
[[package]] [[package]]
name = "textsender_api" name = "textsender_api"
version = "0.1.9" version = "0.1.10"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
@@ -2320,8 +2320,8 @@ dependencies = [
[[package]] [[package]]
name = "textsender_models" name = "textsender_models"
version = "0.4.0" version = "0.4.1"
source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.4.0#2722af5589023db819554a97c1f596d9e735fe96" source = "git+ssh://git@git.kundeng.us/phoenix/textsender_models.git?tag=v0.4.1-29-ad632fca16-111#ad632fca168ec817e61dd364716f8030c9cfb0b3"
dependencies = [ dependencies = [
"const_format", "const_format",
"dotenvy", "dotenvy",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "textsender_api" name = "textsender_api"
version = "0.1.9" version = "0.1.10"
edition = "2024" edition = "2024"
rust-version = "1.96" rust-version = "1.96"
@@ -22,7 +22,7 @@ jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] }
josekit = { version = "0.10.3" } josekit = { version = "0.10.3" }
utoipa = { version = "5.5.0", features = ["axum_extras"] } utoipa = { version = "5.5.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } 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] [dev-dependencies]
common-multipart-rfc7578 = { version = "0.7.0" } common-multipart-rfc7578 = { version = "0.7.0" }
+8
View File
@@ -16,3 +16,11 @@ CREATE TABLE IF NOT EXISTS "messages" (
content TEXT NOT NULL, content TEXT NOT NULL,
user_id UUID 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
);
@@ -1,3 +1,5 @@
pub mod scheduling;
pub mod request { pub mod request {
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
+104
View File
@@ -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<time::OffsetDateTime>,
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<textsender_models::message::scheduling::ScheduledMessage>,
}
}
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<sqlx::PgPool>,
axum::Json(payload): axum::Json<super::request::ScheduleMessageRequest>,
) -> (
axum::http::StatusCode,
axum::Json<super::response::ScheduleMessageResponse>,
) {
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<time::OffsetDateTime>) -> bool {
scheduled_time
.is_some_and(|t| t - time::OffsetDateTime::now_utc() >= time::Duration::minutes(10))
}
}
+2
View File
@@ -14,6 +14,8 @@ pub mod endpoints {
pub const ADD_MESSAGE: &str = "/api/v1/message/new"; pub const ADD_MESSAGE: &str = "/api/v1/message/new";
/// Constant for getting messages endpoint /// Constant for getting messages endpoint
pub const GET_MESSAGE: &str = "/api/v1/message"; 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 { pub mod response {
+15 -2
View File
@@ -16,10 +16,14 @@ pub mod init {
use crate::caller::contact as contact_caller; use crate::caller::contact as contact_caller;
use crate::caller::message as message_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::endpoint as contact_endpoints;
use contact_caller::response as contact_responses; use contact_caller::response as contact_responses;
use message_caller::endpoint as message_endpoints; use message_caller::endpoint as message_endpoints;
use message_caller::response as message_responses; use message_caller::response as message_responses;
use scheduling_caller::endpoint as scheduling_endpoints;
use scheduling_caller::response as scheduling_responses;
mod cors { mod cors {
pub async fn configure_cors() -> tower_http::cors::CorsLayer { pub async fn configure_cors() -> tower_http::cors::CorsLayer {
@@ -77,8 +81,11 @@ pub mod init {
#[derive(utoipa::OpenApi)] #[derive(utoipa::OpenApi)]
#[openapi( #[openapi(
paths(contact_endpoints::create_contact, paths(contact_endpoints::create_contact,
message_endpoints::create_message), message_endpoints::create_message,
components(schemas(contact_responses::AddContactResponse, message_responses::AddMessageResponse)), scheduling_endpoints::schedule_message,
),
components(schemas(contact_responses::AddContactResponse, message_responses::AddMessageResponse,
scheduling_responses::ScheduleMessageResponse)),
tags( tags(
(name = "textsender API", description = "Web API to manage texting") (name = "textsender API", description = "Web API to manage texting")
) )
@@ -117,6 +124,12 @@ pub mod init {
crate::auth::auth::<axum::body::Body>, crate::auth::auth::<axum::body::Body>,
)), )),
) )
.route(
crate::caller::endpoints::SCHEDULE_MESSAGE,
post(scheduling_endpoints::schedule_message).route_layer(
axum::middleware::from_fn(crate::auth::auth::<axum::body::Body>),
),
)
.layer(cors::configure_cors().await) .layer(cors::configure_cors().await)
} }
+1
View File
@@ -1,2 +1,3 @@
pub mod contact; pub mod contact;
pub mod message; pub mod message;
pub mod scheduling;
+27
View File
@@ -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),
}
}
+92 -85
View File
@@ -1,8 +1,6 @@
// use std::io::Write; // use std::io::Write;
// use common_multipart_rfc7578::client::multipart::{Body as MultipartBody, Form as MultipartForm}; // use common_multipart_rfc7578::client::multipart::{Body as MultipartBody, Form as MultipartForm};
// use tower::ServiceExt;
use textsender_api::db; use textsender_api::db;
mod db_mgr { mod db_mgr {
@@ -43,6 +41,29 @@ mod db_mgr {
} }
} }
pub async fn get_database_ready() -> (
sqlx::Pool<sqlx::Postgres>,
String,
sqlx::Pool<sqlx::Postgres>,
) {
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 // Function to drop a database
pub async fn drop_database( pub async fn drop_database(
template_pool: &sqlx::PgPool, template_pool: &sqlx::PgPool,
@@ -73,16 +94,9 @@ mod db_mgr {
} }
} }
/*
pub async fn migrations(pool: &sqlx::PgPool) { pub async fn migrations(pool: &sqlx::PgPool) {
// Run migrations using the sqlx::migrate! macro super::db::migrations(pool).await;
// 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");
} }
*/
} }
mod init { mod init {
@@ -301,6 +315,46 @@ mod request {
Err(err) => Err(err), 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<axum::response::Response, axum::http::Error> {
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( pub async fn run_post(
@@ -337,21 +391,7 @@ mod request {
#[tokio::test] #[tokio::test]
async fn test_create_contact() { async fn test_create_contact() {
let tm_pool = db_mgr::get_pool().await.unwrap(); let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await;
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 app = init::app(pool).await; let app = init::app(pool).await;
// Send request // Send request
@@ -373,21 +413,7 @@ async fn test_create_contact() {
#[tokio::test] #[tokio::test]
async fn test_get_contact() { async fn test_get_contact() {
let tm_pool = db_mgr::get_pool().await.unwrap(); let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await;
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 app = init::app(pool).await; let app = init::app(pool).await;
// Send request // Send request
@@ -428,21 +454,7 @@ async fn test_get_contact() {
#[tokio::test] #[tokio::test]
async fn test_update_contact_names() { async fn test_update_contact_names() {
let tm_pool = db_mgr::get_pool().await.unwrap(); let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await;
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 app = init::app(pool).await; let app = init::app(pool).await;
let mut opt_contact: Option<textsender_models::contact::Contact> = None; let mut opt_contact: Option<textsender_models::contact::Contact> = None;
@@ -519,21 +531,7 @@ async fn test_update_contact_names() {
#[tokio::test] #[tokio::test]
async fn test_create_message() { async fn test_create_message() {
let tm_pool = db_mgr::get_pool().await.unwrap(); let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await;
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 app = init::app(pool).await; let app = init::app(pool).await;
// Send request // Send request
@@ -560,21 +558,7 @@ async fn test_create_message() {
#[tokio::test] #[tokio::test]
async fn test_get_message() { async fn test_get_message() {
let tm_pool = db_mgr::get_pool().await.unwrap(); let (tm_pool, db_name, pool) = db_mgr::get_database_ready().await;
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 app = init::app(pool).await; let app = init::app(pool).await;
let mut message_result: Option<textsender_models::message::Message> = None; let mut message_result: Option<textsender_models::message::Message> = None;
@@ -617,3 +601,26 @@ async fn test_get_message() {
let _ = db_mgr::drop_database(&tm_pool, &db_name).await; 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;
}