Compare commits

..

2 Commits

Author SHA1 Message Date
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
12 changed files with 101 additions and 28 deletions

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 {
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 author_id: Uuid,
pub author: String,
pub full_name: Option<String>,
pub title: String,
pub content: String,
pub published_at: DateTime<Utc>,
@@ -13,13 +14,9 @@ pub struct PostEntry {
impl PostEntry {
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> {
match markdown::to_html_with_options(&self.content, &markdown::Options::gfm()) {
Ok(content) => Ok(content),

View File

@@ -7,7 +7,7 @@ use std::time::Duration;
use tracing::{Span, field::display};
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 email_client = EmailClient::build(configuration.email_client).unwrap();
worker_loop(connection_pool, email_client).await

View File

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

View File

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

View File

@@ -78,7 +78,7 @@ async fn get_posts(
sqlx::query_as!(
PostEntry,
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
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
@@ -101,7 +101,7 @@ pub async fn get_posts_page(
sqlx::query_as!(
PostEntry,
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
FROM posts p
LEFT JOIN users u ON p.author_id = u.user_id
@@ -261,7 +261,7 @@ async fn get_post_data(
sqlx::query_as!(
PostEntry,
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
FROM posts p
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!(
PostEntry,
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
FROM posts p
INNER JOIN users u ON p.author_id = u.user_id

View File

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

View File

@@ -5,7 +5,7 @@
<div class="mb-1">
{% if let Some(user_id) = comment.user_id %}
<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() }}
</a>
{% else %}

View File

@@ -3,16 +3,17 @@
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<a href="/posts/{{ post.post_id }}">
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{
post.title }}</h2>
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">
{{
post.title }}
</h2>
</a>
<div class="flex items-center text-sm text-gray-500 mb-1">
<svg class="w-4 h-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<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"/>
<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" />
</svg>
<time datetime="{{ post.published_at }}">
{{ post.formatted_date() }}
@@ -23,11 +24,16 @@
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<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"/>
<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" />
</svg>
<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>
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
@@ -35,7 +41,7 @@
fill="none"
viewBox="0 0 24 24"
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>
</a>
</div>

View File

@@ -17,7 +17,13 @@
</svg>
</div>
<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 class="flex items-center">
<svg class="w-4 h-4 mr-1 text-gray-400"
@@ -47,7 +53,7 @@
{% endif %}
</div>
{% 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 %}
</header>
{% if session_user_id.as_ref() == Some(post.author_id) %}