Compare commits

..

3 Commits

Author SHA1 Message Date
Alphonse Paix
be69a54fd1 queries
All checks were successful
Rust / Test (push) Successful in 5m51s
Rust / Rustfmt (push) Successful in 22s
Rust / Clippy (push) Successful in 1m38s
Rust / Code coverage (push) Successful in 5m6s
2025-10-11 00:06:08 +02:00
Alphonse Paix
90aa4f8185 Templates update
Some checks failed
Rust / Rustfmt (push) Has been cancelled
Rust / Clippy (push) Has been cancelled
Rust / Code coverage (push) Has been cancelled
Rust / Test (push) Has been cancelled
2025-10-11 00:02:05 +02:00
Alphonse Paix
5d5f9ec765 Database worker
Worker used to clean up pending subscriptions and old idempotency
records
2025-10-11 00:02:05 +02:00
17 changed files with 159 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, p.author_id, u.username AS author,\n p.title, p.content, p.published_at, last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ", "query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n WHERE p.post_id = $1\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -20,21 +20,26 @@
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "title", "name": "full_name",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "content", "name": "title",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "last_modified", "name": "last_modified",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
@@ -48,11 +53,12 @@
false, false,
false, false,
false, false,
true,
false, false,
false, false,
false, false,
true true
] ]
}, },
"hash": "ccffe61c27508d32cf43556a8bffa465f24fec8416a4884ead4eafd324feea72" "hash": "059162eba48cf5f519d0d8b6ce63575ced91941b8c55c986b8c5591c7d9b09e4"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM idempotency\n WHERE created_at < NOW() - INTERVAL '1 hour'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1e1a90042e89bd8662df3bae15bc7506146cff102034664c77ab0fc68b9480f5"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT p.author_id, u.username as author,\n p.post_id, p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ", "query": "\n SELECT p.author_id, u.username as author, u.full_name,\n p.post_id, p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -15,26 +15,31 @@
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "full_name",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "post_id", "name": "post_id",
"type_info": "Uuid" "type_info": "Uuid"
}, },
{ {
"ordinal": 3, "ordinal": 4,
"name": "title", "name": "title",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 5,
"name": "content", "name": "content",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 5, "ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "last_modified", "name": "last_modified",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
@@ -47,6 +52,7 @@
"nullable": [ "nullable": [
false, false,
false, false,
true,
false, false,
false, false,
false, false,
@@ -54,5 +60,5 @@
true true
] ]
}, },
"hash": "c545267390019d45c5b4b32caf6c46928ffc7bdac46828cf7f1104ef67f42391" "hash": "1fc92c14786c21d24951341e3a8149964533b7627d2d073eeac7b7d3230513ce"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM subscriptions\n WHERE status = 'pending_confirmation'\n AND subscribed_at < NOW() - INTERVAL '24 hours'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "7eccf0027753bc1c42897aef12c9350eca023f3be52e24530127d06c3c449104"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT p.post_id, p.author_id, u.username AS author,\n p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ", "query": "\n SELECT p.post_id, p.author_id, u.username AS author, u.full_name,\n p.title, p.content, p.published_at, p.last_modified\n FROM posts p\n LEFT JOIN users u ON p.author_id = u.user_id\n ORDER BY p.published_at DESC\n LIMIT $1\n OFFSET $2\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -20,21 +20,26 @@
}, },
{ {
"ordinal": 3, "ordinal": 3,
"name": "title", "name": "full_name",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "content", "name": "title",
"type_info": "Text" "type_info": "Text"
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "content",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "published_at", "name": "published_at",
"type_info": "Timestamptz" "type_info": "Timestamptz"
}, },
{ {
"ordinal": 6, "ordinal": 7,
"name": "last_modified", "name": "last_modified",
"type_info": "Timestamptz" "type_info": "Timestamptz"
} }
@@ -49,11 +54,12 @@
false, false,
false, false,
false, false,
true,
false, false,
false, false,
false, false,
true true
] ]
}, },
"hash": "836bd296bffff9a2ec14e43ea6aa64a468aaf0914bd95297431320621b42e396" "hash": "dc3c1b786b4f4bd65f625922ce05eab4cb161f3de6c6e676af778f7749af5710"
} }

58
src/database_worker.rs Normal file
View File

@@ -0,0 +1,58 @@
use anyhow::Context;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use std::time::Duration;
pub async fn run_until_stopped(configuration: PgConnectOptions) -> Result<(), anyhow::Error> {
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration);
worker_loop(connection_pool).await
}
async fn worker_loop(connection_pool: PgPool) -> Result<(), anyhow::Error> {
loop {
if let Err(e) = clean_pending_subscriptions(&connection_pool).await {
tracing::error!("{:?}", e);
}
if let Err(e) = clean_idempotency_keys(&connection_pool).await {
tracing::error!("{:?}", e);
}
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
async fn clean_pending_subscriptions(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
let result = sqlx::query!(
"
DELETE FROM subscriptions
WHERE status = 'pending_confirmation'
AND subscribed_at < NOW() - INTERVAL '24 hours'
"
)
.execute(connection_pool)
.await
.context("Failed to clean up subscriptions table.")?;
match result.rows_affected() {
n if n > 0 => tracing::info!("Cleaned up {} expired subscriptions.", n),
_ => (),
}
Ok(())
}
async fn clean_idempotency_keys(connection_pool: &PgPool) -> Result<(), anyhow::Error> {
let result = sqlx::query!(
"
DELETE FROM idempotency
WHERE created_at < NOW() - INTERVAL '1 hour'
"
)
.execute(connection_pool)
.await
.context("Failed to clean up idempontency table.")?;
match result.rows_affected() {
n if n > 0 => tracing::info!("Cleaned up {} old idempotency records.", n),
_ => (),
}
Ok(())
}

View File

@@ -13,6 +13,6 @@ pub struct CommentEntry {
impl CommentEntry { impl CommentEntry {
pub fn formatted_date(&self) -> String { pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y %H:%M").to_string()
} }
} }

View File

@@ -5,6 +5,7 @@ pub struct PostEntry {
pub post_id: Uuid, pub post_id: Uuid,
pub author_id: Uuid, pub author_id: Uuid,
pub author: String, pub author: String,
pub full_name: Option<String>,
pub title: String, pub title: String,
pub content: String, pub content: String,
pub published_at: DateTime<Utc>, pub published_at: DateTime<Utc>,
@@ -13,13 +14,9 @@ pub struct PostEntry {
impl PostEntry { impl PostEntry {
pub fn formatted_date(&self) -> String { pub fn formatted_date(&self) -> String {
self.published_at.format("%B %d, %Y").to_string() self.published_at.format("%B %d, %Y %H:%M").to_string()
} }
// pub fn last_modified(&self) -> String {
// if let Some(last_modified) = self.last_modi
// }
pub fn to_html(&self) -> anyhow::Result<String> { pub fn to_html(&self) -> anyhow::Result<String> {
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) { match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
Ok(content) => Ok(content), Ok(content) => Ok(content),

View File

@@ -7,7 +7,7 @@ use std::time::Duration;
use tracing::{Span, field::display}; use tracing::{Span, field::display};
use uuid::Uuid; use uuid::Uuid;
pub async fn run_worker_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> { pub async fn run_until_stopped(configuration: Settings) -> Result<(), anyhow::Error> {
let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db()); let connection_pool = PgPoolOptions::new().connect_lazy_with(configuration.database.with_db());
let email_client = EmailClient::build(configuration.email_client).unwrap(); let email_client = EmailClient::build(configuration.email_client).unwrap();
worker_loop(connection_pool, email_client).await worker_loop(connection_pool, email_client).await

View File

@@ -1,5 +1,6 @@
pub mod authentication; pub mod authentication;
pub mod configuration; pub mod configuration;
pub mod database_worker;
pub mod domain; pub mod domain;
pub mod email_client; pub mod email_client;
pub mod idempotency; pub mod idempotency;

View File

@@ -1,6 +1,6 @@
use zero2prod::{ use zero2prod::{
configuration::get_configuration, issue_delivery_worker::run_worker_until_stopped, configuration::get_configuration, database_worker, issue_delivery_worker, startup::Application,
startup::Application, telemetry::init_subscriber, telemetry::init_subscriber,
}; };
#[tokio::main] #[tokio::main]
@@ -11,11 +11,16 @@ async fn main() -> Result<(), anyhow::Error> {
let application = Application::build(configuration.clone()).await?; let application = Application::build(configuration.clone()).await?;
let application_task = tokio::spawn(application.run_until_stopped()); let application_task = tokio::spawn(application.run_until_stopped());
let worker_task = tokio::spawn(run_worker_until_stopped(configuration)); let database_worker_task = tokio::spawn(database_worker::run_until_stopped(
configuration.database.with_db(),
));
let delivery_worker_task =
tokio::spawn(issue_delivery_worker::run_until_stopped(configuration));
tokio::select! { tokio::select! {
_ = application_task => {}, _ = application_task => {},
_ = worker_task => {}, _ = database_worker_task => {},
_ = delivery_worker_task => {},
}; };
Ok(()) Ok(())

View File

@@ -78,7 +78,7 @@ async fn get_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, p.author_id, u.username AS author, SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
@@ -101,7 +101,7 @@ pub async fn get_posts_page(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, p.author_id, u.username AS author, SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, p.last_modified p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id
@@ -261,7 +261,7 @@ async fn get_post_data(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.post_id, p.author_id, u.username AS author, SELECT p.post_id, p.author_id, u.username AS author, u.full_name,
p.title, p.content, p.published_at, last_modified p.title, p.content, p.published_at, last_modified
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id LEFT JOIN users u ON p.author_id = u.user_id

View File

@@ -322,7 +322,7 @@ async fn fetch_user_posts(
sqlx::query_as!( sqlx::query_as!(
PostEntry, PostEntry,
r#" r#"
SELECT p.author_id, u.username as author, SELECT p.author_id, u.username as author, u.full_name,
p.post_id, p.title, p.content, p.published_at, p.last_modified p.post_id, p.title, p.content, p.published_at, p.last_modified
FROM posts p FROM posts p
INNER JOIN users u ON p.author_id = u.user_id INNER JOIN users u ON p.author_id = u.user_id

View File

@@ -21,7 +21,7 @@ where
) )
.with( .with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.compact() .pretty()
.with_writer(sink) .with_writer(sink)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE), .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
) )

View File

@@ -5,7 +5,7 @@
<div class="mb-1"> <div class="mb-1">
{% if let Some(user_id) = comment.user_id %} {% if let Some(user_id) = comment.user_id %}
<a href="/users/{{ comment.username.as_ref().unwrap() }}" <a href="/users/{{ comment.username.as_ref().unwrap() }}"
class="font-semibold text-blue-600 hover:text-blue-800 hover:underline"> class="font-semibold text-blue-800 hover:text-blue-600 hover:underline">
{{ comment.username.as_ref().unwrap() }} {{ comment.username.as_ref().unwrap() }}
</a> </a>
{% else %} {% else %}

View File

@@ -3,16 +3,17 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<a href="/posts/{{ post.post_id }}"> <a href="/posts/{{ post.post_id }}">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ <h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">
post.title }}</h2> {{
post.title }}
</h2>
</a> </a>
<div class="flex items-center text-sm text-gray-500 mb-1"> <div class="flex items-center text-sm text-gray-500 mb-1">
<svg class="w-4 h-4 mr-1" <svg class="w-4 h-4 mr-1"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<time datetime="{{ post.published_at }}"> <time datetime="{{ post.published_at }}">
{{ post.formatted_date() }} {{ post.formatted_date() }}
@@ -23,11 +24,16 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg> </svg>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline">{{ post.author }}</a> class="hover:text-blue-600 hover:underline">
{% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
</div> </div>
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4"> <a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
@@ -35,7 +41,7 @@
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg> </svg>
</a> </a>
</div> </div>

View File

@@ -17,7 +17,13 @@
</svg> </svg>
</div> </div>
<a href="/users/{{ post.author }}" <a href="/users/{{ post.author }}"
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a> class="hover:text-blue-600 hover:underline font-medium">
{% if let Some(full_name) = post.full_name %}
{{ full_name }}
{% else %}
{{ post.author }}
{% endif %}
</a>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400" <svg class="w-4 h-4 mr-1 text-gray-400"
@@ -47,7 +53,7 @@
{% endif %} {% endif %}
</div> </div>
{% if let Some(modified) = post.last_modified %} {% if let Some(modified) = post.last_modified %}
<span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }}, at {{ modified.format("%H:%M") }}</span> <span class="text-sm italic text-gray-500">Last modified on {{ modified.format("%B %d, %Y") }} at {{ modified.format("%H:%M") }}</span>
{% endif %} {% endif %}
</header> </header>
{% if session_user_id.as_ref() == Some(post.author_id) %} {% if session_user_id.as_ref() == Some(post.author_id) %}