-
Notifications
You must be signed in to change notification settings - Fork 32
feat: Verify Subscriber #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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.
| 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, | ||
| } | ||
|
|
||
| #[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>, | ||
| } | ||
| 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), | ||
| ) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π |
||
| 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(()) | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 π |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good π