Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ pub struct AppState {
pub async fn serve(configuration: Arc<Configuration>, db: Db) -> anyhow::Result<()> {
let addr = configuration.listen_address;
let app_state = AppState { configuration, db };

let app = api_router(app_state);

tracing::info!("Listening for requests on {}", addr);
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app)
Expand Down Expand Up @@ -82,5 +80,8 @@ async fn shutdown_signal() {
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();

tokio::select! {_ = ctrl_c => {}, _ = terminate => {},}
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
43 changes: 43 additions & 0 deletions src/http/newsletter/domain.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,53 @@
use garde::Validate;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, PartialEq, sqlx::Type, serde::Serialize, serde::Deserialize)]
#[sqlx(type_name = "subscriber_status", rename_all = "lowercase")]
pub enum SubscriberStatus {
Pending,
Active,
Unsubscribed,
Bounced,
SpamComplaint,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good πŸ‘


#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
pub struct NewsletterSubscriber {
#[garde(skip)]
pub id: Uuid,
#[garde(email)]
pub email: String,
#[garde(length(min = 2, max = 255))]
pub name: String,
#[garde(skip)]
pub status: SubscriberStatus,
#[garde(skip)]
pub subscribed_at: Option<chrono::DateTime<chrono::Utc>>,
#[garde(skip)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[garde(skip)]
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct SubscribeNewsletterRequest {
#[garde(email)]
pub email: String,
#[garde(length(min = 2, max = 255))]
pub name: String,
}

#[derive(Debug, Deserialize, Validate)]
pub struct VerifySubscriberRequest {
#[garde(ascii, length(min = 1))]
pub token: String,
}

#[derive(Debug, Serialize)]
pub struct VerifySubscriberResponse {
pub message: String,
pub subscriber_id: Uuid,
pub email: String,
pub verified_at: chrono::DateTime<chrono::Utc>,
}
10 changes: 8 additions & 2 deletions src/http/newsletter/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
pub mod domain;
pub mod subscribe;
mod verify_subscriber;

use crate::AppState;
use axum::Router;
use axum::{Router, routing::post};

pub fn router() -> Router<AppState> {
Router::new().nest("/newsletter", subscribe::router())
Router::new()
.nest("/newsletter", subscribe::router())
.route(
"/newsletter/verify",
post(verify_subscriber::verify_subscriber),
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘

4 changes: 2 additions & 2 deletions src/http/newsletter/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use axum::{
};
use garde::Validate;

use crate::{AppState, Result, http::newsletter::domain::NewsletterSubscriber};
use crate::{AppState, Result, http::newsletter::domain::SubscribeNewsletterRequest};

pub fn router() -> Router<AppState> {
Router::new().route("/subscribe", post(subscribe_handler))
Expand All @@ -23,7 +23,7 @@ pub fn router() -> Router<AppState> {
)]
pub async fn subscribe_handler(
state: State<AppState>,
Json(req): Json<NewsletterSubscriber>,
Json(req): Json<SubscribeNewsletterRequest>,
) -> Result<impl IntoResponse> {
req.validate()?;

Expand Down
134 changes: 134 additions & 0 deletions src/http/newsletter/verify_subscriber.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use crate::{
AppState, Error, Result,
http::newsletter::domain::{
NewsletterSubscriber, SubscriberStatus, VerifySubscriberRequest, VerifySubscriberResponse,
},
};
use axum::{Json, extract::State};
use garde::Validate;
use sqlx::PgPool;
use uuid::Uuid;

#[tracing::instrument(name = "Verify Newsletter Subscriber", skip(state))]
pub async fn verify_subscriber(
State(state): State<AppState>,
Json(request): Json<VerifySubscriberRequest>,
) -> Result<Json<VerifySubscriberResponse>> {
request.validate()?;

tracing::info!(
token_length = request.token.len(),
"Attempting to verify newsletter subscriber"
);

// Find the subscriber by token
let subscriber = find_subscriber_by_token(&state.db.pool, &request.token).await?;

if subscriber.status == SubscriberStatus::Active {
tracing::warn!(
subscriber_id = %subscriber.id,
"Attempt to reverify already active subscriber"
);
return Err(Error::unprocessable_entity([(
"verification",
"Subscriber is already verified",
)]));
}

let verification_date = chrono::Utc::now();

match verify_subscriber_in_db(&state.db.pool, subscriber.id, verification_date).await {
Ok(_) => {
tracing::info!("Subscriber {} successfully verified", subscriber.id);
Ok(Json(VerifySubscriberResponse {
message: "Newsletter subscription successfully verified".to_string(),
subscriber_id: subscriber.id,
email: subscriber.email,
verified_at: verification_date,
}))
}
Err(e) => {
tracing::error!("Failed to verify subscriber {}: {}", subscriber.id, e);
Err(Error::unprocessable_entity([(
"verification",
"Failed to verify subscriber",
)]))
}
}
}

async fn find_subscriber_by_token(pool: &PgPool, token: &str) -> Result<NewsletterSubscriber> {
let result = sqlx::query!(
r#"
SELECT
ns.id,
ns.email,
ns.name,
ns.status as "status!: String",
ns.subscribed_at,
ns.created_at,
ns.updated_at
FROM newsletter_subscribers ns
INNER JOIN subscription_token st ON ns.id = st.subscriber_id
WHERE st.subscription_token = $1
"#,
token
)
.fetch_optional(pool)
.await?;

match result {
Some(row) => {
let status = match row.status.as_str() {
"pending" => SubscriberStatus::Pending,
"active" => SubscriberStatus::Active,
"unsubscribed" => SubscriberStatus::Unsubscribed,
"bounced" => SubscriberStatus::Bounced,
"spam_complaint" => SubscriberStatus::SpamComplaint,
_ => {
return Err(Error::unprocessable_entity([(
"status",
"Invalid subscriber status",
)]));
}
};

Ok(NewsletterSubscriber {
id: row.id,
email: row.email,
name: row.name,
status,
subscribed_at: row.subscribed_at,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
None => {
tracing::error!("No subscriber found for token");
Err(Error::NotFound)
}
}
}

async fn verify_subscriber_in_db(
pool: &PgPool,
subscriber_id: Uuid,
verification_date: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
sqlx::query!(
r#"
UPDATE newsletter_subscribers
SET
status = 'active',
subscribed_at = $2,
updated_at = $2
WHERE id = $1
"#,
subscriber_id,
verification_date
)
.execute(pool)
.await?;

Ok(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great Job with this implementation, the thought process, the modularization, everything, you did a great job πŸ‘

Loading