Compare commits

...

12 Commits

19 changed files with 95 additions and 43 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{
"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": {
"columns": [
{
@@ -22,5 +22,5 @@
true
]
},
"hash": "d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba"
"hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
}

View File

@@ -1,6 +1,6 @@
{
"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": {
"columns": [
{
@@ -34,5 +34,5 @@
true
]
},
"hash": "41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8"
"hash": "f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318"
}

View File

@@ -3,22 +3,21 @@ use domain::models::DiaryEntry;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter {
feed_title: String,
feed_link: String,
}
impl RssAdapter {
pub fn new(feed_title: String, feed_link: String) -> Self {
Self { feed_title, feed_link }
pub fn new(feed_link: String) -> Self {
Self { feed_link }
}
}
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
.iter()
.map(|e| {
let title = format!(
let item_title = format!(
"{} ({})",
e.movie().title().value(),
e.movie().release_year().value()
@@ -38,7 +37,7 @@ impl RssFeedRenderer for RssAdapter {
.permalink(false)
.build();
ItemBuilder::default()
.title(Some(title))
.title(Some(item_title))
.description(Some(description))
.pub_date(Some(pub_date))
.guid(Some(guid))
@@ -47,12 +46,31 @@ impl RssFeedRenderer for RssAdapter {
.collect::<Vec<_>>();
let channel = ChannelBuilder::default()
.title(self.feed_title.clone())
.title(title.to_string())
.link(self.feed_link.clone())
.description(self.feed_title.clone())
.description(title.to_string())
.items(items)
.build();
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> {
sqlx::query_as!(
UserTotalsRow,
r#"SELECT COUNT(*) AS "total!: i64",
r#"SELECT COUNT(DISTINCT movie_id) AS "total!: i64",
AVG(CAST(rating AS REAL)) AS avg_rating
FROM reviews WHERE user_id = ?"#,
user_id

View File

@@ -104,7 +104,7 @@ impl UserRepository for SqliteUserRepository {
UserSummaryRow,
r#"SELECT u.id,
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
FROM users u
LEFT JOIN reviews r ON r.user_id = u.id

View File

@@ -15,7 +15,7 @@
<nav>
<a href="/">Feed</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 %}
<a href="/reviews/new">Add Review</a>
<a href="/logout">Logout</a>

View File

@@ -37,7 +37,7 @@
<div class="heatmap-label">Movies watched this year</div>
<div class="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-month">{{ cell.month_label }}</div>
</div>

View File

@@ -6,6 +6,7 @@ pub struct HtmlPageContext {
pub user_email: Option<String>,
pub user_id: Option<Uuid>,
pub register_enabled: bool,
pub rss_url: String,
}
impl HtmlPageContext {
@@ -67,5 +68,5 @@ pub trait HtmlRenderer: 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 sort_by: Option<SortDirection>,
pub movie_id: Option<Uuid>,
pub user_id: Option<Uuid>,
}
pub struct GetReviewHistoryQuery {

View File

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

View File

@@ -133,14 +133,6 @@ impl Review {
comment: Option<Comment>,
watched_at: NaiveDateTime,
) -> 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 {
id: ReviewId::generate(),
movie_id,
@@ -148,7 +140,7 @@ impl Review {
rating,
comment,
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,
user_id: None,
}
}
}

View File

@@ -153,7 +153,7 @@ mod tests {
struct 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;
@@ -269,7 +269,7 @@ mod tests {
}
struct 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;
crate::state::AppState {
@@ -329,7 +329,7 @@ mod tests {
}
struct 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 {
app_ctx: AppContext {

View File

@@ -1,4 +1,5 @@
const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;
pub mod html {
use axum::{
@@ -41,6 +42,7 @@ pub mod html {
user_email,
user_id: uuid,
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_id: None,
register_enabled: state.app_ctx.config.allow_registration,
rss_url: "/feed.rss".to_string(),
};
let html = state
.html_renderer
@@ -119,6 +122,7 @@ pub mod html {
user_email: None,
user_id: None,
register_enabled: true,
rss_url: "/feed.rss".to_string(),
};
let html = state
.html_renderer
@@ -276,7 +280,7 @@ pub mod html {
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ProfileQueryParams>,
) -> 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 profile_user = match state.app_ctx.user_repository
@@ -303,6 +307,7 @@ pub mod html {
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
ctx.rss_url = format!("/users/{}/feed.rss", profile_user_uuid);
let data = application::ports::ProfilePageData {
ctx,
profile_user_id: profile_user_uuid,
@@ -359,30 +364,64 @@ pub mod posters {
pub mod rss {
use axum::{
extract::State,
extract::{Path, State},
http::header,
response::IntoResponse,
};
use uuid::Uuid;
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};
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let query = GetDiaryQuery {
limit: Some(50),
limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(SortDirection::Descending),
movie_id: None,
user_id: None,
};
let page = get_diary::execute(&state.app_ctx, query).await?;
let xml = state
.rss_renderer
.render_feed(&page.items)
.render_feed(&page.items, "Movie Diary")
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
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 {

View File

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

View File

@@ -108,7 +108,7 @@ async fn test_app() -> Router {
config: AppConfig { allow_registration: false },
},
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)

View File

@@ -471,6 +471,7 @@ form button[type="submit"]:hover {
text-align: center;
min-height: 48px;
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-month { font-size: 0.65rem; opacity: 0.6; margin-top: 2px; }