Compare commits

...

12 Commits

19 changed files with 95 additions and 43 deletions

View File

@@ -1,4 +1,5 @@
DATABASE_URL=sqlite:./dev.db DATABASE_URL=sqlite:./dev.db
BASE_URL=http://localhost:3000
PORT=3000 PORT=3000
JWT_SECRET= JWT_SECRET=
JWT_TTL_SECONDS= JWT_TTL_SECONDS=

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT COUNT(*) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?", "query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -22,5 +22,5 @@
true true
] ]
}, },
"hash": "d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba" "hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT u.id,\n u.email,\n COUNT(r.id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC", "query": "SELECT u.id,\n u.email,\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -34,5 +34,5 @@
true true
] ]
}, },
"hash": "41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8" "hash": "f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318"
} }

View File

@@ -3,22 +3,21 @@ use domain::models::DiaryEntry;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder}; use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter { pub struct RssAdapter {
feed_title: String,
feed_link: String, feed_link: String,
} }
impl RssAdapter { impl RssAdapter {
pub fn new(feed_title: String, feed_link: String) -> Self { pub fn new(feed_link: String) -> Self {
Self { feed_title, feed_link } Self { feed_link }
} }
} }
impl RssFeedRenderer for RssAdapter { impl RssFeedRenderer for RssAdapter {
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String> { fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String> {
let items = entries let items = entries
.iter() .iter()
.map(|e| { .map(|e| {
let title = format!( let item_title = format!(
"{} ({})", "{} ({})",
e.movie().title().value(), e.movie().title().value(),
e.movie().release_year().value() e.movie().release_year().value()
@@ -38,7 +37,7 @@ impl RssFeedRenderer for RssAdapter {
.permalink(false) .permalink(false)
.build(); .build();
ItemBuilder::default() ItemBuilder::default()
.title(Some(title)) .title(Some(item_title))
.description(Some(description)) .description(Some(description))
.pub_date(Some(pub_date)) .pub_date(Some(pub_date))
.guid(Some(guid)) .guid(Some(guid))
@@ -47,12 +46,31 @@ impl RssFeedRenderer for RssAdapter {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let channel = ChannelBuilder::default() let channel = ChannelBuilder::default()
.title(self.feed_title.clone()) .title(title.to_string())
.link(self.feed_link.clone()) .link(self.feed_link.clone())
.description(self.feed_title.clone()) .description(title.to_string())
.items(items) .items(items)
.build(); .build();
Ok(channel.to_string()) Ok(channel.to_string())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_feed_uses_provided_title() {
let adapter = RssAdapter::new("http://example.com".into());
let xml = adapter.render_feed(&[], "Custom Title").unwrap();
assert!(xml.contains("<title>Custom Title</title>"));
}
#[test]
fn render_feed_empty_entries_produces_valid_xml() {
let adapter = RssAdapter::new("http://example.com".into());
let xml = adapter.render_feed(&[], "My Feed").unwrap();
assert!(xml.starts_with("<?xml") || xml.starts_with("<rss"));
}
}

View File

@@ -240,7 +240,7 @@ impl SqliteMovieRepository {
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> { async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as!( sqlx::query_as!(
UserTotalsRow, UserTotalsRow,
r#"SELECT COUNT(*) AS "total!: i64", r#"SELECT COUNT(DISTINCT movie_id) AS "total!: i64",
AVG(CAST(rating AS REAL)) AS avg_rating AVG(CAST(rating AS REAL)) AS avg_rating
FROM reviews WHERE user_id = ?"#, FROM reviews WHERE user_id = ?"#,
user_id user_id

View File

@@ -104,7 +104,7 @@ impl UserRepository for SqliteUserRepository {
UserSummaryRow, UserSummaryRow,
r#"SELECT u.id, r#"SELECT u.id,
u.email, u.email,
COUNT(r.id) AS "total_movies!: i64", COUNT(DISTINCT r.movie_id) AS "total_movies!: i64",
AVG(CAST(r.rating AS REAL)) AS avg_rating AVG(CAST(r.rating AS REAL)) AS avg_rating
FROM users u FROM users u
LEFT JOIN reviews r ON r.user_id = u.id LEFT JOIN reviews r ON r.user_id = u.id

View File

@@ -15,7 +15,7 @@
<nav> <nav>
<a href="/">Feed</a> <a href="/">Feed</a>
<a href="/users">Users</a> <a href="/users">Users</a>
<a href="/feed.rss">RSS</a> <a href="{{ ctx.rss_url }}">RSS</a>
{% if let Some(email) = ctx.user_email %} {% if let Some(email) = ctx.user_email %}
<a href="/reviews/new">Add Review</a> <a href="/reviews/new">Add Review</a>
<a href="/logout">Logout</a> <a href="/logout">Logout</a>

View File

@@ -37,7 +37,7 @@
<div class="heatmap-label">Movies watched this year</div> <div class="heatmap-label">Movies watched this year</div>
<div class="heatmap"> <div class="heatmap">
{% for cell in heatmap %} {% for cell in heatmap %}
<div class="heatmap-cell" style="background: rgba(74, 158, 255, {{ cell.alpha }})"> <div class="heatmap-cell" style="--alpha: {{ cell.alpha }}">
<div class="heatmap-count">{{ cell.count }}</div> <div class="heatmap-count">{{ cell.count }}</div>
<div class="heatmap-month">{{ cell.month_label }}</div> <div class="heatmap-month">{{ cell.month_label }}</div>
</div> </div>

View File

@@ -6,6 +6,7 @@ pub struct HtmlPageContext {
pub user_email: Option<String>, pub user_email: Option<String>,
pub user_id: Option<Uuid>, pub user_id: Option<Uuid>,
pub register_enabled: bool, pub register_enabled: bool,
pub rss_url: String,
} }
impl HtmlPageContext { impl HtmlPageContext {
@@ -67,5 +68,5 @@ pub trait HtmlRenderer: Send + Sync {
} }
pub trait RssFeedRenderer: Send + Sync { pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String>; fn render_feed(&self, entries: &[DiaryEntry], title: &str) -> Result<String, String>;
} }

View File

@@ -6,6 +6,7 @@ pub struct GetDiaryQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
pub sort_by: Option<SortDirection>, pub sort_by: Option<SortDirection>,
pub movie_id: Option<Uuid>, pub movie_id: Option<Uuid>,
pub user_id: Option<Uuid>,
} }
pub struct GetReviewHistoryQuery { pub struct GetReviewHistoryQuery {

View File

@@ -4,7 +4,7 @@ use domain::{
DiaryEntry, DiaryFilter, SortDirection, DiaryEntry, DiaryFilter, SortDirection,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
value_objects::MovieId, value_objects::{MovieId, UserId},
}; };
use crate::{context::AppContext, queries::GetDiaryQuery}; use crate::{context::AppContext, queries::GetDiaryQuery};
@@ -14,17 +14,15 @@ pub async fn execute(
query: GetDiaryQuery, query: GetDiaryQuery,
) -> Result<Paginated<DiaryEntry>, DomainError> { ) -> Result<Paginated<DiaryEntry>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?; let page = PageParams::new(query.limit, query.offset)?;
let movie_id = query.movie_id.map(MovieId::from_uuid); let movie_id = query.movie_id.map(MovieId::from_uuid);
let user_id = query.user_id.map(UserId::from_uuid);
let filter = DiaryFilter { let filter = DiaryFilter {
sort_by: query.sort_by.unwrap_or(SortDirection::Descending), sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
page, page,
movie_id, movie_id,
user_id: None, user_id,
}; };
let paginated_results = ctx.repository.query_diary(&filter).await?; ctx.repository.query_diary(&filter).await
Ok(paginated_results)
} }

View File

@@ -133,14 +133,6 @@ impl Review {
comment: Option<Comment>, comment: Option<Comment>,
watched_at: NaiveDateTime, watched_at: NaiveDateTime,
) -> Result<Self, DomainError> { ) -> Result<Self, DomainError> {
let now = Utc::now().naive_utc();
if watched_at > now {
return Err(DomainError::ValidationError(
"watched_at cannot be in the future".into(),
));
}
Ok(Self { Ok(Self {
id: ReviewId::generate(), id: ReviewId::generate(),
movie_id, movie_id,
@@ -148,7 +140,7 @@ impl Review {
rating, rating,
comment, comment,
watched_at, watched_at,
created_at: now, created_at: Utc::now().naive_utc(),
}) })
} }

View File

@@ -219,6 +219,7 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
} }
}), }),
movie_id: p.movie_id, movie_id: p.movie_id,
user_id: None,
} }
} }
} }

View File

@@ -153,7 +153,7 @@ mod tests {
struct PanicRssRenderer; struct PanicRssRenderer;
impl crate::ports::RssFeedRenderer for PanicRssRenderer { impl crate::ports::RssFeedRenderer for PanicRssRenderer {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() } fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
} }
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo; struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
@@ -269,7 +269,7 @@ mod tests {
} }
struct PanicRssRenderer2; struct PanicRssRenderer2;
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 { impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() } fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
} }
struct PanicAuth2; struct PanicAuth2;
crate::state::AppState { crate::state::AppState {
@@ -329,7 +329,7 @@ mod tests {
} }
struct PanicRssRenderer3; struct PanicRssRenderer3;
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 { impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
fn render_feed(&self, _: &[domain::models::DiaryEntry]) -> Result<String, String> { panic!() } fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
} }
crate::state::AppState { crate::state::AppState {
app_ctx: AppContext { app_ctx: AppContext {

View File

@@ -1,4 +1,5 @@
const DEFAULT_PAGE_LIMIT: u32 = 5; const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;
pub mod html { pub mod html {
use axum::{ use axum::{
@@ -41,6 +42,7 @@ pub mod html {
user_email, user_email,
user_id: uuid, user_id: uuid,
register_enabled: state.app_ctx.config.allow_registration, register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
} }
} }
@@ -67,6 +69,7 @@ pub mod html {
user_email: None, user_email: None,
user_id: None, user_id: None,
register_enabled: state.app_ctx.config.allow_registration, register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
}; };
let html = state let html = state
.html_renderer .html_renderer
@@ -119,6 +122,7 @@ pub mod html {
user_email: None, user_email: None,
user_id: None, user_id: None,
register_enabled: true, register_enabled: true,
rss_url: "/feed.rss".to_string(),
}; };
let html = state let html = state
.html_renderer .html_renderer
@@ -276,7 +280,7 @@ pub mod html {
Path(profile_user_uuid): Path<Uuid>, Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ProfileQueryParams>, Query(params): Query<crate::dtos::ProfileQueryParams>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id).await; let mut ctx = build_page_context(&state, user_id).await;
let view = params.view.unwrap_or_else(|| "recent".to_string()); let view = params.view.unwrap_or_else(|| "recent".to_string());
let profile_user = match state.app_ctx.user_repository let profile_user = match state.app_ctx.user_repository
@@ -303,6 +307,7 @@ pub mod html {
(e.offset, has_more, e.limit) (e.offset, has_more, e.limit)
}) })
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
ctx.rss_url = format!("/users/{}/feed.rss", profile_user_uuid);
let data = application::ports::ProfilePageData { let data = application::ports::ProfilePageData {
ctx, ctx,
profile_user_id: profile_user_uuid, profile_user_id: profile_user_uuid,
@@ -359,30 +364,64 @@ pub mod posters {
pub mod rss { pub mod rss {
use axum::{ use axum::{
extract::State, extract::{Path, State},
http::header, http::header,
response::IntoResponse, response::IntoResponse,
}; };
use uuid::Uuid;
use application::{queries::GetDiaryQuery, use_cases::get_diary}; use application::{queries::GetDiaryQuery, use_cases::get_diary};
use domain::{errors::DomainError, models::SortDirection}; use domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
use crate::{errors::ApiError, state::AppState}; use crate::{errors::ApiError, state::AppState};
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> { pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let query = GetDiaryQuery { let query = GetDiaryQuery {
limit: Some(50), limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0), offset: Some(0),
sort_by: Some(SortDirection::Descending), sort_by: Some(SortDirection::Descending),
movie_id: None, movie_id: None,
user_id: None,
}; };
let page = get_diary::execute(&state.app_ctx, query).await?; let page = get_diary::execute(&state.app_ctx, query).await?;
let xml = state let xml = state
.rss_renderer .rss_renderer
.render_feed(&page.items) .render_feed(&page.items, "Movie Diary")
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml)) Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
} }
pub async fn get_user_feed(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let user = state
.app_ctx
.user_repository
.find_by_id(&UserId::from_uuid(user_id))
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
let query = GetDiaryQuery {
limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(SortDirection::Descending),
movie_id: None,
user_id: Some(user_id),
};
let page = get_diary::execute(&state.app_ctx, query).await?;
let display_name = user.email().value().split('@').next().unwrap_or("User");
let title = format!("{}'s Movie Diary", display_name);
let xml = state
.rss_renderer
.render_feed(&page.items, &title)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok(([(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], xml))
}
} }
pub mod api { pub mod api {

View File

@@ -109,8 +109,7 @@ async fn wire_dependencies() -> anyhow::Result<AppState> {
app_ctx, app_ctx,
html_renderer: Arc::new(AskamaHtmlRenderer::new()), html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new( rss_renderer: Arc::new(RssAdapter::new(
"Movie Diary".into(), std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
"http://localhost:3000".into(),
)), )),
}) })
} }

View File

@@ -33,6 +33,7 @@ fn html_routes() -> Router<AppState> {
.route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review)) .route("/reviews/{id}/delete", routing::post(handlers::html::post_delete_review))
.route("/posters/{path}", routing::get(handlers::posters::get_poster)) .route("/posters/{path}", routing::get(handlers::posters::get_poster))
.route("/feed.rss", routing::get(handlers::rss::get_feed)) .route("/feed.rss", routing::get(handlers::rss::get_feed))
.route("/users/{id}/feed.rss", routing::get(handlers::rss::get_user_feed))
} }
fn api_routes() -> Router<AppState> { fn api_routes() -> Router<AppState> {

View File

@@ -108,7 +108,7 @@ async fn test_app() -> Router {
config: AppConfig { allow_registration: false }, config: AppConfig { allow_registration: false },
}, },
html_renderer: Arc::new(AskamaHtmlRenderer::new()), html_renderer: Arc::new(AskamaHtmlRenderer::new()),
rss_renderer: Arc::new(RssAdapter::new("Movie Diary".into(), "http://localhost:3000".into())), rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
}; };
routes::build_router(state) routes::build_router(state)

View File

@@ -471,6 +471,7 @@ form button[type="submit"]:hover {
text-align: center; text-align: center;
min-height: 48px; min-height: 48px;
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
background: oklch(85.2% 0.199 91.936 / var(--alpha, 0.05));
} }
.heatmap-count { font-size: 0.85rem; font-weight: 700; } .heatmap-count { font-size: 0.85rem; font-weight: 700; }
.heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; } .heatmap-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; }