Compute dashboard stats
Some checks failed
Rust / Test (push) Has been cancelled
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled

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.
This commit is contained in:
Alphonse Paix
2025-09-24 04:30:27 +02:00
parent 33281132c6
commit 4cb1d2b6fd
19 changed files with 303 additions and 60 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1 @@
ALTER TABLE issue_delivery_queue ADD COLUMN kind TEXT NOT NULL;

View File

@@ -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
);

View File

@@ -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<ExecutionOutcome, anyhow::Error> {
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 {

View File

@@ -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<AppState>,
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
) -> Response {
) -> Result<Response, AppError> {
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<DashboardStats, anyhow::Error> {
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,
})
}

View File

@@ -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.")?;

View File

@@ -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.")?;

View File

@@ -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<i64, sqlx::Err
.map(|r| r.count.unwrap())
}
#[derive(serde::Deserialize)]
pub struct PostParams {
origin: Option<Uuid>,
}
pub async fn see_post(
State(AppState {
connection_pool, ..
}): State<AppState>,
Path(post_id): Path<Uuid>,
Query(PostParams { origin }): Query<PostParams>,
) -> Result<Response, AppError> {
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))

View File

@@ -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.

View File

@@ -34,8 +34,24 @@
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Total subscribers</p>
<p class="text-2xl font-semibold text-gray-900">2,143</p>
<p class="text-sm font-medium text-gray-500">Subscribers</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.subscribers }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-purple-600 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Posts</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.posts }}</p>
</div>
</div>
</div>
@@ -50,8 +66,8 @@
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Issues sent</p>
<p class="text-2xl font-semibold text-gray-900">23</p>
<p class="text-sm font-medium text-gray-500">Notifications</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.notifications_sent }}</p>
</div>
</div>
</div>
@@ -68,23 +84,7 @@
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Open rate</p>
<p class="text-2xl font-semibold text-gray-900">68%</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
<div class="flex items-center">
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center shrink-0">
<svg class="w-6 h-6 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Growth</p>
<p class="text-2xl font-semibold text-gray-900">+12%</p>
<p class="text-2xl font-semibold text-gray-900">{{ stats.formatted_rate() }}</p>
</div>
</div>
</div>

View File

@@ -7,7 +7,8 @@
<h2 class="post-title">{{ post_title }}</h2>
{% if !post_excerpt.is_empty() %}<p class="post-excerpt">{{ post_excerpt }}</p>{% endif %}
</div>
<a href="{{ base_url }}/posts/{{ post_id }}" class="cta-button">Read the full post →</a>
<a href="{{ base_url }}/posts/{{ post_id }}?origin=EMAIL_ID"
class="cta-button">Read the full post →</a>
<p>
This post covers practical insights and real-world examples that I hope will be valuable for your backend development journey.
</p>