Compare commits
7 Commits
044991d623
...
91e8b5f001
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91e8b5f001 | ||
|
|
a75c410948 | ||
|
|
95c4d3fdd0 | ||
|
|
71d4872878 | ||
|
|
3120c700a4 | ||
|
|
08d5f611b5 | ||
|
|
54218f92a9 |
@@ -54,5 +54,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ed9f14ed1476ef5a9dc8b7aabf38fd31e127e2a6246d5a14f4ef624f0302eac8"
|
||||
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
|
||||
}
|
||||
@@ -37,5 +37,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0851bf5e8d147f0ace037c6f434bcc4e04d330e3c4259ef8c8097e61f77b64e2"
|
||||
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
|
||||
}
|
||||
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal file
18
.sqlx/query-3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO posts (post_id, author_id, title, content, published_at)\n VALUES ($1, $2, $3, $4, $5)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3d6654896cea2ea1405f7ee5088da406ebe3d829380e3719b23b9bdf08affcfc"
|
||||
}
|
||||
15
.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json
generated
Normal file
15
.sqlx/query-409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b"
|
||||
}
|
||||
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal file
16
.sqlx/query-61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO subscriptions (id, email, subscribed_at, status)\n VALUES ($1, $2, $3, 'pending_confirmation')\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "61eb9d8067d08c12b6f703d3100cda08bd84a53e54a49bf072758a59a375dc14"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO subscriptions (id, email, name, subscribed_at, status)\n VALUES ($1, $2, $3, $4, 'pending_confirmation')\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e6822c9e162eabc20338cc27d51a8e80578803ec1589c234d93c3919d14a96a6"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f007c2d5d9ae67a2412c6a70a2228390c5bd4835fcf71fd17a00fe521b43415d"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
7
migrations/20250918120924_create_posts_table.sql
Normal file
7
migrations/20250918120924_create_posts_table.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE posts (
|
||||
post_id UUID PRIMARY KEY,
|
||||
author_id UUID NOT NULL REFERENCES users (user_id),
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
published_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
@@ -75,8 +75,6 @@ struct EmailField<'a> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
|
||||
};
|
||||
@@ -88,6 +86,7 @@ mod tests {
|
||||
lorem::en::{Paragraph, Sentence},
|
||||
},
|
||||
};
|
||||
use std::time::Duration;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{any, header, header_exists, method, path},
|
||||
|
||||
@@ -2,12 +2,29 @@ mod admin;
|
||||
mod health_check;
|
||||
mod home;
|
||||
mod login;
|
||||
mod posts;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
||||
pub use admin::*;
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
pub use health_check::*;
|
||||
pub use home::*;
|
||||
pub use login::*;
|
||||
pub use posts::*;
|
||||
use reqwest::StatusCode;
|
||||
pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/404.html")]
|
||||
struct NotFoundTemplate;
|
||||
|
||||
pub async fn not_found() -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(NotFoundTemplate.render().unwrap()),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ mod change_password;
|
||||
mod dashboard;
|
||||
mod logout;
|
||||
mod newsletters;
|
||||
mod posts;
|
||||
|
||||
use crate::{routes::error_chain_fmt, templates::ErrorTemplate};
|
||||
use askama::Template;
|
||||
@@ -14,6 +15,7 @@ pub use change_password::*;
|
||||
pub use dashboard::*;
|
||||
pub use logout::*;
|
||||
pub use newsletters::*;
|
||||
pub use posts::*;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[derive(thiserror::Error)]
|
||||
@@ -56,7 +58,8 @@ impl IntoResponse for AdminError {
|
||||
AdminError::NotAuthenticated => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/login".parse().unwrap());
|
||||
(StatusCode::OK, headers).into_response()
|
||||
headers.insert("Location", "/login".parse().unwrap());
|
||||
(StatusCode::SEE_OTHER, headers).into_response()
|
||||
}
|
||||
AdminError::ChangePassword(e) => {
|
||||
let template = ErrorTemplate {
|
||||
|
||||
@@ -10,16 +10,19 @@ use uuid::Uuid;
|
||||
#[template(path = "../templates/dashboard.html")]
|
||||
struct DashboardTemplate {
|
||||
username: String,
|
||||
idempotency_key: String,
|
||||
idempotency_key_1: String,
|
||||
idempotency_key_2: String,
|
||||
}
|
||||
|
||||
pub async fn admin_dashboard(
|
||||
Extension(AuthenticatedUser { username, .. }): Extension<AuthenticatedUser>,
|
||||
) -> Response {
|
||||
let idempotency_key = Uuid::new_v4().to_string();
|
||||
let idempotency_key_1 = Uuid::new_v4().to_string();
|
||||
let idempotency_key_2 = Uuid::new_v4().to_string();
|
||||
let template = DashboardTemplate {
|
||||
username,
|
||||
idempotency_key,
|
||||
idempotency_key_1,
|
||||
idempotency_key_2,
|
||||
};
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::{routes::AdminError, session_state::TypedSession};
|
||||
use axum::{
|
||||
http::HeaderMap,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[tracing::instrument(name = "Logging out", skip(session))]
|
||||
pub async fn logout(session: TypedSession) -> Result<Response, AdminError> {
|
||||
|
||||
@@ -48,7 +48,7 @@ pub async fn insert_newsletter_issue(
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn enqueue_delivery_tasks(
|
||||
pub async fn enqueue_delivery_tasks(
|
||||
transaction: &mut Transaction<'static, Postgres>,
|
||||
newsletter_issue_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
@@ -76,9 +76,7 @@ pub async fn publish_newsletter(
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<BodyData>,
|
||||
) -> Result<Response, AdminError> {
|
||||
if let Err(e) = validate_form(&form) {
|
||||
return Err(AdminError::Publish(anyhow::anyhow!(e)));
|
||||
}
|
||||
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||
|
||||
let idempotency_key: IdempotencyKey = form
|
||||
.idempotency_key
|
||||
@@ -94,11 +92,11 @@ pub async fn publish_newsletter(
|
||||
|
||||
let issue_id = insert_newsletter_issue(&mut transaction, &form.title, &form.text, &form.html)
|
||||
.await
|
||||
.context("Failed to store newsletter issue details")?;
|
||||
.context("Failed to store newsletter issue details.")?;
|
||||
|
||||
enqueue_delivery_tasks(&mut transaction, issue_id)
|
||||
.await
|
||||
.context("Failed to enqueue delivery tasks")?;
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
let success_message = format!(
|
||||
r#"The newsletter issue "{}" has been published!"#,
|
||||
|
||||
120
src/routes/admin/posts.rs
Normal file
120
src/routes/admin/posts.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::{
|
||||
authentication::AuthenticatedUser,
|
||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||
routes::{AdminError, enqueue_delivery_tasks, insert_newsletter_issue},
|
||||
startup::AppState,
|
||||
templates::SuccessTemplate,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::{Executor, Postgres, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CreatePostForm {
|
||||
title: String,
|
||||
content: String,
|
||||
idempotency_key: String,
|
||||
}
|
||||
|
||||
fn validate_form(form: &CreatePostForm) -> Result<(), anyhow::Error> {
|
||||
if form.title.is_empty() || form.content.is_empty() {
|
||||
anyhow::bail!("Fields cannot be empty.")
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Creating a post", skip(connection_pool, form))]
|
||||
pub async fn create_post(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<CreatePostForm>,
|
||||
) -> Result<Response, AdminError> {
|
||||
validate_form(&form).map_err(AdminError::Publish)?;
|
||||
|
||||
let idempotency_key: IdempotencyKey = form
|
||||
.idempotency_key
|
||||
.try_into()
|
||||
.map_err(AdminError::Idempotency)?;
|
||||
|
||||
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
||||
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||
return Ok(response);
|
||||
}
|
||||
};
|
||||
|
||||
let post_id = insert_post(&mut transaction, &form.title, &form.content, &user_id)
|
||||
.await
|
||||
.context("Failed to insert new post in the database.")?;
|
||||
|
||||
let newsletter_uuid = create_newsletter(&mut transaction, &form.title, &form.content, &post_id)
|
||||
.await
|
||||
.context("Failed to create newsletter.")?;
|
||||
|
||||
enqueue_delivery_tasks(&mut transaction, newsletter_uuid)
|
||||
.await
|
||||
.context("Failed to enqueue delivery tasks.")?;
|
||||
|
||||
// Send emails with unique identifiers that contains link to blog post with special param
|
||||
// Get handpoint that returns the post and mark the email as opened
|
||||
|
||||
let template = SuccessTemplate {
|
||||
success_message: "Your new post has been saved. Subscribers will be notified.".into(),
|
||||
};
|
||||
let response = Html(template.render().unwrap()).into_response();
|
||||
save_response(transaction, &idempotency_key, user_id, response)
|
||||
.await
|
||||
.map_err(AdminError::UnexpectedError)
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Saving new post in the database",
|
||||
skip(transaction, title, content, author)
|
||||
)]
|
||||
pub async fn insert_post(
|
||||
transaction: &mut Transaction<'static, Postgres>,
|
||||
title: &str,
|
||||
content: &str,
|
||||
author: &Uuid,
|
||||
) -> Result<Uuid, AdminError> {
|
||||
let post_id = Uuid::new_v4();
|
||||
let query = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO posts (post_id, author_id, title, content, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
post_id,
|
||||
author,
|
||||
title,
|
||||
content,
|
||||
Utc::now()
|
||||
);
|
||||
transaction
|
||||
.execute(query)
|
||||
.await
|
||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?;
|
||||
Ok(post_id)
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Creating newsletter for new post",
|
||||
skip(transaction, title, content, _post_id)
|
||||
)]
|
||||
pub async fn create_newsletter(
|
||||
transaction: &mut Transaction<'static, Postgres>,
|
||||
title: &str,
|
||||
content: &str,
|
||||
_post_id: &Uuid,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
insert_newsletter_issue(transaction, title, content, content).await
|
||||
}
|
||||
@@ -9,12 +9,10 @@ use askama::Template;
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Redirect,
|
||||
};
|
||||
use axum::{http::StatusCode, response::Redirect};
|
||||
use secrecy::SecretString;
|
||||
|
||||
#[derive(thiserror::Error)]
|
||||
|
||||
103
src/routes/posts.rs
Normal file
103
src/routes/posts.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use crate::startup::AppState;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::StatusCode;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct PostEntry {
|
||||
post_id: Uuid,
|
||||
author: Option<String>,
|
||||
title: String,
|
||||
content: String,
|
||||
published_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PostEntry {
|
||||
#[allow(dead_code)]
|
||||
fn formatted_date(&self) -> String {
|
||||
self.published_at.format("%B %d, %Y").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/posts.html")]
|
||||
struct PostsTemplate {
|
||||
posts: Vec<PostEntry>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "../templates/post.html")]
|
||||
struct PostTemplate {
|
||||
post: PostEntry,
|
||||
}
|
||||
|
||||
pub async fn list_posts(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
) -> Response {
|
||||
match get_latest_posts(&connection_pool, 5).await {
|
||||
Err(e) => {
|
||||
tracing::error!("Could not fetch latest posts: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
Ok(posts) => {
|
||||
let template = PostsTemplate { posts };
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_latest_posts(connection_pool: &PgPool, n: i64) -> Result<Vec<PostEntry>, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
r#"
|
||||
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
ORDER BY p.published_at DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
n
|
||||
)
|
||||
.fetch_all(connection_pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn see_post(
|
||||
State(AppState {
|
||||
connection_pool, ..
|
||||
}): State<AppState>,
|
||||
Path(post_id): Path<Uuid>,
|
||||
) -> Response {
|
||||
match get_post(&connection_pool, post_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!("Could not fetch post #{}: {}", post_id, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
Ok(post) => {
|
||||
let template = PostTemplate { post };
|
||||
Html(template.render().unwrap()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_post(connection_pool: &PgPool, post_id: Uuid) -> Result<PostEntry, sqlx::Error> {
|
||||
sqlx::query_as!(
|
||||
PostEntry,
|
||||
r#"
|
||||
SELECT p.post_id, u.username AS author, p.title, p.content, p.published_at
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.author_id = u.user_id
|
||||
WHERE p.post_id = $1
|
||||
"#,
|
||||
post_id
|
||||
)
|
||||
.fetch_one(connection_pool)
|
||||
.await
|
||||
}
|
||||
@@ -119,6 +119,7 @@ pub fn app(
|
||||
.route("/dashboard", get(admin_dashboard))
|
||||
.route("/password", post(change_password))
|
||||
.route("/newsletters", post(publish_newsletter))
|
||||
.route("/posts", post(create_post))
|
||||
.route("/logout", post(logout))
|
||||
.layer(middleware::from_fn(require_auth));
|
||||
Router::new()
|
||||
@@ -128,6 +129,8 @@ pub fn app(
|
||||
.route("/health_check", get(health_check))
|
||||
.route("/subscriptions", post(subscribe))
|
||||
.route("/subscriptions/confirm", get(confirm))
|
||||
.route("/posts", get(list_posts))
|
||||
.route("/posts/{post_id}", get(see_post))
|
||||
.nest("/admin", admin_routes)
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
@@ -147,5 +150,6 @@ pub fn app(
|
||||
}),
|
||||
)
|
||||
.layer(SessionManagerLayer::new(redis_store).with_secure(false))
|
||||
.fallback(not_found)
|
||||
.with_state(app_state)
|
||||
}
|
||||
|
||||
37
templates/404.html
Normal file
37
templates/404.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}404{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-4xl font-semibold text-gray-700 mb-4">404</h1>
|
||||
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Not Found</h2>
|
||||
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
Sorry, we couldn't find the page you're looking for. The page may have been moved, deleted, or the URL might be incorrect.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<a href="/"
|
||||
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Home
|
||||
</a>
|
||||
<a href="/posts"
|
||||
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
Read posts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,7 +6,8 @@
|
||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% block title %}zero2prod{% endblock %}
|
||||
{% block title %}{% endblock %}
|
||||
- zero2prod
|
||||
</title>
|
||||
<link href="/assets/css/main.css" rel="stylesheet" />
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
@@ -15,13 +16,16 @@
|
||||
<header class="bg-white shadow-sm border-b border-gray-200 top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex-shrink-0">
|
||||
<a href="/" class="hover:opacity-80 transition-opacity">
|
||||
<h1 class="text-xl font-bold text-gray-900">
|
||||
<span class="text-blue-600">zero2prod</span>
|
||||
</h1>
|
||||
<nav class="flex items-center space-x-2">
|
||||
<a href="/"
|
||||
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
<a href="/posts"
|
||||
class="flex items-center text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-3 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
Posts
|
||||
</a>
|
||||
</nav>
|
||||
<nav>
|
||||
<a href="/admin/dashboard"
|
||||
hx-boost="true"
|
||||
@@ -33,10 +37,10 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0">
|
||||
<div class="py-8 px-4 sm:px-6 lg:px-8">
|
||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||
<!-- <div class="flex-1 flex-col py-8 px-4 sm:px-6 lg:px-8"> -->
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
</main>
|
||||
</div>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
@@ -47,7 +51,7 @@
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
|
||||
Code repository
|
||||
Gitea
|
||||
<svg class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}zero2prod{% endblock %}
|
||||
{% block content %}
|
||||
<div class="min-h-[60vh] flex items-center justify-center">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div class="text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 mb-6">
|
||||
@@ -13,10 +13,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">Subscription confirmed</h1>
|
||||
<p class="text-lg text-gray-600 mb-8">
|
||||
Your email has been confirmed! You're all set to receive our newsletter
|
||||
updates.
|
||||
</p>
|
||||
<p class="text-lg text-gray-600 mb-8">Your email has been confirmed! You're all set to receive the latest updates.</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a href="/"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - zero2prod{% endblock %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="mb-8">
|
||||
@@ -21,9 +21,6 @@
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -93,6 +90,56 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
<svg class="w-5 h-5 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>
|
||||
Write a new post
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Publish a new post online.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/admin/posts"
|
||||
hx-target="#post-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
|
||||
<div>
|
||||
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<input type="text"
|
||||
id="post-title"
|
||||
name="title"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="post-content"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||
<textarea id="post-content"
|
||||
name="content"
|
||||
rows="6"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-purple-600 text-white hover:bg-purple-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 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>
|
||||
Publish
|
||||
</button>
|
||||
<div id="post-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
||||
@@ -102,39 +149,45 @@
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
Send an issue
|
||||
Send an email
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Create and send a newsletter issue.</p>
|
||||
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form hx-post="/admin/newsletters"
|
||||
hx-target="#newsletter-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}" />
|
||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<label for="newsletter-title"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
|
||||
<input type="text"
|
||||
id="title"
|
||||
id="newsletter-title"
|
||||
name="title"
|
||||
placeholder="Subject"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="html" class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||
<textarea id="html"
|
||||
<label for="newsletter-html"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">HTML content</label>
|
||||
<textarea id="newsletter-html"
|
||||
name="html"
|
||||
rows="6"
|
||||
placeholder="HTML version"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="text" class="block text-sm font-medium text-gray-700 mb-2">Plain text content</label>
|
||||
<textarea id="text"
|
||||
<label for="newsletter-text"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Text content</label>
|
||||
<textarea id="newsletter-text"
|
||||
name="text"
|
||||
rows="6"
|
||||
placeholder="Plain text version"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
@@ -159,7 +212,7 @@
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Password
|
||||
Change your password
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Home - zero2prod{% endblock %}
|
||||
{% block title %}Home{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 mb-8">
|
||||
<div class="max-w-3xl">
|
||||
<h1 class="text-4xl font-bold mb-4">zero2prod</h1>
|
||||
<p class="text-xl text-blue-100 mb-6">
|
||||
Welcome to our newsletter! Stay updated on our latest projects and
|
||||
thoughts. Unsubscribe at any time.
|
||||
Welcome to my blog! Stay updated on my latest projects and
|
||||
thoughts. Subscribe (and unsubscribe) at any time.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<a href="#newsletter-signup"
|
||||
@@ -22,22 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Idempotent</h3>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Smart duplicate prevention ensures you'll never receive the same email
|
||||
twice.
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-green-600"
|
||||
@@ -73,7 +59,7 @@
|
||||
class="bg-white rounded-lg shadow-md p-8 border border-gray-200">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Stay updated</h2>
|
||||
<p class="text-gray-600 mb-6">Subscribe to our newsletter to get the latest updates.</p>
|
||||
<p class="text-gray-600 mb-6">Subscribe to my newsletter to get the latest updates.</p>
|
||||
<form hx-post="/subscriptions"
|
||||
hx-target="#subscribe-messages"
|
||||
hx-swap="innerHTML"
|
||||
@@ -93,28 +79,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4 text-center">Stats</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-blue-600" id="subscriber-count">2</div>
|
||||
<div class="text-sm text-gray-600">subscribers</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-orange-600">23</div>
|
||||
<div class="text-sm text-gray-600">emails sent</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-600">0</div>
|
||||
<div class="text-sm text-gray-600">email opened</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-purple-600">3</div>
|
||||
<div class="text-sm text-gray-600">issues delivered</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer utilities {
|
||||
.font-inter {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
}
|
||||
|
||||
48
templates/post.html
Normal file
48
templates/post.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<article class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||
<header class="px-8 pt-8 pb-6 border-b border-gray-100">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
|
||||
<svg class="w-4 h-4 text-blue-600"
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||
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" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="px-8 py-8">
|
||||
<div class="prose prose-lg prose-blue max-w-none">{{ post.content | safe }}</div>
|
||||
</div>
|
||||
</article>
|
||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||
<a href="/#newsletter-signup"
|
||||
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
75
templates/posts.html
Normal file
75
templates/posts.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Posts{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Posts</h1>
|
||||
<p class="mt-2 text-gray-600">Insights on Rust backend development, performance tips, and production stories.</p>
|
||||
</div>
|
||||
{% if posts.is_empty() %}
|
||||
<div class="bg-white rounded-lg shadow-md p-8 border border-gray-200 text-center">
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
||||
<p class="text-gray-600">Check back later for new content!</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="space-y-6">
|
||||
{% for post in posts %}
|
||||
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
|
||||
<a href="/posts/{{ post.post_id }}"
|
||||
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
|
||||
<div class="flex items-center text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<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" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
<span class="mx-2">•</span>
|
||||
<div class="flex items-center">
|
||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-8 text-center">
|
||||
<button class="bg-blue-600 text-white hover:bg-blue-700 font-medium py-3 px-6 rounded-md transition-colors">
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,12 +2,13 @@ use argon2::{
|
||||
Algorithm, Argon2, Params, PasswordHasher, Version,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use fake::{Fake, faker::internet::en::SafeEmail};
|
||||
use linkify::LinkFinder;
|
||||
use once_cell::sync::Lazy;
|
||||
use sqlx::{Connection, Executor, PgConnection, PgPool};
|
||||
use uuid::Uuid;
|
||||
use wiremock::{
|
||||
Mock, MockBuilder, MockServer,
|
||||
Mock, MockBuilder, MockServer, ResponseTemplate,
|
||||
matchers::{method, path},
|
||||
};
|
||||
use zero2prod::{
|
||||
@@ -120,6 +121,42 @@ impl TestApp {
|
||||
app
|
||||
}
|
||||
|
||||
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
||||
let email: String = SafeEmail().fake();
|
||||
let body = format!("email={email}");
|
||||
|
||||
let _mock_guard = when_sending_an_email()
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.named("Create unconfirmed subscriber")
|
||||
.expect(1)
|
||||
.mount_as_scoped(&self.email_server)
|
||||
.await;
|
||||
|
||||
self.post_subscriptions(body)
|
||||
.await
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let email_request = &self
|
||||
.email_server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
|
||||
self.get_confirmation_links(email_request)
|
||||
}
|
||||
|
||||
pub async fn create_confirmed_subscriber(&self) {
|
||||
let confirmation_links = self.create_unconfirmed_subscriber().await;
|
||||
reqwest::get(confirmation_links.html)
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub async fn dispatch_all_pending_emails(&self) {
|
||||
loop {
|
||||
if let ExecutionOutcome::EmptyQueue =
|
||||
@@ -195,7 +232,7 @@ impl TestApp {
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to execute request")
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn admin_login(&self) {
|
||||
@@ -211,7 +248,7 @@ impl TestApp {
|
||||
.post(format!("{}/admin/logout", self.address))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to execute request")
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn post_change_password<Body>(&self, body: &Body) -> reqwest::Response
|
||||
@@ -223,7 +260,19 @@ impl TestApp {
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to execute request")
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
{
|
||||
self.api_client
|
||||
.post(format!("{}/admin/posts", self.address))
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +297,11 @@ async fn configure_database(config: &DatabaseSettings) -> PgPool {
|
||||
}
|
||||
|
||||
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
||||
dbg!(&response);
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert_eq!(response.headers().get("hx-redirect").unwrap(), location);
|
||||
assert!(
|
||||
response.status().as_u16() == 303
|
||||
|| response.status().as_u16() == 200
|
||||
&& response.headers().get("hx-redirect").unwrap() == location
|
||||
);
|
||||
}
|
||||
|
||||
pub fn when_sending_an_email() -> MockBuilder {
|
||||
|
||||
@@ -4,5 +4,6 @@ mod health_check;
|
||||
mod helpers;
|
||||
mod login;
|
||||
mod newsletters;
|
||||
mod posts;
|
||||
mod subscriptions;
|
||||
mod subscriptions_confirm;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::helpers::{ConfirmationLinks, TestApp, assert_is_redirect_to, when_sending_an_email};
|
||||
use fake::{Fake, faker::internet::en::SafeEmail};
|
||||
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
use wiremock::ResponseTemplate;
|
||||
@@ -7,7 +6,7 @@ use wiremock::ResponseTemplate;
|
||||
#[tokio::test]
|
||||
async fn newsletters_are_not_delivered_to_unconfirmed_subscribers() {
|
||||
let app = TestApp::spawn().await;
|
||||
create_unconfirmed_subscriber(&app).await;
|
||||
app.create_unconfirmed_subscriber().await;
|
||||
app.admin_login().await;
|
||||
|
||||
when_sending_an_email()
|
||||
@@ -48,7 +47,7 @@ async fn requests_without_authentication_are_redirected() {
|
||||
#[tokio::test]
|
||||
async fn newsletters_are_delivered_to_confirmed_subscribers() {
|
||||
let app = TestApp::spawn().await;
|
||||
create_confirmed_subscriber(&app).await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
app.admin_login().await;
|
||||
|
||||
when_sending_an_email()
|
||||
@@ -123,7 +122,7 @@ async fn form_shows_error_for_invalid_data() {
|
||||
#[tokio::test]
|
||||
async fn newsletter_creation_is_idempotent() {
|
||||
let app = TestApp::spawn().await;
|
||||
create_confirmed_subscriber(&app).await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
app.admin_login().await;
|
||||
|
||||
when_sending_an_email()
|
||||
@@ -164,7 +163,7 @@ async fn newsletter_creation_is_idempotent() {
|
||||
#[tokio::test]
|
||||
async fn concurrent_form_submission_is_handled_gracefully() {
|
||||
let app = TestApp::spawn().await;
|
||||
create_confirmed_subscriber(&app).await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
app.admin_login().await;
|
||||
|
||||
when_sending_an_email()
|
||||
@@ -191,39 +190,3 @@ async fn concurrent_form_submission_is_handled_gracefully() {
|
||||
|
||||
app.dispatch_all_pending_emails().await;
|
||||
}
|
||||
|
||||
async fn create_unconfirmed_subscriber(app: &TestApp) -> ConfirmationLinks {
|
||||
let email: String = SafeEmail().fake();
|
||||
let body = format!("email={email}");
|
||||
|
||||
let _mock_guard = when_sending_an_email()
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.named("Create unconfirmed subscriber")
|
||||
.expect(1)
|
||||
.mount_as_scoped(&app.email_server)
|
||||
.await;
|
||||
|
||||
app.post_subscriptions(body)
|
||||
.await
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let email_request = &app
|
||||
.email_server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
|
||||
app.get_confirmation_links(email_request)
|
||||
}
|
||||
|
||||
async fn create_confirmed_subscriber(app: &TestApp) {
|
||||
let confirmation_links = create_unconfirmed_subscriber(app).await;
|
||||
reqwest::get(confirmation_links.html)
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
83
tests/api/posts.rs
Normal file
83
tests/api/posts.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::helpers::{TestApp, assert_is_redirect_to, when_sending_an_email};
|
||||
use fake::{
|
||||
Fake,
|
||||
faker::lorem::en::{Paragraph, Sentence},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use wiremock::ResponseTemplate;
|
||||
|
||||
fn subject() -> String {
|
||||
Sentence(1..2).fake()
|
||||
}
|
||||
|
||||
fn content() -> String {
|
||||
Paragraph(1..10).fake()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn you_must_be_logged_in_to_create_a_new_post() {
|
||||
let app = TestApp::spawn().await;
|
||||
|
||||
let title = subject();
|
||||
let content = content();
|
||||
let body = serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
});
|
||||
let response = app.post_create_post(&body).await;
|
||||
|
||||
assert_is_redirect_to(&response, "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_posts_are_stored_in_the_database() {
|
||||
let app = TestApp::spawn().await;
|
||||
app.admin_login().await;
|
||||
|
||||
let title = subject();
|
||||
let content = content();
|
||||
let body = serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
"idempotency_key": Uuid::new_v4(),
|
||||
});
|
||||
let response = app.post_create_post(&body).await;
|
||||
|
||||
assert!(response.status().is_success());
|
||||
let html_fragment = response.text().await.unwrap();
|
||||
assert!(html_fragment.contains("Your new post has been saved"));
|
||||
|
||||
let saved = sqlx::query!("SELECT title, content FROM posts")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.expect("Failed to fetch saved post");
|
||||
|
||||
assert_eq!(saved.title, title);
|
||||
assert_eq!(saved.content, content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirmed_subscribers_are_notified_when_a_new_post_is_published() {
|
||||
let app = TestApp::spawn().await;
|
||||
app.create_unconfirmed_subscriber().await;
|
||||
app.create_confirmed_subscriber().await;
|
||||
app.admin_login().await;
|
||||
|
||||
when_sending_an_email()
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&app.email_server)
|
||||
.await;
|
||||
|
||||
let title = subject();
|
||||
let content = content();
|
||||
let body = serde_json::json!({
|
||||
"title": title,
|
||||
"content": content,
|
||||
"idempotency_key": Uuid::new_v4(),
|
||||
|
||||
});
|
||||
app.post_create_post(&body).await;
|
||||
|
||||
app.dispatch_all_pending_emails().await;
|
||||
}
|
||||
Reference in New Issue
Block a user