From 4cb1d2b6fdfc1cbcf8564bdb6c09a753e33e74d7 Mon Sep 17 00:00:00 2001 From: Alphonse Paix Date: Wed, 24 Sep 2025 04:30:27 +0200 Subject: [PATCH] Compute dashboard stats Track open rate for new post notifications (user clicked the button in the link or not). No data about the user is collected during the process, it only uses an ID inserted by the issue delivery worker. --- ...a46f9a2598aa9c0618d00f6287978d5ce28ca.json | 20 ++++++ ...6f5cb1171a7e89c398e005ad41ee12aaf91f.json} | 10 ++- ...97e17d1fc934015d53754f9b0055c4701ee21.json | 20 ++++++ ...9b2bd3817582798027c00d59d43089531ecc0.json | 15 ++++ ...3425b2991237b8c823dd7c863e6dad002d4b6.json | 20 ++++++ ...2d06c32e318c7536918d5adbaf5eaf5777e3d.json | 14 ++++ ...d404043b35f44f639b0edde61ed9e1a7f2944.json | 14 ---- ...32637d6303b2c166ef89e7f725b309d2219f.json} | 14 ++-- ...7ee05281c9e7779f8fd6486780f882f46e385.json | 15 ++++ ...10147_add_kind_to_issue_delivery_queue.sql | 1 + ...2_create_notifications_delivered_table.sql | 7 ++ src/issue_delivery_worker.rs | 45 ++++++++++-- src/routes/admin/dashboard.rs | 70 ++++++++++++++++++- src/routes/admin/newsletters.rs | 25 ++++++- src/routes/admin/posts.rs | 4 +- src/routes/posts.rs | 19 ++++- src/templates.rs | 5 +- templates/dashboard.html | 42 +++++------ templates/email/new_post.html | 3 +- 19 files changed, 303 insertions(+), 60 deletions(-) create mode 100644 .sqlx/query-06f07a7522f3ee8e2cdfe5a7988a46f9a2598aa9c0618d00f6287978d5ce28ca.json rename .sqlx/{query-6d21a0dd6ef2ea03ce82248ceceab76bb486237ff8e4a2ccd4dbf2b73c496048.json => query-3b79eca713fe7e167578537399436f5cb1171a7e89c398e005ad41ee12aaf91f.json} (63%) create mode 100644 .sqlx/query-3f4aceeab03c1c7352d6bed39d397e17d1fc934015d53754f9b0055c4701ee21.json create mode 100644 .sqlx/query-5d9039a01feaca50218a1c791439b2bd3817582798027c00d59d43089531ecc0.json create mode 100644 .sqlx/query-95a6533f617e7bae589b00548c73425b2991237b8c823dd7c863e6dad002d4b6.json create mode 100644 .sqlx/query-9fc831553927814e21dd2aa4ff92d06c32e318c7536918d5adbaf5eaf5777e3d.json delete mode 100644 .sqlx/query-ca8fe28bbf395e1c62a495f7299d404043b35f44f639b0edde61ed9e1a7f2944.json rename .sqlx/{query-43116d4e670155129aa69a7563ddc3f7d01ef3689bb8de9ee1757b401ad95b46.json => query-f682b1791fb9871c5f7416711caf32637d6303b2c166ef89e7f725b309d2219f.json} (57%) create mode 100644 .sqlx/query-f8afa9b469bf8c216c5855e1d6b7ee05281c9e7779f8fd6486780f882f46e385.json create mode 100644 migrations/20250924010147_add_kind_to_issue_delivery_queue.sql create mode 100644 migrations/20250924011112_create_notifications_delivered_table.sql 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.