From 7352b533ff9b13a85ebdbdb5e75aa54cec4c9c46 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 4 May 2026 18:42:45 +0200 Subject: [PATCH] feat: implement feed/stats/history/trends SQLite queries --- ...b48027d22900a570b98a636c780cb3e2efd23.json | 20 ++ ...1fb743e5be49ffc59826509be37a7cc81b6ee.json | 98 ++++++ ...44279f2ae77a95a8ea61bbf3dbfef2d861dd8.json | 38 +++ ...85a0002c2b600c34ba4d99f1e1c5e99f35e75.json | 20 ++ ...8feb95e6dab051c5ac55a66f9793482522199.json | 92 ++++++ ...0e74015a814ed8185b6f86fbe39e641ab804e.json | 92 ++++++ ...d37096c87859ded1762137ce745955f46830c.json | 92 ++++++ ...340e978d31a36be9121da3c59378f2fc1ed8e.json | 26 ++ ...0f77b68500cf4c96002a4a17b1e40093504ba.json | 26 ++ ...14d76d76ecc7d2190ffb73d12bec2874111d2.json | 20 ++ ...bb606fd9ee9884f4457831f693a0df3609317.json | 32 ++ crates/adapters/sqlite/src/lib.rs | 306 +++++++++++++++++- crates/adapters/sqlite/src/users.rs | 2 +- crates/application/src/use_cases/get_diary.rs | 1 + 14 files changed, 855 insertions(+), 10 deletions(-) create mode 100644 .sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json create mode 100644 .sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json create mode 100644 .sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json create mode 100644 .sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json create mode 100644 .sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json create mode 100644 .sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json create mode 100644 .sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json create mode 100644 .sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json create mode 100644 .sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json create mode 100644 .sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json create mode 100644 .sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json diff --git a/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json b/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json new file mode 100644 index 0000000..f1f314f --- /dev/null +++ b/.sqlx/query-0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM reviews WHERE user_id = ?", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0cd1a7b7255a0ee753deffab7cbb48027d22900a570b98a636c780cb3e2efd23" +} diff --git a/.sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json b/.sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json new file mode 100644 index 0000000..dda6f89 --- /dev/null +++ b/.sqlx/query-217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at,\n u.email AS user_email\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n INNER JOIN users u ON u.id = r.user_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "external_metadata_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "release_year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "director", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "poster_path", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "review_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "user_email", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "217854179b4f77897178e6cfae51fb743e5be49ffc59826509be37a7cc81b6ee" +} diff --git a/.sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json b/.sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json new file mode 100644 index 0000000..8805af9 --- /dev/null +++ b/.sqlx/query-41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8.json @@ -0,0 +1,38 @@ +{ + "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", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "total_movies!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "avg_rating", + "ordinal": 3, + "type_info": "Float" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "41273bd5f2ad4e86bb2f60d7b3144279f2ae77a95a8ea61bbf3dbfef2d861dd8" +} diff --git a/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json b/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json new file mode 100644 index 0000000..f67cfd6 --- /dev/null +++ b/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "month", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75" +} diff --git a/.sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json b/.sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json new file mode 100644 index 0000000..eeff415 --- /dev/null +++ b/.sqlx/query-5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "external_metadata_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "release_year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "director", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "poster_path", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "review_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "5a861b5a934c9831ff17d896fa48feb95e6dab051c5ac55a66f9793482522199" +} diff --git a/.sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json b/.sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json new file mode 100644 index 0000000..96480e2 --- /dev/null +++ b/.sqlx/query-8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "external_metadata_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "release_year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "director", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "poster_path", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "review_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "8d144859b397a842118c2dc4ab30e74015a814ed8185b6f86fbe39e641ab804e" +} diff --git a/.sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json b/.sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json new file mode 100644 index 0000000..50b7c83 --- /dev/null +++ b/.sqlx/query-a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.rating DESC, r.watched_at DESC\n LIMIT ? OFFSET ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "external_metadata_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "release_year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "director", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "poster_path", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "review_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "a3f4385bac7f78a9959648fb325d37096c87859ded1762137ce745955f46830c" +} diff --git a/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json b/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json new file mode 100644 index 0000000..4392039 --- /dev/null +++ b/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5", + "describe": { + "columns": [ + { + "name": "director!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "count!: i64", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false + ] + }, + "hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e" +} diff --git a/.sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json b/.sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json new file mode 100644 index 0000000..9ea8fdb --- /dev/null +++ b/.sqlx/query-d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?", + "describe": { + "columns": [ + { + "name": "total!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "avg_rating", + "ordinal": 1, + "type_info": "Float" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "d59e1a103fc56b9b4579add523f0f77b68500cf4c96002a4a17b1e40093504ba" +} diff --git a/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json b/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json new file mode 100644 index 0000000..506d8af --- /dev/null +++ b/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "director", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2" +} diff --git a/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json b/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json new file mode 100644 index 0000000..0e39030 --- /dev/null +++ b/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC", + "describe": { + "columns": [ + { + "name": "month!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "avg_rating!: f64", + "ordinal": 1, + "type_info": "Float" + }, + { + "name": "count!: i64", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317" +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 41a1489..5e37d3e 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -3,11 +3,12 @@ use domain::{ errors::DomainError, events::DomainEvent, models::{ - DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection, - collections::Paginated, + DiaryEntry, DiaryFilter, DirectorStat, FeedEntry, Movie, MonthlyRating, + Review, ReviewHistory, SortDirection, UserStats, UserTrends, + collections::{PageParams, Paginated}, }, ports::MovieRepository, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId}, + value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId}, }; use sqlx::SqlitePool; @@ -15,10 +16,26 @@ mod migrations; mod models; mod users; -use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str}; +use models::{ + DiaryRow, DirectorCountRow, FeedRow, MonthlyRatingRow, MovieRow, ReviewRow, + UserTotalsRow, datetime_to_str, +}; pub use users::SqliteUserRepository; +fn format_year_month(ym: &str) -> String { + let parts: Vec<&str> = ym.splitn(2, '-').collect(); + if parts.len() != 2 { return ym.to_string(); } + let year = parts[0].get(2..).unwrap_or(parts[0]); + let month = match parts[1] { + "01" => "Jan", "02" => "Feb", "03" => "Mar", "04" => "Apr", + "05" => "May", "06" => "Jun", "07" => "Jul", "08" => "Aug", + "09" => "Sep", "10" => "Oct", "11" => "Nov", "12" => "Dec", + _ => parts[1], + }; + format!("{} '{}", month, year) +} + pub struct SqliteMovieRepository { pool: SqlitePool, } @@ -59,7 +76,7 @@ impl SqliteMovieRepository { offset: i64, ) -> Result, DomainError> { match sort { - SortDirection::Descending => sqlx::query_as!( + SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!( DiaryRow, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at @@ -99,7 +116,7 @@ impl SqliteMovieRepository { offset: i64, ) -> Result, DomainError> { match sort { - SortDirection::Descending => sqlx::query_as!( + SortDirection::Descending | SortDirection::ByRatingDesc => sqlx::query_as!( DiaryRow, "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at @@ -134,6 +151,141 @@ impl SqliteMovieRepository { .map_err(Self::map_err), } } + + async fn count_user_diary_entries(&self, user_id: &str) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM reviews WHERE user_id = ?", + user_id + ) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn fetch_user_diary_rows_by_watched( + &self, + user_id: &str, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + sqlx::query_as!( + DiaryRow, + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? + ORDER BY r.watched_at DESC + LIMIT ? OFFSET ?", + user_id, limit, offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn fetch_user_diary_rows_by_rating( + &self, + user_id: &str, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + sqlx::query_as!( + DiaryRow, + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? + ORDER BY r.rating DESC, r.watched_at DESC + LIMIT ? OFFSET ?", + user_id, limit, offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn count_feed_entries(&self) -> Result { + sqlx::query_scalar!("SELECT COUNT(*) FROM reviews") + .fetch_one(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn fetch_feed_rows( + &self, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + sqlx::query_as!( + FeedRow, + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, + u.email AS user_email + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + INNER JOIN users u ON u.id = r.user_id + ORDER BY r.watched_at DESC + LIMIT ? OFFSET ?", + limit, offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn fetch_user_totals(&self, user_id: &str) -> Result { + sqlx::query_as!( + UserTotalsRow, + r#"SELECT COUNT(*) AS "total!: i64", + AVG(CAST(rating AS REAL)) AS avg_rating + FROM reviews WHERE user_id = ?"#, + user_id + ) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err) + } + + async fn fetch_user_favorite_director( + &self, + user_id: &str, + ) -> Result, DomainError> { + let row = sqlx::query_scalar!( + "SELECT m.director + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND m.director IS NOT NULL + GROUP BY m.director + ORDER BY COUNT(*) DESC + LIMIT 1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(row.flatten()) + } + + async fn fetch_user_most_active_month( + &self, + user_id: &str, + ) -> Result, DomainError> { + let result: Option> = sqlx::query_scalar!( + "SELECT strftime('%Y-%m', watched_at) AS month + FROM reviews + WHERE user_id = ? + GROUP BY month + ORDER BY COUNT(*) DESC + LIMIT 1", + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(result.flatten()) + } } #[async_trait] @@ -261,18 +413,39 @@ impl MovieRepository for SqliteMovieRepository { let limit = filter.page.limit as i64; let offset = filter.page.offset as i64; - let (total, rows) = match &filter.movie_id { - None => tokio::try_join!( + let (total, rows) = match (&filter.movie_id, &filter.user_id) { + (None, None) => tokio::try_join!( self.count_diary_entries(None), self.fetch_all_diary_rows(&filter.sort_by, limit, offset) )?, - Some(id) => { + (Some(id), None) => { let id_str = id.value().to_string(); tokio::try_join!( self.count_diary_entries(Some(id_str.as_str())), self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset) )? } + (None, Some(uid)) => { + let uid_str = uid.value().to_string(); + match &filter.sort_by { + SortDirection::ByRatingDesc => tokio::try_join!( + self.count_user_diary_entries(&uid_str), + self.fetch_user_diary_rows_by_rating(&uid_str, limit, offset) + )?, + _ => tokio::try_join!( + self.count_user_diary_entries(&uid_str), + self.fetch_user_diary_rows_by_watched(&uid_str, limit, offset) + )?, + } + } + (Some(mid), Some(uid)) => { + let mid_str = mid.value().to_string(); + let uid_str = uid.value().to_string(); + tokio::try_join!( + self.count_user_diary_entries(&uid_str), + self.fetch_movie_diary_rows(&mid_str, &filter.sort_by, limit, offset) + )? + } }; let items = rows @@ -351,4 +524,119 @@ impl MovieRepository for SqliteMovieRepository { Ok(ReviewHistory::new(movie, viewings)) } + + async fn query_activity_feed( + &self, + page: &PageParams, + ) -> Result, DomainError> { + let limit = page.limit as i64; + let offset = page.offset as i64; + + let (total, rows) = tokio::try_join!( + self.count_feed_entries(), + self.fetch_feed_rows(limit, offset) + )?; + + let items = rows + .into_iter() + .map(FeedRow::to_domain) + .collect::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: page.limit, + offset: page.offset, + }) + } + + async fn get_user_stats(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); + + let (totals, fav_director, most_active) = tokio::try_join!( + self.fetch_user_totals(&uid), + self.fetch_user_favorite_director(&uid), + self.fetch_user_most_active_month(&uid) + )?; + + let most_active_month = most_active.map(|ym| format_year_month(&ym)); + + Ok(UserStats { + total_movies: totals.total, + avg_rating: totals.avg_rating, + favorite_director: fav_director, + most_active_month, + }) + } + + async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows = sqlx::query_as!( + DiaryRow, + "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? + ORDER BY r.watched_at DESC", + uid + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + + rows.into_iter().map(DiaryRow::to_domain).collect() + } + + async fn get_user_trends(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); + + let (rating_rows, director_rows) = tokio::try_join!( + sqlx::query_as!( + MonthlyRatingRow, + r#"SELECT strftime('%Y-%m', watched_at) AS "month!", + AVG(CAST(rating AS REAL)) AS "avg_rating!: f64", + COUNT(*) AS "count!: i64" + FROM reviews + WHERE user_id = ? AND watched_at >= datetime('now', '-12 months') + GROUP BY "month!" + ORDER BY "month!" ASC"#, + uid + ) + .fetch_all(&self.pool), + sqlx::query_as!( + DirectorCountRow, + r#"SELECT m.director AS "director!", + COUNT(*) AS "count!: i64" + FROM reviews r + INNER JOIN movies m ON m.id = r.movie_id + WHERE r.user_id = ? AND m.director IS NOT NULL + GROUP BY m.director + ORDER BY COUNT(*) DESC + LIMIT 5"#, + uid + ) + .fetch_all(&self.pool) + ) + .map_err(Self::map_err)?; + + let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); + + let monthly_ratings = rating_rows + .into_iter() + .map(|r| MonthlyRating { + month_label: format_year_month(&r.month), + year_month: r.month, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(); + + let top_directors = director_rows + .into_iter() + .map(|d| DirectorStat { director: d.director, count: d.count }) + .collect(); + + Ok(UserTrends { monthly_ratings, top_directors, max_director_count }) + } } diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 7f6ee39..abbd511 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -105,7 +105,7 @@ impl UserRepository for SqliteUserRepository { r#"SELECT u.id, u.email, COUNT(r.id) AS "total_movies!: i64", - AVG(CAST(r.rating AS REAL)) AS "avg_rating: Option" + AVG(CAST(r.rating AS REAL)) AS avg_rating FROM users u LEFT JOIN reviews r ON r.user_id = u.id GROUP BY u.id, u.email diff --git a/crates/application/src/use_cases/get_diary.rs b/crates/application/src/use_cases/get_diary.rs index 1ca46d9..1e3f65b 100644 --- a/crates/application/src/use_cases/get_diary.rs +++ b/crates/application/src/use_cases/get_diary.rs @@ -21,6 +21,7 @@ pub async fn execute( sort_by: query.sort_by.unwrap_or(SortDirection::Descending), page, movie_id, + user_id: None, }; let paginated_results = ctx.repository.query_diary(&filter).await?;