diff --git a/.sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json b/.sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json new file mode 100644 index 0000000..568cabf --- /dev/null +++ b/.sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM notifications_delivered WHERE opened = TRUE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca" +} diff --git a/.sqlx/query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json b/.sqlx/query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json similarity index 63% rename from .sqlx/query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json rename to .sqlx/query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json index 91d10a6..39d5ab9 100644 --- a/.sqlx/query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json +++ b/.sqlx/query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ", + "query": "\n SELECT newsletter_issue_id, subscriber_email, unsubscribe_token, kind\n FROM issue_delivery_queue\n FOR UPDATE\n SKIP LOCKED\n LIMIT 1\n ", "describe": { "columns": [ { @@ -17,16 +17,22 @@ "ordinal": 2, "name": "unsubscribe_token", "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" } ], "parameters": { "Left": [] }, "nullable": [ + false, false, false, false ] }, - "hash": "6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048" + "hash": "3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f" } diff --git a/.sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json b/.sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json new file mode 100644 index 0000000..886700c --- /dev/null +++ b/.sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM notifications_delivered", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21" +} diff --git a/.sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json b/.sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json new file mode 100644 index 0000000..be1a08e --- /dev/null +++ b/.sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token,\n kind\n )\n SELECT $1, email, unsubscribe_token, $2\n FROM subscriptions\n WHERE status = 'confirmed'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0" +} diff --git a/.sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json b/.sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json new file mode 100644 index 0000000..1102c15 --- /dev/null +++ b/.sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT count(*) FROM subscriptions WHERE status = 'confirmed'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6" +} diff --git a/.sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json b/.sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json new file mode 100644 index 0000000..b1949e0 --- /dev/null +++ b/.sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d" +} diff --git a/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json b/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json deleted file mode 100644 index 59a6f35..0000000 --- a/.sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO issue_delivery_queue (\n newsletter_issue_id,\n subscriber_email,\n unsubscribe_token\n )\n SELECT $1, email, unsubscribe_token\n FROM subscriptions\n WHERE status = 'confirmed'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944" -} diff --git a/.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json b/.sqlx/query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json similarity index 57% rename from .sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json rename to .sqlx/query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json index 193d3c4..76ec8cb 100644 --- a/.sqlx/query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json +++ b/.sqlx/query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json @@ -1,20 +1,25 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT title, text_content, html_content\n FROM newsletter_issues\n WHERE newsletter_issue_id = $1\n ", + "query": "\n SELECT newsletter_issue_id, title, text_content, html_content\n FROM newsletter_issues\n WHERE newsletter_issue_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, + "name": "newsletter_issue_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, "name": "title", "type_info": "Text" }, { - "ordinal": 1, + "ordinal": 2, "name": "text_content", "type_info": "Text" }, { - "ordinal": 2, + "ordinal": 3, "name": "html_content", "type_info": "Text" } @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, false, false ] }, - "hash": "43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46" + "hash": "f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f" } diff --git a/.sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json b/.sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json new file mode 100644 index 0000000..275d278 --- /dev/null +++ b/.sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO notifications_delivered (email_id, newsletter_issue_id)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385" +} diff --git a/migrations/20250924010147_add_kind_to_issue_delivery_queue.sql b/migrations/20250924010147_add_kind_to_issue_delivery_queue.sql new file mode 100644 index 0000000..745abad --- /dev/null +++ b/migrations/20250924010147_add_kind_to_issue_delivery_queue.sql @@ -0,0 +1 @@ +ALTER TABLE issue_delivery_queue ADD COLUMN kind TEXT NOT NULL; diff --git a/migrations/20250924011112_create_notifications_delivered_table.sql b/migrations/20250924011112_create_notifications_delivered_table.sql new file mode 100644 index 0000000..a227dac --- /dev/null +++ b/migrations/20250924011112_create_notifications_delivered_table.sql @@ -0,0 +1,7 @@ +CREATE TABLE notifications_delivered ( + email_id UUID PRIMARY KEY, + newsletter_issue_id UUID NOT NULL + REFERENCES newsletter_issues (newsletter_issue_id), + delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + opened BOOLEAN NOT NULL DEFAULT FALSE +); diff --git a/src/issue_delivery_worker.rs b/src/issue_delivery_worker.rs index e90bfa3..fba0783 100644 --- a/src/issue_delivery_worker.rs +++ b/src/issue_delivery_worker.rs @@ -1,4 +1,7 @@ -use crate::{configuration::Settings, domain::SubscriberEmail, email_client::EmailClient}; +use crate::{ + configuration::Settings, domain::SubscriberEmail, email_client::EmailClient, routes::EmailType, +}; +use anyhow::Context; use sqlx::{Executor, PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions}; use std::time::Duration; use tracing::{Span, field::display}; @@ -41,10 +44,10 @@ pub async fn try_execute_task( email_client: &EmailClient, ) -> Result { let task = dequeue_task(connection_pool).await?; - if task.is_none() { - return Ok(ExecutionOutcome::EmptyQueue); - } - let (transaction, task) = task.unwrap(); + let (mut transaction, task) = match task { + Some((transaction, task)) => (transaction, task), + None => return Ok(ExecutionOutcome::EmptyQueue), + }; Span::current() .record("newsletter_issue_id", display(task.newsletter_issue_id)) .record("subscriber_email", display(&task.subscriber_email)); @@ -52,6 +55,9 @@ pub async fn try_execute_task( Ok(email) => { let mut issue = get_issue(connection_pool, task.newsletter_issue_id).await?; issue.inject_unsubscribe_token(&task.unsubscribe_token); + if task.kind == EmailType::NewPost.to_string() { + issue.create_tracking_info(&mut transaction).await?; + } if let Err(e) = email_client .send_email( &email, @@ -85,6 +91,7 @@ pub async fn try_execute_task( } struct NewsletterIssue { + newsletter_issue_id: Uuid, title: String, text_content: String, html_content: String, @@ -95,6 +102,28 @@ impl NewsletterIssue { self.text_content = self.text_content.replace("UNSUBSCRIBE_TOKEN", token); self.html_content = self.html_content.replace("UNSUBSCRIBE_TOKEN", token); } + + async fn create_tracking_info( + &mut self, + transaction: &mut Transaction<'static, Postgres>, + ) -> Result<(), anyhow::Error> { + let email_id = Uuid::new_v4(); + let query = sqlx::query!( + r#" + INSERT INTO notifications_delivered (email_id, newsletter_issue_id) + VALUES ($1, $2) + "#, + email_id, + self.newsletter_issue_id + ); + transaction + .execute(query) + .await + .context("Failed to store email tracking info.")?; + self.text_content = self.text_content.replace("EMAIL_ID", &email_id.to_string()); + self.html_content = self.html_content.replace("EMAIL_ID", &email_id.to_string()); + Ok(()) + } } #[tracing::instrument(skip_all)] @@ -105,7 +134,7 @@ async fn get_issue( let issue = sqlx::query_as!( NewsletterIssue, r#" - SELECT title, text_content, html_content + SELECT newsletter_issue_id, title, text_content, html_content FROM newsletter_issues WHERE newsletter_issue_id = $1 "#, @@ -120,6 +149,7 @@ pub struct Task { pub newsletter_issue_id: Uuid, pub subscriber_email: String, pub unsubscribe_token: String, + pub kind: String, } #[tracing::instrument(skip_all)] @@ -129,7 +159,7 @@ async fn dequeue_task( let mut transaction = connection_pool.begin().await?; let query = sqlx::query!( r#" - SELECT newsletter_issue_id, subscriber_email, unsubscribe_token + SELECT newsletter_issue_id, subscriber_email, unsubscribe_token, kind FROM issue_delivery_queue FOR UPDATE SKIP LOCKED @@ -142,6 +172,7 @@ async fn dequeue_task( newsletter_issue_id: row.get("newsletter_issue_id"), subscriber_email: row.get("subscriber_email"), unsubscribe_token: row.get("unsubscribe_token"), + kind: row.get("kind"), }; Ok(Some((transaction, task))) } else { diff --git a/src/routes/admin/dashboard.rs b/src/routes/admin/dashboard.rs index 447740d..a7d292f 100644 --- a/src/routes/admin/dashboard.rs +++ b/src/routes/admin/dashboard.rs @@ -1,20 +1,84 @@ -use crate::{authentication::AuthenticatedUser, templates::DashboardTemplate}; +use crate::{ + authentication::AuthenticatedUser, routes::AppError, startup::AppState, + templates::DashboardTemplate, +}; +use anyhow::Context; use askama::Template; use axum::{ Extension, + extract::State, response::{Html, IntoResponse, Response}, }; use uuid::Uuid; +pub struct DashboardStats { + pub subscribers: i64, + pub posts: i64, + pub notifications_sent: i64, + pub open_rate: f64, +} + +impl DashboardStats { + pub fn formatted_rate(&self) -> String { + format!("{:.1}%", self.open_rate) + } +} + pub async fn admin_dashboard( + State(AppState { + connection_pool, .. + }): State, Extension(AuthenticatedUser { username, .. }): Extension, -) -> Response { +) -> Result { + let stats = get_stats(&connection_pool).await?; let idempotency_key_1 = Uuid::new_v4().to_string(); let idempotency_key_2 = Uuid::new_v4().to_string(); let template = DashboardTemplate { username, idempotency_key_1, idempotency_key_2, + stats, }; - Html(template.render().unwrap()).into_response() + Ok(Html(template.render().unwrap()).into_response()) +} + +async fn get_stats(connection_pool: &sqlx::PgPool) -> Result { + let subscribers = + sqlx::query_scalar!("SELECT count(*) FROM subscriptions WHERE status = 'confirmed'") + .fetch_one(connection_pool) + .await + .context("Failed to fetch subscribers count.")? + .unwrap_or(0); + + let posts = sqlx::query_scalar!("SELECT count(*) FROM posts") + .fetch_one(connection_pool) + .await + .context("Failed to fetch posts count.")? + .unwrap_or(0); + + let notifications_sent = sqlx::query_scalar!("SELECT count(*) FROM notifications_delivered") + .fetch_one(connection_pool) + .await + .context("Failed to fetch notifications sent count.")? + .unwrap_or(0); + + let opened = + sqlx::query_scalar!("SELECT count(*) FROM notifications_delivered WHERE opened = TRUE") + .fetch_one(connection_pool) + .await + .context("Failed to fetch notifications sent count.")? + .unwrap_or(0); + + let open_rate = if notifications_sent == 0 { + 0.0 + } else { + (opened as f64) / (notifications_sent as f64) * 100.0 + }; + + Ok(DashboardStats { + subscribers, + posts, + notifications_sent, + open_rate, + }) } diff --git a/src/routes/admin/newsletters.rs b/src/routes/admin/newsletters.rs index ad589d6..ffbc1f8 100644 --- a/src/routes/admin/newsletters.rs +++ b/src/routes/admin/newsletters.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use crate::{ authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, @@ -46,23 +48,40 @@ pub async fn insert_newsletter_issue( Ok(newsletter_issue_id) } +pub enum EmailType { + NewPost, + Newsletter, +} + +impl Display for EmailType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EmailType::NewPost => write!(f, "new_post"), + EmailType::Newsletter => write!(f, "newsletter"), + } + } +} + #[tracing::instrument(skip_all)] pub async fn enqueue_delivery_tasks( transaction: &mut Transaction<'static, Postgres>, newsletter_issue_id: Uuid, + kind: EmailType, ) -> Result<(), sqlx::Error> { let query = sqlx::query!( r#" INSERT INTO issue_delivery_queue ( newsletter_issue_id, subscriber_email, - unsubscribe_token + unsubscribe_token, + kind ) - SELECT $1, email, unsubscribe_token + SELECT $1, email, unsubscribe_token, $2 FROM subscriptions WHERE status = 'confirmed' "#, newsletter_issue_id, + kind.to_string() ); transaction.execute(query).await?; Ok(()) @@ -102,7 +121,7 @@ pub async fn publish_newsletter( .await .context("Failed to store newsletter issue details.")?; - enqueue_delivery_tasks(&mut transaction, issue_id) + enqueue_delivery_tasks(&mut transaction, issue_id, EmailType::Newsletter) .await .context("Failed to enqueue delivery tasks.")?; diff --git a/src/routes/admin/posts.rs b/src/routes/admin/posts.rs index 3b7f687..1e12adc 100644 --- a/src/routes/admin/posts.rs +++ b/src/routes/admin/posts.rs @@ -1,7 +1,7 @@ use crate::{ authentication::AuthenticatedUser, idempotency::{IdempotencyKey, save_response, try_processing}, - routes::{AdminError, AppError, enqueue_delivery_tasks, insert_newsletter_issue}, + routes::{AdminError, AppError, EmailType, enqueue_delivery_tasks, insert_newsletter_issue}, startup::AppState, templates::{MessageTemplate, NewPostEmailTemplate}, }; @@ -63,7 +63,7 @@ pub async fn create_post( .await .context("Failed to create newsletter.")?; - enqueue_delivery_tasks(&mut transaction, newsletter_uuid) + enqueue_delivery_tasks(&mut transaction, newsletter_uuid, EmailType::NewPost) .await .context("Failed to enqueue delivery tasks.")?; diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 0302fa9..48d0eb6 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -8,7 +8,7 @@ use anyhow::Context; use askama::Template; use axum::{ extract::{Path, Query, State}, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, }; use sqlx::PgPool; use uuid::Uuid; @@ -62,12 +62,29 @@ async fn get_posts_table_size(connection_pool: &PgPool) -> Result, +} + pub async fn see_post( State(AppState { connection_pool, .. }): State, Path(post_id): Path, + Query(PostParams { origin }): Query, ) -> Result { + if let Some(origin) = origin { + sqlx::query!( + "UPDATE notifications_delivered SET opened = TRUE WHERE email_id = $1", + origin, + ) + .execute(&connection_pool) + .await + .context("Failed to mark email as opened.") + .map_err(AppError::unexpected_page)?; + return Ok(Redirect::to(&format!("/posts/{}", post_id)).into_response()); + } let post = get_post_data(&connection_pool, post_id) .await .context(format!("Failed to fetch post #{}", post_id)) diff --git a/src/templates.rs b/src/templates.rs index 3656d3b..7a28890 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,4 +1,4 @@ -use crate::domain::PostEntry; +use crate::{domain::PostEntry, routes::DashboardStats}; use askama::Template; use uuid::Uuid; @@ -24,6 +24,7 @@ pub struct DashboardTemplate { pub username: String, pub idempotency_key_1: String, pub idempotency_key_2: String, + pub stats: DashboardStats, } #[derive(Template)] @@ -102,7 +103,7 @@ I just published a new post that I think you'll find interesting: "{}" -Read the full post: {}/posts/{} +Read the full post: {}/posts/{}?origin=EMAIL_ID This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey. diff --git a/templates/dashboard.html b/templates/dashboard.html index cdd7d95..95eb351 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -34,8 +34,24 @@
-

Total subscribers

-

2,143

+

Subscribers

+

{{ stats.subscribers }}

+
+ + +
+
+
+ + + +
+
+

Posts

+

{{ stats.posts }}

@@ -50,8 +66,8 @@
-

Issues sent

-

23

+

Notifications

+

{{ stats.notifications_sent }}

@@ -68,23 +84,7 @@

Open rate

-

68%

-
- - -
-
-
- - - -
-
-

Growth

-

+12%

+

{{ stats.formatted_rate() }}

diff --git a/templates/email/new_post.html b/templates/email/new_post.html index f80b92d..73366d3 100644 --- a/templates/email/new_post.html +++ b/templates/email/new_post.html @@ -7,7 +7,8 @@

{{ post_title }}

{% if !post_excerpt.is_empty() %}

{{ post_excerpt }}

{% endif %} - Read the full post → + Read the full post →

This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.