diff --git a/.gitignore b/.gitignore index b27c518..2fe4135 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ .vscode .env -.env.prod \ No newline at end of file +.env.prod + +*.db \ No newline at end of file diff --git a/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json b/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json new file mode 100644 index 0000000..27ed241 --- /dev/null +++ b/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.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.movie_id = ?\n ORDER BY r.watched_at ASC\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": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6" +} diff --git a/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json b/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json new file mode 100644 index 0000000..58175b3 --- /dev/null +++ b/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.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 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": 2 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff" +} diff --git a/.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json b/.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json new file mode 100644 index 0000000..2fb8300 --- /dev/null +++ b/.sqlx/query-0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM reviews", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "0963b9661182e139cd760bbabb0d6ea3a301a2a3adbdfdda4a88f333a1144c77" +} diff --git a/.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json b/.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json new file mode 100644 index 0000000..54b4a4e --- /dev/null +++ b/.sqlx/query-3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE title = ? AND release_year = ?", + "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" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + true, + false, + false, + true, + true + ] + }, + "hash": "3047579c6ed13ce87aad9b9ce6300c02f0df3516979518976e13f9d9abc6a403" +} diff --git a/.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json b/.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json new file mode 100644 index 0000000..5666885 --- /dev/null +++ b/.sqlx/query-33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE id = ?", + "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" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true + ] + }, + "hash": "33d0dae7d16b0635c1c7eb5afd10824bb55af7cc7a854f590d326622863759d1" +} diff --git a/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json b/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json new file mode 100644 index 0000000..8db3f5e --- /dev/null +++ b/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.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.movie_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": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055" +} diff --git a/.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json b/.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json new file mode 100644 index 0000000..1089c99 --- /dev/null +++ b/.sqlx/query-4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM reviews WHERE movie_id = ?", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "4b3074b532342c6356ee0e8e4d8c4a830f016234bb690e1f6240f02824d6d84f" +} diff --git a/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json b/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json new file mode 100644 index 0000000..8b41a06 --- /dev/null +++ b/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5" +} diff --git a/.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json b/.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json new file mode 100644 index 0000000..a23bd20 --- /dev/null +++ b/.sqlx/query-7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, external_metadata_id, title, release_year, director, poster_path\n FROM movies WHERE external_metadata_id = ?", + "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" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true + ] + }, + "hash": "7bc4aebcb94547976d3d7e063e4e908fc22b977b3cbf063ee93ffe4648c42011" +} diff --git a/.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json b/.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json new file mode 100644 index 0000000..1967c47 --- /dev/null +++ b/.sqlx/query-7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n external_metadata_id = excluded.external_metadata_id,\n title = excluded.title,\n release_year = excluded.release_year,\n director = excluded.director,\n poster_path = excluded.poster_path", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "7d7e23355ee0e442f2aa27e898dcfa40bdc4b09391afe04325f076157d9d84aa" +} diff --git a/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json b/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json new file mode 100644 index 0000000..4b94231 --- /dev/null +++ b/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d" +} diff --git a/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json b/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json new file mode 100644 index 0000000..65a123c --- /dev/null +++ b/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.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 ORDER BY r.watched_at ASC\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": 2 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8" +} diff --git a/Cargo.lock b/Cargo.lock index cfd242b..2987355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -53,6 +62,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "auth" version = "0.1.0" @@ -63,6 +78,70 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.22.1" @@ -295,6 +374,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "etcetera" version = "0.8.0" @@ -519,6 +608,92 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -753,6 +928,21 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -773,6 +963,22 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" name = "metadata" version = "0.1.0" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.0" @@ -784,6 +990,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -940,6 +1155,20 @@ dependencies = [ [[package]] name = "presentation" version = "0.1.0" +dependencies = [ + "anyhow", + "application", + "axum", + "domain", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] [[package]] name = "prettyplease" @@ -1023,6 +1252,23 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "ring" version = "0.17.14" @@ -1162,6 +1408,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1196,12 +1453,31 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -1264,6 +1540,7 @@ dependencies = [ "chrono", "domain", "sqlx", + "tokio", "tracing", "uuid", ] @@ -1496,6 +1773,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -1527,6 +1810,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1561,11 +1853,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1577,6 +1883,73 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1607,6 +1980,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1615,6 +2018,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -1684,6 +2093,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/crates/adapters/sqlite/Cargo.toml b/crates/adapters/sqlite/Cargo.toml index 223233c..1b9e049 100644 --- a/crates/adapters/sqlite/Cargo.toml +++ b/crates/adapters/sqlite/Cargo.toml @@ -16,3 +16,4 @@ uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +tokio = { workspace = true } diff --git a/crates/adapters/sqlite/migrations/0001_initial.sql b/crates/adapters/sqlite/migrations/0001_initial.sql new file mode 100644 index 0000000..355842b --- /dev/null +++ b/crates/adapters/sqlite/migrations/0001_initial.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS movies ( + id TEXT PRIMARY KEY NOT NULL, + external_metadata_id TEXT UNIQUE, + title TEXT NOT NULL, + release_year INTEGER NOT NULL, + director TEXT, + poster_path TEXT +); + +CREATE INDEX IF NOT EXISTS idx_movies_title_year + ON movies (title, release_year); + +CREATE TABLE IF NOT EXISTS reviews ( + id TEXT PRIMARY KEY NOT NULL, + movie_id TEXT NOT NULL REFERENCES movies(id), + user_id TEXT NOT NULL, + rating INTEGER NOT NULL, + comment TEXT, + watched_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_reviews_movie_id ON reviews (movie_id); +CREATE INDEX IF NOT EXISTS idx_reviews_watched_at ON reviews (watched_at); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index d7ad3bc..d6757b1 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -1,12 +1,21 @@ +use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, + models::{ + DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection, + collections::Paginated, + }, ports::MovieRepository, value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, }; use sqlx::SqlitePool; +mod migrations; +mod models; + +use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str}; + pub struct SqliteMovieRepository { pool: SqlitePool, } @@ -16,23 +25,140 @@ impl SqliteMovieRepository { Self { pool } } + pub async fn migrate(&self) -> Result<(), DomainError> { + migrations::run(&self.pool).await + } + fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("Database error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } + + async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { + match movie_id { + None => sqlx::query_scalar!("SELECT COUNT(*) FROM reviews") + .fetch_one(&self.pool) + .await + .map_err(Self::map_err), + Some(id) => { + sqlx::query_scalar!("SELECT COUNT(*) FROM reviews WHERE movie_id = ?", id) + .fetch_one(&self.pool) + .await + .map_err(Self::map_err) + } + } + } + + async fn fetch_diary_rows( + &self, + movie_id: Option<&str>, + sort: &SortDirection, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + // sqlx macros require literal ORDER BY values; separate branches also let the + // query planner use the movie_id index instead of falling back to a filtered scan. + match (movie_id, sort) { + (None, SortDirection::Descending) => 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 + ORDER BY r.watched_at DESC + LIMIT ? OFFSET ?", + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err), + + (None, SortDirection::Ascending) => 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 + ORDER BY r.watched_at ASC + LIMIT ? OFFSET ?", + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err), + + (Some(id), SortDirection::Descending) => 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.movie_id = ? + ORDER BY r.watched_at DESC + LIMIT ? OFFSET ?", + id, + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err), + + (Some(id), SortDirection::Ascending) => 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.movie_id = ? + ORDER BY r.watched_at ASC + LIMIT ? OFFSET ?", + id, + limit, + offset + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err), + } + } } -#[async_trait::async_trait] +#[async_trait] impl MovieRepository for SqliteMovieRepository { async fn get_movie_by_external_id( &self, external_metadata_id: &ExternalMetadataId, ) -> Result, DomainError> { - todo!() + let id = external_metadata_id.value(); + sqlx::query_as!( + MovieRow, + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE external_metadata_id = ?", + id + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(MovieRow::to_domain) + .transpose() } async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - todo!() + let id = movie_id.value().to_string(); + sqlx::query_as!( + MovieRow, + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE id = ?", + id + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .map(MovieRow::to_domain) + .transpose() } async fn get_movies_by_title_and_year( @@ -40,25 +166,138 @@ impl MovieRepository for SqliteMovieRepository { title: &MovieTitle, year: &ReleaseYear, ) -> Result, DomainError> { - todo!() + let title = title.value(); + let year = year.value() as i64; + sqlx::query_as!( + MovieRow, + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE title = ? AND release_year = ?", + title, + year + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + .into_iter() + .map(MovieRow::to_domain) + .collect() } async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - todo!() + let id = movie.id().value().to_string(); + let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); + let title = movie.title().value(); + let release_year = movie.release_year().value() as i64; + let director = movie.director(); + let poster_path = movie.poster_path().map(|p| p.value().to_string()); + + sqlx::query!( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + external_metadata_id = excluded.external_metadata_id, + title = excluded.title, + release_year = excluded.release_year, + director = excluded.director, + poster_path = excluded.poster_path", + id, + external_metadata_id, + title, + release_year, + director, + poster_path + ) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(()) } async fn save_review(&self, review: &Review) -> Result { - todo!() + let id = review.id().value().to_string(); + let movie_id = review.movie_id().value().to_string(); + let user_id = review.user_id().value().to_string(); + let rating = review.rating().value() as i64; + let comment = review.comment().map(|c| c.value().to_string()); + let watched_at = datetime_to_str(review.watched_at()); + let created_at = datetime_to_str(review.created_at()); + + sqlx::query!( + "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + id, + movie_id, + user_id, + rating, + comment, + watched_at, + created_at + ) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(DomainEvent::ReviewLogged { + review_id: review.id().clone(), + movie_id: review.movie_id().clone(), + user_id: review.user_id().clone(), + rating: review.rating().clone(), + watched_at: *review.watched_at(), + }) } - async fn query_diary( - &self, - filter: &DiaryFilter, - ) -> Result, DomainError> { - todo!() + async fn query_diary(&self, filter: &DiaryFilter) -> Result, DomainError> { + let movie_id: Option = filter.movie_id.as_ref().map(|id| id.value().to_string()); + let limit = filter.page.limit as i64; + let offset = filter.page.offset as i64; + + let (total, rows) = tokio::try_join!( + self.count_diary_entries(movie_id.as_deref()), + self.fetch_diary_rows(movie_id.as_deref(), &filter.sort_by, limit, offset) + )?; + + let items = rows + .into_iter() + .map(DiaryRow::to_domain) + .collect::, _>>()?; + + Ok(Paginated { + items, + total_count: total as u64, + limit: filter.page.limit, + offset: filter.page.offset, + }) } async fn get_review_history(&self, movie_id: &MovieId) -> Result { - todo!() + let id_str = movie_id.value().to_string(); + + let movie = sqlx::query_as!( + MovieRow, + "SELECT id, external_metadata_id, title, release_year, director, poster_path + FROM movies WHERE id = ?", + id_str + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)? + .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? + .to_domain()?; + + let viewings = sqlx::query_as!( + ReviewRow, + "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at + FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", + id_str + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)? + .into_iter() + .map(ReviewRow::to_domain) + .collect::, _>>()?; + + Ok(ReviewHistory::new(movie, viewings)) } } diff --git a/crates/adapters/sqlite/src/migrations.rs b/crates/adapters/sqlite/src/migrations.rs new file mode 100644 index 0000000..02eacfd --- /dev/null +++ b/crates/adapters/sqlite/src/migrations.rs @@ -0,0 +1,9 @@ +use domain::errors::DomainError; +use sqlx::SqlitePool; + +pub(crate) async fn run(pool: &SqlitePool) -> Result<(), DomainError> { + sqlx::migrate!("./migrations") + .run(pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())) +} diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs new file mode 100644 index 0000000..cdb9983 --- /dev/null +++ b/crates/adapters/sqlite/src/models.rs @@ -0,0 +1,126 @@ +use chrono::NaiveDateTime; +use domain::{ + errors::DomainError, + models::{DiaryEntry, Movie, Review}, + value_objects::{ + Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + ReviewId, UserId, + }, +}; +use uuid::Uuid; + +#[derive(sqlx::FromRow)] +pub(crate) struct MovieRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, +} + +impl MovieRow { + pub fn to_domain(self) -> Result { + let id = MovieId::from_uuid(parse_uuid(&self.id)?); + let external_metadata_id = self + .external_metadata_id + .map(ExternalMetadataId::new) + .transpose()?; + let title = MovieTitle::new(self.title)?; + let release_year = ReleaseYear::new(self.release_year as u16)?; + let poster_path = self.poster_path.map(PosterPath::new).transpose()?; + Ok(Movie::from_persistence( + id, + external_metadata_id, + title, + release_year, + self.director, + poster_path, + )) + } +} + +#[derive(sqlx::FromRow)] +pub(crate) struct ReviewRow { + pub id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, +} + +impl ReviewRow { + pub fn to_domain(self) -> Result { + let id = ReviewId::from_uuid(parse_uuid(&self.id)?); + let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); + let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); + let rating = Rating::new(self.rating as u8)?; + let comment = self.comment.map(Comment::new).transpose()?; + let watched_at = parse_datetime(&self.watched_at)?; + let created_at = parse_datetime(&self.created_at)?; + Ok(Review::from_persistence( + id, movie_id, user_id, rating, comment, watched_at, created_at, + )) + } +} + +// Used by query_diary JOIN — r.id aliased to review_id to avoid ambiguity with m.id +#[derive(sqlx::FromRow)] +pub(crate) struct DiaryRow { + pub id: String, + pub external_metadata_id: Option, + pub title: String, + pub release_year: i64, + pub director: Option, + pub poster_path: Option, + pub review_id: String, + pub movie_id: String, + pub user_id: String, + pub rating: i64, + pub comment: Option, + pub watched_at: String, + pub created_at: String, +} + +impl DiaryRow { + pub fn to_domain(self) -> Result { + let movie = MovieRow { + id: self.id, + external_metadata_id: self.external_metadata_id, + title: self.title, + release_year: self.release_year, + director: self.director, + poster_path: self.poster_path, + } + .to_domain()?; + + let review = ReviewRow { + id: self.review_id, + movie_id: self.movie_id, + user_id: self.user_id, + rating: self.rating, + comment: self.comment, + watched_at: self.watched_at, + created_at: self.created_at, + } + .to_domain()?; + + Ok(DiaryEntry::new(movie, review)) + } +} + +pub(crate) fn parse_uuid(s: &str) -> Result { + Uuid::parse_str(s) + .map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e))) +} + +pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String { + dt.format("%Y-%m-%d %H:%M:%S").to_string() +} + +pub(crate) fn parse_datetime(s: &str) -> Result { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e))) +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 8e14e43..ca4f69a 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -4,3 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] +axum = { version = "0.8.8", features = ["macros"] } +tower-http = { version = "0.6.8", features = ["fs", "trace", "tracing"] } + +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } + +domain = { workspace = true } +application = { workspace = true }