Compare commits
12 Commits
e28f628c80
...
317898d51b
| Author | SHA1 | Date | |
|---|---|---|---|
| 317898d51b | |||
| 790bb6fbb5 | |||
| 658df38788 | |||
| cff0f854fa | |||
| 66ade70273 | |||
| cbd2ac5b3e | |||
| 0433cd4d9b | |||
| b5a8ea2395 | |||
| 49b79799c1 | |||
| f4aba551a2 | |||
| 91df35dbd3 | |||
| 623f90e43f |
@@ -1,4 +1,5 @@
|
||||
DATABASE_URL=sqlite:./dev.db
|
||||
BASE_URL=http://localhost:3000
|
||||
PORT=3000
|
||||
JWT_SECRET=
|
||||
JWT_TTL_SECONDS=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ impl From<DiaryQueryParams> for GetDiaryQuery {
|
||||
}
|
||||
}),
|
||||
movie_id: p.movie_id,
|
||||
user_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user