Compare commits

...

52 Commits

Author SHA1 Message Date
d034af9e9c feat: update color scheme to use primary variables for consistency in styling 2026-05-04 17:47:00 +02:00
59d308f41b feat: enhance movie rating display with star icons and improved styling 2026-05-04 15:43:02 +02:00
bbb2ee00d6 feat: enhance styling and layout; add background image and improve UI elements 2026-05-04 15:39:15 +02:00
5dd9aac68d feat: add Dockerfile, .dockerignore, and README; remove common crate
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 15:19:29 +02:00
6dcc4c8317 Refactor movie review logging and resolution strategies
- Introduced `MovieResolver` and associated strategies for resolving movie data based on external metadata ID, manual title, or manual entry.
- Updated `log_review` use case to utilize the new `MovieResolver` for fetching movie details.
- Simplified the `LogReviewData` structure and its conversion to `LogReviewCommand`.
- Enhanced error handling for date parsing in review forms and requests.
- Updated dependencies in `Cargo.toml` and `Cargo.lock` to include necessary crates for async operations.
- Added tests for new functionality in `movie_resolver.rs` to ensure correct behavior of resolution strategies.
2026-05-04 15:08:04 +02:00
e31d99a240 fix(tests): add missing trait methods to PanicRepo mocks 2026-05-04 14:37:48 +02:00
41fec1efa5 fix(presentation): restore user_id in get_new_review_page for nav bar 2026-05-04 14:34:46 +02:00
160c08d1c4 fix(presentation): pass None user_id for non-diary page contexts 2026-05-04 14:32:30 +02:00
7aa6d7bf4d feat(template): add user_id to HtmlPageContext and delete button to diary 2026-05-04 14:31:12 +02:00
144f2f8e0c fix(presentation): use {id} syntax in delete review route 2026-05-04 14:29:24 +02:00
cff64f7a6b feat(presentation): add POST /reviews/:id/delete handler and route 2026-05-04 14:27:43 +02:00
5baff54cb9 feat(presentation): add DELETE /api/reviews/:id handler and route 2026-05-04 14:24:48 +02:00
f94d2db8b1 feat(sqlite): implement get_review_by_id, delete_review, delete_movie 2026-05-04 14:21:25 +02:00
48875a6e86 feat(application): add DeleteReviewCommand and delete_review use case 2026-05-04 14:17:13 +02:00
9387ae705b feat(domain): add get_review_by_id to MovieRepository 2026-05-04 14:15:04 +02:00
9871e21bc0 feat(gitignore): add .superpowers and docs/ to .gitignore 2026-05-04 14:00:56 +02:00
fa8efbaa23 feat(database): remove unused SQL queries and update Cargo dependencies 2026-05-04 14:00:33 +02:00
d769a5b55c feat(css): add monospace minimal stylesheet 2026-05-04 13:38:57 +02:00
8e1fb1a974 feat(routes): replace /diary with /, add login/logout/register/reviews/new routes 2026-05-04 13:38:19 +02:00
6145b873f5 feat(handlers): add HTML handlers for login, logout, register, new review, diary index 2026-05-04 13:38:16 +02:00
cc668ae44d feat(dtos): add LoginForm, RegisterForm, ErrorQuery 2026-05-04 13:38:14 +02:00
e5097c22dd feat(extractors): add OptionalCookieUser and RequiredCookieUser 2026-05-04 13:34:31 +02:00
450468ef3d feat(templates): add base layout, login, register, new_review templates; update diary 2026-05-04 13:30:33 +02:00
6e7c6467a7 feat(domain): add find_by_id to UserRepository + SQLite impl 2026-05-04 13:28:20 +02:00
7f815f8207 feat(ports): extend HtmlRenderer with page context and new render methods 2026-05-04 13:20:30 +02:00
5df89200d4 docs: add frontend HTML design spec 2026-05-04 13:19:21 +02:00
eb273dc277 fix(database): update database connection to use DATABASE_URL with SqliteConnectOptions 2026-05-04 12:54:28 +02:00
5689db0ad7 feat(wiring): wire PosterSyncHandler into event channel in main.rs 2026-05-04 12:44:53 +02:00
5c70b8b8be fix(event-handlers): expect over unwrap, panic-stub comment, fix deprecated chrono call 2026-05-04 12:42:03 +02:00
4c547df04e feat(presentation): implement PosterSyncHandler with retry 2026-05-04 12:37:57 +02:00
602df8df22 feat(application): derive Clone on SyncPosterCommand 2026-05-04 12:35:10 +02:00
5b69a3a7c0 test(event-publisher): fix flaky sleep synchronization in EventWorker tests 2026-05-04 12:34:29 +02:00
a38f78d261 feat(event-publisher): add EventHandler trait and fan-out in EventWorker 2026-05-04 12:32:44 +02:00
17f90726e8 feat(event-publisher): add event publisher adapter with configuration and integration 2026-05-04 12:30:42 +02:00
563f33212e docs: event-driven poster sync implementation plan 2026-05-04 12:30:20 +02:00
8e5ac9f433 docs: event-driven poster sync design spec 2026-05-04 12:24:52 +02:00
f790fa2a0f feat(rss): implement RSS feed adapter and integrate with application state 2026-05-04 12:03:17 +02:00
edcf3c1170 feat(poster-fetcher): add poster fetcher adapter with configuration and integration 2026-05-04 11:51:20 +02:00
1985d2c57f feat(poster-storage): implement S3/Minio storage adapter and configuration
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 11:44:44 +02:00
f0b3d8ad90 feat(log_review): add manual title resolution for movie lookup 2026-05-04 11:24:18 +02:00
da72ab1446 feat(metadata): Implement OMDB metadata provider and refactor metadata client
- Added `OmdbProvider` to fetch movie metadata from the OMDB API.
- Refactored `MetadataClient` to use `MetadataSearchCriteria` for fetching movie metadata.
- Updated `MetadataClientImpl` to support fetching metadata using OMDB.
- Modified `log_review` use case to utilize the new metadata fetching mechanism.
- Updated tests and presentation layer to accommodate changes in metadata handling.
- Added dependencies for `reqwest` and `async-trait` in relevant `Cargo.toml` files.
2026-05-04 11:19:51 +02:00
93c65cd155 feat(auth): implement JWT authentication and user registration
- Added JWT authentication with token generation and validation.
- Introduced user registration functionality with email and password.
- Integrated Argon2 for password hashing.
- Created SQLite user repository for user data persistence.
- Updated application context to include user repository and configuration settings.
- Added environment variable support for JWT secret and registration allowance.
- Enhanced error handling for unauthorized access and validation errors.
- Updated presentation layer to handle login and registration requests.
2026-05-04 10:43:07 +02:00
ba42d3d445 refactor(tests): remove unused models from api_test 2026-05-04 09:35:54 +02:00
819332522a cargo lock 2026-05-04 09:30:58 +02:00
79a06e6844 presentation wiring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:30:20 +02:00
97a496553a chore: ignore .worktrees/ directory 2026-05-04 02:43:32 +02:00
5a58625265 feat(presentation): add initial structure with dtos, errors, extractors, handlers, and routes modules 2026-05-04 02:11:33 +02:00
6d9ac07dfc refactor(template-askama): clean up comments and improve code readability 2026-05-04 02:05:13 +02:00
b6a7cf9417 feat(template-askama): add Askama template adapter for diary entries 2026-05-04 02:04:52 +02:00
c4b39c9410 feat(sqlite): implement movie and review management with migrations
- Added SQL migrations for movies and reviews tables.
- Implemented SqliteMovieRepository with methods for upserting movies, saving reviews, and querying diary entries.
- Introduced models for database rows and conversion to domain models.
- Integrated async migration handling in the repository.
- Updated Cargo.toml files to include necessary dependencies for async operations and HTTP handling.
2026-05-04 01:59:52 +02:00
f60cc368b6 Add SQLite repository implementation and update domain models for persistence 2026-05-04 01:34:52 +02:00
65bab7fd44 application layer
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 01:19:59 +02:00
114 changed files with 9188 additions and 98 deletions

2
.cargo/config.toml Normal file
View File

@@ -0,0 +1,2 @@
[env]
SQLX_OFFLINE = "true"

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
target/
.git/
.env
*.db
*.db-shm
*.db-wal

View File

@@ -0,0 +1,11 @@
DATABASE_URL=sqlite:./dev.db
PORT=3000
JWT_SECRET=
JWT_TTL_SECONDS=
ALLOW_REGISTRATION=true
OMDB_API_KEY=
POSTER_FETCH_TIMEOUT_SECONDS=30
MINIO_ENDPOINT=
MINIO_ACCESS_KEY_ID=
MINIO_SECRET_ACCESS_KEY=
MINIO_BUCKET=

6
.gitignore vendored
View File

@@ -6,3 +6,9 @@
.env .env
.env.prod .env.prod
*.db
.worktrees/
.superpowers/
docs/

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE id = ?",
"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": "70ee6050284475b5641af712e5923ba2091b8b70b1885ca6518dfa4bb01fdac2"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM movies WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM reviews WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
}

1646
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
[workspace] [workspace]
members = [ members = [
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/event-publisher",
"crates/adapters/metadata", "crates/adapters/metadata",
"crates/adapters/poster-fetcher",
"crates/adapters/poster-storage",
"crates/adapters/rss", "crates/adapters/rss",
"crates/adapters/sqlite", "crates/adapters/sqlite",
"crates/adapters/template-askama",
"crates/application", "crates/application",
"crates/common",
"crates/domain", "crates/domain",
"crates/presentation", "crates/presentation",
] ]
@@ -13,6 +16,7 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
anyhow = "1.0" anyhow = "1.0"
@@ -22,12 +26,23 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
async-trait = "0.1" async-trait = "0.1"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8.6", features = [
"runtime-tokio-rustls",
"sqlite",
"uuid",
"macros",
] }
reqwest = { version = "0.13", features = ["json", "query"] }
object_store = { version = "0.11", features = ["aws"] }
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
common = { path = "crates/common" }
application = { path = "crates/application" } application = { path = "crates/application" }
presentation = { path = "crates/presentation" } presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" } metadata = { path = "crates/adapters/metadata" }
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
poster-storage = { path = "crates/adapters/poster-storage" }
event-publisher = { path = "crates/adapters/event-publisher" }
rss = { path = "crates/adapters/rss" } rss = { path = "crates/adapters/rss" }
sqlite = { path = "crates/adapters/sqlite" } sqlite = { path = "crates/adapters/sqlite" }
template-askama = { path = "crates/adapters/template-askama" }

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# ----- build -----
FROM rust:slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 && rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
COPY crates/adapters/poster-storage/Cargo.toml crates/adapters/poster-storage/Cargo.toml
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
RUN cargo fetch
# Now copy real sources (invalidates cache only on source changes)
COPY crates ./crates
# sqlx macros verify queries at compile time; create a real DB from migrations
RUN sqlite3 /build/dev.db \
< crates/adapters/sqlite/migrations/0001_initial.sql && \
sqlite3 /build/dev.db \
< crates/adapters/sqlite/migrations/0002_users.sql
ENV DATABASE_URL=sqlite:///build/dev.db
RUN cargo build --release -p presentation
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/presentation ./presentation
COPY static ./static
EXPOSE 3000
ENV RUST_LOG=presentation=info,tower_http=info
CMD ["./presentation"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# Movies Diary
A self-hosted, server-side rendered movie logging system. Built in Rust — no JavaScript, no SPA, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site.
## Features
- Log movies with a TMDB/OMDb ID and a 05 rating
- Immutable append-only viewing ledger (tracks re-watches)
- Background poster fetching and storage (S3-compatible)
- RSS/Atom feed for public subscription
- JWT authentication via cookie (HTML) or Bearer token (REST API)
- Zero JavaScript
## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
domain — pure types and trait definitions, no external deps
common — shared error types
application — use cases / business logic orchestration
presentation — Axum HTTP router, wires all adapters together
adapters/
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository via sqlx
metadata — OMDb HTTP client
poster-fetcher — downloads poster images
poster-storage — uploads posters to S3-compatible storage
template-askama — Askama HTML rendering
rss — RSS/Atom feed generation
event-publisher — async event channel for background poster sync
```
## Prerequisites
- Rust (stable, 2024 edition)
- SQLite
- An S3-compatible object store (e.g. MinIO) for poster storage
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables
Copy and fill in the following (e.g. in a `.env` file):
```env
# Database
DATABASE_URL=sqlite://movies.db
# Authentication
JWT_SECRET=change-me
JWT_TTL_SECONDS=86400
# OMDb metadata
OMDB_API_KEY=your-key
# Poster storage (S3-compatible)
MINIO_ENDPOINT=http://localhost:9000
MINIO_BUCKET=posters
MINIO_REGION=us-east-1
MINIO_ACCESS_KEY_ID=minioadmin
MINIO_SECRET_ACCESS_KEY=minioadmin
# Optional
ALLOW_REGISTRATION=false
POSTER_FETCH_TIMEOUT_SECONDS=10
EVENT_CHANNEL_BUFFER=32
RUST_LOG=presentation=debug,tower_http=debug
```
## Run
```bash
cargo run -p presentation
```
Server listens on `0.0.0.0:3000`.
## Test
```bash
cargo test
```
## License
MIT License. See [LICENSE](LICENSE).

View File

@@ -4,3 +4,12 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true }
domain = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
serde = { version = "1.0", features = ["derive"] }
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -1,14 +1,104 @@
pub fn add(left: u64, right: u64) -> u64 { use async_trait::async_trait;
left + right use argon2::{
Argon2,
password_hash::{PasswordHasher as _, PasswordVerifier, SaltString},
};
use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use domain::{
errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId},
};
pub struct AuthConfig {
secret: String,
ttl_seconds: u64,
} }
#[cfg(test)] impl AuthConfig {
mod tests { pub fn from_env() -> anyhow::Result<Self> {
use super::*; let secret = std::env::var("JWT_SECRET")
.map_err(|_| anyhow::anyhow!("JWT_SECRET env var is required"))?;
if secret.is_empty() {
anyhow::bail!("JWT_SECRET must not be empty");
}
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
Ok(Self { secret, ttl_seconds })
}
}
#[test] #[derive(Serialize, Deserialize)]
fn it_works() { struct Claims {
let result = add(2, 2); sub: String,
assert_eq!(result, 4); exp: u64,
}
pub struct JwtAuthService {
config: AuthConfig,
}
impl JwtAuthService {
pub fn new(config: AuthConfig) -> Self {
Self { config }
}
}
#[async_trait]
impl AuthService for JwtAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
let expires_at = Utc::now() + Duration::seconds(self.config.ttl_seconds as i64);
let claims = Claims {
sub: user_id.value().to_string(),
exp: expires_at.timestamp() as u64,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.config.secret.as_bytes()),
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(GeneratedToken { token, expires_at })
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.config.secret.as_bytes()),
&Validation::default(),
)
.map_err(|_| DomainError::Unauthorized("Invalid or expired token".into()))?;
let uuid = Uuid::parse_str(&data.claims.sub)
.map_err(|_| DomainError::Unauthorized("Invalid token subject".into()))?;
Ok(UserId::from_uuid(uuid))
}
}
pub struct Argon2PasswordHasher;
#[async_trait]
impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain_password.as_bytes(), &salt)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_string();
PasswordHash::new(hash).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
let parsed = argon2::password_hash::PasswordHash::new(hash.value())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Argon2::default()
.verify_password(plain_password.as_bytes(), &parsed)
.is_ok())
} }
} }

View File

@@ -0,0 +1,10 @@
[package]
name = "event-publisher"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,209 @@
use async_trait::async_trait;
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
use tokio::sync::mpsc;
pub struct EventPublisherConfig {
pub channel_buffer: usize,
}
impl EventPublisherConfig {
pub fn from_env() -> Self {
let channel_buffer = std::env::var("EVENT_CHANNEL_BUFFER")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128);
Self { channel_buffer }
}
}
#[async_trait]
pub trait EventHandler: Send + Sync {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
pub struct ChannelEventPublisher {
sender: mpsc::Sender<DomainEvent>,
}
#[async_trait]
impl EventPublisher for ChannelEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.sender
.send(event.clone())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
pub struct EventWorker {
receiver: mpsc::Receiver<DomainEvent>,
handlers: Vec<Box<dyn EventHandler>>,
}
impl EventWorker {
pub async fn run(mut self) {
while let Some(event) = self.receiver.recv().await {
match &event {
DomainEvent::ReviewLogged {
review_id,
movie_id,
user_id,
rating,
watched_at,
} => {
tracing::info!(
review_id = %review_id.value(),
movie_id = %movie_id.value(),
user_id = %user_id.value(),
rating = rating.value(),
watched_at = %watched_at,
"event: review_logged"
);
}
DomainEvent::MovieDiscovered {
movie_id,
external_metadata_id,
} => {
tracing::info!(
movie_id = %movie_id.value(),
external_id = external_metadata_id.value(),
"event: movie_discovered"
);
}
}
for handler in &self.handlers {
if let Err(e) = handler.handle(&event).await {
tracing::error!("event handler error: {e}");
}
}
}
tracing::info!("event worker shut down");
}
}
pub struct NoopEventPublisher;
#[async_trait]
impl EventPublisher for NoopEventPublisher {
async fn publish(&self, _event: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
pub fn create_event_channel(
config: EventPublisherConfig,
handlers: Vec<Box<dyn EventHandler>>,
) -> (ChannelEventPublisher, EventWorker) {
let (tx, rx) = mpsc::channel(config.channel_buffer);
(
ChannelEventPublisher { sender: tx },
EventWorker {
receiver: rx,
handlers,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId},
};
struct RecordingHandler {
calls: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl EventHandler for RecordingHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let label = match event {
DomainEvent::MovieDiscovered { .. } => "movie_discovered",
DomainEvent::ReviewLogged { .. } => "review_logged",
};
self.calls.lock().unwrap().push(label.to_string());
Ok(())
}
}
#[tokio::test]
async fn single_handler_receives_event() {
let calls = Arc::new(Mutex::new(vec![]));
let handler = RecordingHandler { calls: Arc::clone(&calls) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(config, vec![Box::new(handler)]);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt1234567".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(*calls.lock().unwrap(), vec!["movie_discovered"]);
}
#[tokio::test]
async fn multiple_handlers_all_receive_event() {
let calls1 = Arc::new(Mutex::new(vec![]));
let calls2 = Arc::new(Mutex::new(vec![]));
let handler1 = RecordingHandler { calls: Arc::clone(&calls1) };
let handler2 = RecordingHandler { calls: Arc::clone(&calls2) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(handler1), Box::new(handler2)],
);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt9999999".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(calls1.lock().unwrap().len(), 1);
assert_eq!(calls2.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn handler_error_does_not_stop_worker() {
struct FailingHandler;
#[async_trait]
impl EventHandler for FailingHandler {
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
let calls = Arc::new(Mutex::new(vec![]));
let good = RecordingHandler { calls: Arc::clone(&calls) };
let config = EventPublisherConfig { channel_buffer: 8 };
let (publisher, worker) = create_event_channel(
config,
vec![Box::new(FailingHandler), Box::new(good)],
);
let handle = tokio::spawn(worker.run());
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::generate(),
external_metadata_id: ExternalMetadataId::new("tt0000001".into()).unwrap(),
};
publisher.publish(&event).await.unwrap();
drop(publisher);
handle.await.unwrap();
assert_eq!(calls.lock().unwrap().len(), 1);
}
}

View File

@@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
domain = { workspace = true }

View File

@@ -1,14 +1,54 @@
pub fn add(left: u64, right: u64) -> u64 { use async_trait::async_trait;
left + right use domain::{
errors::DomainError,
models::Movie,
ports::{MetadataClient, MetadataSearchCriteria},
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
mod omdb;
pub(crate) struct ProviderMovie {
pub imdb_id: ExternalMetadataId,
pub title: MovieTitle,
pub release_year: ReleaseYear,
pub director: Option<String>,
pub poster_url: Option<PosterUrl>,
} }
#[cfg(test)] #[async_trait]
mod tests { pub(crate) trait MetadataProvider: Send + Sync {
use super::*; async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError>;
}
#[test] pub struct MetadataClientImpl {
fn it_works() { provider: Box<dyn MetadataProvider>,
let result = add(2, 2); }
assert_eq!(result, 4);
impl MetadataClientImpl {
pub fn new_omdb(api_key: String) -> Self {
Self {
provider: Box::new(omdb::OmdbProvider::new(api_key)),
}
}
}
#[async_trait]
impl MetadataClient for MetadataClientImpl {
async fn fetch_movie_metadata(
&self,
criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
let pm = self.provider.fetch(criteria).await?;
Ok(Movie::new(Some(pm.imdb_id), pm.title, pm.release_year, pm.director, None))
}
async fn get_poster_url(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
let criteria = MetadataSearchCriteria::ImdbId(external_metadata_id.clone());
let pm = self.provider.fetch(&criteria).await?;
Ok(pm.poster_url)
} }
} }

View File

@@ -0,0 +1,119 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::MetadataSearchCriteria,
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
};
use serde::Deserialize;
use crate::{MetadataProvider, ProviderMovie};
pub(crate) struct OmdbProvider {
client: reqwest::Client,
api_key: String,
base_url: String,
}
impl OmdbProvider {
pub(crate) fn new(api_key: String) -> Self {
Self {
client: reqwest::Client::new(),
api_key,
base_url: "http://www.omdbapi.com/".to_string(),
}
}
}
#[derive(Deserialize)]
struct OmdbResponse {
#[serde(rename = "Title")]
title: String,
#[serde(rename = "Year")]
year: String,
#[serde(rename = "Director")]
director: String,
#[serde(rename = "Poster")]
poster: String,
#[serde(rename = "imdbID")]
imdb_id: String,
#[serde(rename = "Response")]
response: String,
#[serde(rename = "Error")]
error: Option<String>,
}
#[async_trait]
impl MetadataProvider for OmdbProvider {
async fn fetch(&self, criteria: &MetadataSearchCriteria) -> Result<ProviderMovie, DomainError> {
let mut url = reqwest::Url::parse(&self.base_url)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
{
let mut params = url.query_pairs_mut();
params.append_pair("apikey", &self.api_key);
match criteria {
MetadataSearchCriteria::ImdbId(id) => {
params.append_pair("i", id.value());
}
MetadataSearchCriteria::Title { title, year } => {
params.append_pair("t", title);
if let Some(y) = year {
params.append_pair("y", &y.to_string());
}
}
}
}
let http_resp = self
.client
.get(url)
.send()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
let resp: OmdbResponse = http_resp
.json()
.await
.map_err(|e: reqwest::Error| DomainError::InfrastructureError(e.to_string()))?;
if resp.response != "True" {
let msg = resp.error.unwrap_or_default();
return if msg.to_lowercase().contains("not found") {
Err(DomainError::NotFound(msg))
} else {
Err(DomainError::InfrastructureError(msg))
};
}
let year: u16 = resp
.year
.chars()
.take(4)
.collect::<String>()
.parse()
.map_err(|_| {
DomainError::InfrastructureError(format!("Unparseable year: {}", resp.year))
})?;
let imdb_id = ExternalMetadataId::new(resp.imdb_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let title = MovieTitle::new(resp.title)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let release_year = ReleaseYear::new(year)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let director = match resp.director.as_str() {
"N/A" | "" => None,
d => Some(d.to_string()),
};
let poster_url = match resp.poster.as_str() {
"N/A" | "" => None,
url => PosterUrl::new(url.to_string()).ok(),
};
Ok(ProviderMovie { imdb_id, title, release_year, director, poster_url })
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "poster-fetcher"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
anyhow = { workspace = true }

View File

@@ -0,0 +1,13 @@
pub struct PosterFetcherConfig {
pub timeout_seconds: u64,
}
impl PosterFetcherConfig {
pub fn from_env() -> Self {
let timeout_seconds = std::env::var("POSTER_FETCH_TIMEOUT_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
Self { timeout_seconds }
}
}

View File

@@ -0,0 +1,38 @@
mod config;
pub use config::PosterFetcherConfig;
use std::time::Duration;
use async_trait::async_trait;
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
pub struct ReqwestPosterFetcher {
client: reqwest::Client,
}
impl ReqwestPosterFetcher {
pub fn new(config: PosterFetcherConfig) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.timeout_seconds))
.build()?;
Ok(Self { client })
}
}
#[async_trait]
impl PosterFetcherClient for ReqwestPosterFetcher {
async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError> {
let bytes = self
.client
.get(poster_url.value())
.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.bytes()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(bytes.to_vec())
}
}

View File

@@ -0,0 +1,16 @@
[package]
name = "poster-storage"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
object_store = { workspace = true }
infer = "0.19.0"
[dev-dependencies]
tokio = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,38 @@
use anyhow::Context;
use object_store::{aws::AmazonS3Builder, ObjectStore};
use std::sync::Arc;
pub struct StorageConfig {
endpoint: String,
access_key_id: String,
secret_access_key: String,
bucket: String,
region: String,
}
impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
endpoint: std::env::var("MINIO_ENDPOINT").context("MINIO_ENDPOINT required")?,
access_key_id: std::env::var("MINIO_ACCESS_KEY_ID")
.context("MINIO_ACCESS_KEY_ID required")?,
secret_access_key: std::env::var("MINIO_SECRET_ACCESS_KEY")
.context("MINIO_SECRET_ACCESS_KEY required")?,
bucket: std::env::var("MINIO_BUCKET").context("MINIO_BUCKET required")?,
region: std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
})
}
pub fn build_store(self) -> anyhow::Result<Arc<dyn ObjectStore>> {
let store = AmazonS3Builder::new()
.with_endpoint(self.endpoint)
.with_access_key_id(self.access_key_id)
.with_secret_access_key(self.secret_access_key)
.with_bucket_name(self.bucket)
.with_region(self.region)
.with_allow_http(true)
.build()
.context("Failed to build S3/Minio store")?;
Ok(Arc::new(store))
}
}

View File

@@ -0,0 +1,95 @@
mod config;
pub use config::StorageConfig;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::PosterStorage,
value_objects::{MovieId, PosterPath},
};
use object_store::{Attribute, Attributes, PutOptions, path::Path, ObjectStore};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
infer::get(bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream")
}
pub struct PosterStorageAdapter {
store: Arc<dyn ObjectStore>,
}
impl PosterStorageAdapter {
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
Self { store }
}
pub fn from_config(config: StorageConfig) -> anyhow::Result<Self> {
Ok(Self::new(config.build_store()?))
}
}
#[async_trait]
impl PosterStorage for PosterStorageAdapter {
async fn store_poster(
&self,
movie_id: &MovieId,
image_bytes: &[u8],
) -> Result<PosterPath, DomainError> {
let path = Path::from(movie_id.value().to_string());
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() };
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
PosterPath::new(path.to_string())
}
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> {
let path = Path::from(poster_path.value().to_string());
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Poster not found".into()),
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
use uuid::Uuid;
fn adapter() -> PosterStorageAdapter {
PosterStorageAdapter::new(Arc::new(InMemory::new()))
}
#[tokio::test]
async fn store_and_retrieve_round_trip() {
let adapter = adapter();
let movie_id = MovieId::from_uuid(Uuid::new_v4());
let bytes = b"fake-image-bytes";
let path = adapter.store_poster(&movie_id, bytes).await.unwrap();
let retrieved = adapter.get_poster(&path).await.unwrap();
assert_eq!(retrieved, bytes);
}
#[tokio::test]
async fn get_missing_returns_not_found() {
let adapter = adapter();
let path = PosterPath::new("nonexistent".into()).unwrap();
let result = adapter.get_poster(&path).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
}

View File

@@ -4,3 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
rss-feed = { package = "rss", version = "2" }
chrono = { workspace = true }
domain = { workspace = true }
application = { workspace = true }

View File

@@ -1,14 +1,58 @@
pub fn add(left: u64, right: u64) -> u64 { use application::ports::RssFeedRenderer;
left + right use domain::models::DiaryEntry;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter {
feed_title: String,
feed_link: String,
} }
#[cfg(test)] impl RssAdapter {
mod tests { pub fn new(feed_title: String, feed_link: String) -> Self {
use super::*; Self { feed_title, feed_link }
}
}
#[test] impl RssFeedRenderer for RssAdapter {
fn it_works() { fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String> {
let result = add(2, 2); let items = entries
assert_eq!(result, 4); .iter()
.map(|e| {
let title = format!(
"{} ({})",
e.movie().title().value(),
e.movie().release_year().value()
);
let description = match e.review().comment() {
Some(c) => format!("{}/5 — {}", e.review().rating().value(), c.value()),
None => format!("{}/5", e.review().rating().value()),
};
let pub_date = e
.review()
.watched_at()
.and_utc()
.format("%a, %d %b %Y %H:%M:%S +0000")
.to_string();
let guid = GuidBuilder::default()
.value(e.review().id().value().to_string())
.permalink(false)
.build();
ItemBuilder::default()
.title(Some(title))
.description(Some(description))
.pub_date(Some(pub_date))
.guid(Some(guid))
.build()
})
.collect::<Vec<_>>();
let channel = ChannelBuilder::default()
.title(self.feed_title.clone())
.link(self.feed_link.clone())
.description(self.feed_title.clone())
.items(items)
.build();
Ok(channel.to_string())
} }
} }

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -10,3 +10,10 @@ sqlx = { version = "0.8.6", features = [
"uuid", "uuid",
"macros", "macros",
] } ] }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }

View File

@@ -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);

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);

View File

@@ -1,14 +1,354 @@
pub fn add(left: u64, right: u64) -> u64 { use async_trait::async_trait;
left + right use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, SortDirection,
collections::Paginated,
},
ports::MovieRepository,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId},
};
use sqlx::SqlitePool;
mod migrations;
mod models;
mod users;
use models::{DiaryRow, MovieRow, ReviewRow, datetime_to_str};
pub use users::SqliteUserRepository;
pub struct SqliteMovieRepository {
pool: SqlitePool,
} }
#[cfg(test)] impl SqliteMovieRepository {
mod tests { pub fn new(pool: SqlitePool) -> Self {
use super::*; Self { pool }
}
#[test] pub async fn migrate(&self) -> Result<(), DomainError> {
fn it_works() { migrations::run(&self.pool).await
let result = add(2, 2); }
assert_eq!(result, 4);
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<i64, DomainError> {
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_all_diary_rows(
&self,
sort: &SortDirection,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
match sort {
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),
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),
}
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
match sort {
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 ?",
movie_id,
limit,
offset
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err),
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 ?",
movie_id,
limit,
offset
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err),
}
}
}
#[async_trait]
impl MovieRepository for SqliteMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
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<Option<Movie>, DomainError> {
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(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
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> {
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<DomainEvent, DomainError> {
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<Paginated<DiaryEntry>, DomainError> {
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!(
self.count_diary_entries(None),
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
)?,
Some(id) => {
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)
)?
}
};
let items = rows
.into_iter()
.map(DiaryRow::to_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: filter.page.limit,
offset: filter.page.offset,
})
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as!(
ReviewRow,
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at
FROM reviews WHERE id = ?",
id
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::to_domain)
.transpose()
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query!("DELETE FROM reviews WHERE id = ?", id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
let id = movie_id.value().to_string();
sqlx::query!("DELETE FROM movies WHERE id = ?", id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
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::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
} }
} }

View File

@@ -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()))
}

View File

@@ -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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
}
impl MovieRow {
pub fn to_domain(self) -> Result<Movie, DomainError> {
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<String>,
pub watched_at: String,
pub created_at: String,
}
impl ReviewRow {
pub fn to_domain(self) -> Result<Review, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub review_id: String,
pub movie_id: String,
pub user_id: String,
pub rating: i64,
pub comment: Option<String>,
pub watched_at: String,
pub created_at: String,
}
impl DiaryRow {
pub fn to_domain(self) -> Result<DiaryEntry, DomainError> {
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, DomainError> {
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, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}

View File

@@ -0,0 +1,151 @@
use async_trait::async_trait;
use chrono::Utc;
use sqlx::SqlitePool;
use domain::{
errors::DomainError,
models::User,
ports::UserRepository,
value_objects::{Email, PasswordHash, UserId},
};
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value();
let row = sqlx::query!(
"SELECT id, email, password_hash FROM users WHERE email = ?",
email_str
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
None => Ok(None),
Some(r) => {
let id = uuid::Uuid::parse_str(&r.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(r.email)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(r.password_hash)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash)))
}
}
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let id = user.id().value().to_string();
let email = user.email().value();
let hash = user.password_hash().value();
let created_at = Utc::now().to_rfc3339();
let result = sqlx::query!(
"INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
id,
email,
hash,
created_at
)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::ValidationError("Email already registered".into()));
}
Ok(())
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query!(
"SELECT id, email, password_hash FROM users WHERE id = ?",
id_str
)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
None => Ok(None),
Some(r) => {
let uuid = uuid::Uuid::parse_str(&r.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = Email::new(r.email)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(r.password_hash)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(Some(User::from_persistence(UserId::from_uuid(uuid), email, hash)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)"
)
.execute(&pool)
.await
.unwrap();
let repo = SqliteUserRepository::new(pool.clone());
(pool, repo)
}
#[tokio::test]
async fn find_by_id_returns_none_when_not_found() {
let (_, repo) = setup().await;
let result = repo
.find_by_id(&UserId::from_uuid(uuid::Uuid::new_v4()))
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn find_by_id_returns_user_when_found() {
let (pool, repo) = setup().await;
let id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)"
)
.bind(id.to_string())
.bind("test@example.com")
.bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash")
.bind("2026-01-01T00:00:00Z")
.execute(&pool)
.await
.unwrap();
let result = repo
.find_by_id(&UserId::from_uuid(id))
.await
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().email().value(), "test@example.com");
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "template-askama"
version = "0.1.0"
edition = "2024"
[dependencies]
askama = { version = "0.16.0" }
serde = { workspace = true }
domain = { workspace = true }
application = { workspace = true }

View File

@@ -0,0 +1,86 @@
use askama::Template;
use application::ports::{
HtmlPageContext, HtmlRenderer, LoginPageData, NewReviewPageData, RegisterPageData,
};
use domain::models::{DiaryEntry, collections::Paginated};
#[derive(Template)]
#[template(path = "diary.html")]
struct DiaryTemplate<'a> {
entries: &'a [DiaryEntry],
current_offset: u32,
limit: u32,
has_more: bool,
ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "login.html")]
struct LoginTemplate<'a> {
error: Option<&'a str>,
ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "register.html")]
struct RegisterTemplate<'a> {
error: Option<&'a str>,
ctx: &'a HtmlPageContext,
}
#[derive(Template)]
#[template(path = "new_review.html")]
struct NewReviewTemplate<'a> {
error: Option<&'a str>,
ctx: &'a HtmlPageContext,
}
pub struct AskamaHtmlRenderer;
impl AskamaHtmlRenderer {
pub fn new() -> Self {
Self {}
}
}
impl HtmlRenderer for AskamaHtmlRenderer {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String> {
let has_more = (data.offset + data.limit) < data.total_count as u32;
DiaryTemplate {
entries: &data.items,
current_offset: data.offset,
limit: data.limit,
has_more,
ctx: &ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String> {
LoginTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String> {
RegisterTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String> {
NewReviewTemplate {
error: data.error,
ctx: &data.ctx,
}
.render()
.map_err(|e| e.to_string())
}
}

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Movies Diary</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header>
<a href="/" class="site-title">Movies Diary</a>
<nav>
<a href="/feed.rss">RSS</a>
{% if let Some(email) = ctx.user_email %}
<a href="/reviews/new">Add Review</a>
<span class="user-email">{{ email }}</span>
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>
{% if ctx.register_enabled %}
<a href="/register">Register</a>
{% endif %}
{% endif %}
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block content %}
<div class="diary">
{% for entry in entries %}
<article class="entry">
{% if let Some(poster) = entry.movie().poster_path() %}
<div class="poster">
<img src="/posters/{{ poster.value() }}" alt="">
</div>
{% endif %}
<div class="entry-body">
<div class="entry-title">
{{ entry.movie().title().value() }}
<span class="year">({{ entry.movie().release_year().value() }})</span>
</div>
{% if let Some(dir) = entry.movie().director() %}
<div class="director">{{ dir }}</div>
{% endif %}
<div class="rating">
<span class="star {% if entry.review().rating().value() >= 1 %}filled{% else %}empty{% endif %}"></span>
<span class="star {% if entry.review().rating().value() >= 2 %}filled{% else %}empty{% endif %}"></span>
<span class="star {% if entry.review().rating().value() >= 3 %}filled{% else %}empty{% endif %}"></span>
<span class="star {% if entry.review().rating().value() >= 4 %}filled{% else %}empty{% endif %}"></span>
<span class="star {% if entry.review().rating().value() >= 5 %}filled{% else %}empty{% endif %}"></span>
</div>
{% if let Some(comment) = entry.review().comment() %}
<div class="comment">{{ comment.value() }}</div>
{% endif %}
<div class="watched-at">{{ entry.review().watched_at().format("%Y-%m-%d") }}</div>
{% if let Some(uid) = ctx.user_id %}
{% if *uid == entry.review().user_id().value() %}
<form method="post" action="/reviews/{{ entry.review().id().value() }}/delete" class="delete-form">
<button type="submit">Delete</button>
</form>
{% endif %}
{% endif %}
</div>
</article>
{% else %}
<p class="empty">No movies logged yet.</p>
{% endfor %}
</div>
<nav class="pagination">
{% if current_offset > 0 %}
<a href="/?offset={{ current_offset - limit }}">&larr; Prev</a>
{% endif %}
{% if has_more %}
<a href="/?offset={{ current_offset + limit }}">Next &rarr;</a>
{% endif %}
</nav>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<h1>Login</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="POST" action="/login">
<label>
Email<br>
<input type="email" name="email" required autocomplete="email">
</label>
<label>
Password<br>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Login</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<h1>Log a Review</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="POST" action="/reviews">
<label>
OMDB ID <span class="optional">(optional)</span><br>
<input type="text" name="external_metadata_id" placeholder="tt0166924">
</label>
<hr>
<label>
Title<br>
<input type="text" name="manual_title">
</label>
<label>
Year<br>
<input type="number" name="manual_release_year" min="1888" max="2100">
</label>
<label>
Director<br>
<input type="text" name="manual_director">
</label>
<hr>
<label>
Rating (05)<br>
<input type="number" name="rating" min="0" max="5" required>
</label>
<label>
Watched<br>
<input type="datetime-local" name="watched_at" required>
</label>
<label>
Comment<br>
<textarea name="comment"></textarea>
</label>
<button type="submit">Log Review</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<h1>Register</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<form method="POST" action="/register">
<label>
Email<br>
<input type="email" name="email" required autocomplete="email">
</label>
<label>
Password<br>
<input type="password" name="password" required autocomplete="new-password">
</label>
<button type="submit">Register</button>
</form>
{% endblock %}

View File

@@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,36 @@
use chrono::NaiveDateTime;
use uuid::Uuid;
pub struct LogReviewCommand {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub user_id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
}
#[derive(Clone)]
pub struct SyncPosterCommand {
pub movie_id: Uuid,
pub external_metadata_id: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}
pub struct RegisterCommand {
pub email: String,
pub password: String,
}
pub struct DeleteReviewCommand {
pub review_id: Uuid,
pub requesting_user_id: Uuid,
}

View File

@@ -0,0 +1,13 @@
#[derive(Clone)]
pub struct AppConfig {
pub allow_registration: bool,
}
impl AppConfig {
pub fn from_env() -> Self {
let allow_registration = std::env::var("ALLOW_REGISTRATION")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
Self { allow_registration }
}
}

View File

@@ -0,0 +1,21 @@
use std::sync::Arc;
use domain::ports::{
AuthService, EventPublisher, MetadataClient, MovieRepository, PasswordHasher,
PosterFetcherClient, PosterStorage, UserRepository,
};
use crate::config::AppConfig;
#[derive(Clone)]
pub struct AppContext {
pub repository: Arc<dyn MovieRepository>,
pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
pub poster_storage: Arc<dyn PosterStorage>,
pub event_publisher: Arc<dyn EventPublisher>,
pub auth_service: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub user_repository: Arc<dyn UserRepository>,
pub config: AppConfig,
}

View File

@@ -1,14 +1,7 @@
pub fn add(left: u64, right: u64) -> u64 { pub mod commands;
left + right pub mod config;
} pub mod context;
pub mod movie_resolver;
#[cfg(test)] pub mod ports;
mod tests { pub mod queries;
use super::*; pub mod use_cases;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View File

@@ -0,0 +1,583 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::Movie,
ports::{MetadataClient, MetadataSearchCriteria, MovieRepository},
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
};
use crate::commands::LogReviewCommand;
pub struct MovieResolverDeps<'a> {
pub repository: &'a dyn MovieRepository,
pub metadata_client: &'a dyn MetadataClient,
}
#[async_trait]
pub trait ResolutionStrategy: Send + Sync {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool;
async fn resolve(
&self,
cmd: &LogReviewCommand,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError>;
}
pub struct ExternalIdStrategy;
pub struct TitleSearchStrategy;
pub struct ManualMovieStrategy;
pub struct MovieResolver {
strategies: Vec<Box<dyn ResolutionStrategy>>,
}
impl MovieResolver {
pub fn default_pipeline() -> Self {
Self {
strategies: vec![
Box::new(ExternalIdStrategy),
Box::new(TitleSearchStrategy),
Box::new(ManualMovieStrategy),
],
}
}
pub async fn resolve(
&self,
cmd: &LogReviewCommand,
deps: &MovieResolverDeps<'_>,
) -> Result<(Movie, bool), DomainError> {
for strategy in &self.strategies {
if strategy.can_handle(cmd) {
if let Some(result) = strategy.resolve(cmd, deps).await? {
return Ok(result);
}
}
}
Err(DomainError::ValidationError(
"Manual title required if TMDB fetch fails or is omitted".into(),
))
}
}
#[async_trait]
impl ResolutionStrategy for ExternalIdStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.external_metadata_id.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let ext_id_str = cmd.external_metadata_id.as_deref().unwrap();
let tmdb_id = ExternalMetadataId::new(ext_id_str.to_string())?;
if let Some(m) = deps.repository.get_movie_by_external_id(&tmdb_id).await? {
return Ok(Some((m, false)));
}
match deps
.metadata_client
.fetch_movie_metadata(&MetadataSearchCriteria::ImdbId(tmdb_id))
.await
{
Ok(m) => Ok(Some((m, true))),
Err(e) => {
tracing::warn!(
"Failed to fetch from TMDB, falling back to manual entry: {:?}",
e
);
Ok(None)
}
}
}
}
#[async_trait]
impl ResolutionStrategy for TitleSearchStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title = cmd.manual_title.as_deref().unwrap();
let criteria = MetadataSearchCriteria::Title {
title: title.to_string(),
year: cmd.manual_release_year,
};
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => Ok(Some((m, true))),
Err(e) => {
tracing::warn!("OMDb title search failed, falling back to manual: {:?}", e);
Ok(None)
}
}
}
}
#[async_trait]
impl ResolutionStrategy for ManualMovieStrategy {
fn can_handle(&self, cmd: &LogReviewCommand) -> bool {
cmd.manual_title.is_some()
}
async fn resolve(
&self,
cmd: &LogReviewCommand,
deps: &MovieResolverDeps<'_>,
) -> Result<Option<(Movie, bool)>, DomainError> {
let title_str = match &cmd.manual_title {
Some(t) => t,
None => return Ok(None),
};
let year_val = cmd.manual_release_year.ok_or_else(|| {
DomainError::ValidationError(
"Manual release year required if TMDB fetch fails or is omitted".into(),
)
})?;
let title = MovieTitle::new(title_str.clone())?;
let release_year = ReleaseYear::new(year_val)?;
let candidates = deps
.repository
.get_movies_by_title_and_year(&title, &release_year)
.await?;
let matched = candidates
.into_iter()
.find(|m| m.is_manual_match(&title, &release_year, cmd.manual_director.as_deref()));
if let Some(existing) = matched {
Ok(Some((existing, false)))
} else {
let new_movie =
Movie::new(None, title, release_year, cmd.manual_director.clone(), None);
Ok(Some((new_movie, true)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated},
ports::{MetadataSearchCriteria, MovieRepository},
value_objects::{
ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear, ReviewId,
},
};
fn make_cmd(ext_id: Option<&str>, title: Option<&str>, year: Option<u16>) -> LogReviewCommand {
LogReviewCommand {
external_metadata_id: ext_id.map(String::from),
manual_title: title.map(String::from),
manual_release_year: year,
manual_director: None,
user_id: uuid::Uuid::new_v4(),
rating: 4,
comment: None,
watched_at: NaiveDate::from_ymd_opt(2024, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
}
}
fn make_movie() -> Movie {
Movie::new(
None,
MovieTitle::new("Inception".to_string()).unwrap(),
ReleaseYear::new(2010).unwrap(),
None,
None,
)
}
struct RepoWithExternalMovie(Movie);
struct RepoEmpty;
struct RepoWithTitleMatch(Movie);
#[async_trait]
impl MovieRepository for RepoWithExternalMovie {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(Some(self.0.clone()))
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
panic!("unexpected")
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
panic!("unexpected")
}
async fn query_diary(
&self,
_: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("unexpected")
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("unexpected")
}
async fn get_review_by_id(
&self,
_: &ReviewId,
) -> Result<Option<Review>, DomainError> {
panic!("unexpected")
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoEmpty {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
Ok(None)
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
panic!("unexpected")
}
async fn query_diary(
&self,
_: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("unexpected")
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("unexpected")
}
async fn get_review_by_id(
&self,
_: &ReviewId,
) -> Result<Option<Review>, DomainError> {
panic!("unexpected")
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MovieRepository for RepoWithTitleMatch {
async fn get_movie_by_external_id(
&self,
_: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> {
panic!("unexpected")
}
async fn get_movies_by_title_and_year(
&self,
_: &MovieTitle,
_: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
Ok(vec![self.0.clone()])
}
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
panic!("unexpected")
}
async fn query_diary(
&self,
_: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("unexpected")
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("unexpected")
}
async fn get_review_by_id(
&self,
_: &ReviewId,
) -> Result<Option<Review>, DomainError> {
panic!("unexpected")
}
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
panic!("unexpected")
}
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!("unexpected")
}
}
struct MetaReturnsMovie(Movie);
struct MetaErrors;
#[async_trait]
impl MetadataClient for MetaReturnsMovie {
async fn fetch_movie_metadata(
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Ok(self.0.clone())
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!("unexpected")
}
}
#[async_trait]
impl MetadataClient for MetaErrors {
async fn fetch_movie_metadata(
&self,
_: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError("metadata unavailable".into()))
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!("unexpected")
}
}
// --- ExternalIdStrategy ---
#[test]
fn external_id_strategy_can_handle_cmd_with_id() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(ExternalIdStrategy.can_handle(&cmd));
}
#[test]
fn external_id_strategy_cannot_handle_cmd_without_id() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(!ExternalIdStrategy.can_handle(&cmd));
}
#[tokio::test]
async fn external_id_strategy_returns_cached_movie() {
let movie = make_movie();
let repo = RepoWithExternalMovie(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
#[tokio::test]
async fn external_id_strategy_fetches_from_metadata_when_not_cached() {
let movie = make_movie();
let repo = RepoEmpty;
let meta = MetaReturnsMovie(movie);
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn external_id_strategy_falls_through_on_metadata_error() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let result = ExternalIdStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(result.is_none());
}
// --- TitleSearchStrategy ---
#[test]
fn title_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(TitleSearchStrategy.can_handle(&cmd));
}
#[test]
fn title_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!TitleSearchStrategy.can_handle(&cmd));
}
#[tokio::test]
async fn title_strategy_fetches_from_metadata() {
let movie = make_movie();
let repo = RepoEmpty;
let meta = MetaReturnsMovie(movie);
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn title_strategy_falls_through_on_metadata_error() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = TitleSearchStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(result.is_none());
}
// --- ManualMovieStrategy ---
#[test]
fn manual_strategy_can_handle_cmd_with_title() {
let cmd = make_cmd(None, Some("Inception"), Some(2010));
assert!(ManualMovieStrategy.can_handle(&cmd));
}
#[test]
fn manual_strategy_cannot_handle_cmd_without_title() {
let cmd = make_cmd(Some("tt123"), None, None);
assert!(!ManualMovieStrategy.can_handle(&cmd));
}
#[tokio::test]
async fn manual_strategy_returns_existing_movie() {
let movie = make_movie();
let repo = RepoWithTitleMatch(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(matches!(result, Some((_, false))));
}
#[tokio::test]
async fn manual_strategy_creates_new_movie_when_no_match() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), Some(2010));
let result = ManualMovieStrategy.resolve(&cmd, &deps).await.unwrap();
assert!(matches!(result, Some((_, true))));
}
#[tokio::test]
async fn manual_strategy_errors_without_year() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, Some("Inception"), None);
assert!(ManualMovieStrategy.resolve(&cmd, &deps).await.is_err());
}
// --- MovieResolver pipeline ---
#[tokio::test]
async fn resolver_returns_error_when_no_strategy_matches() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(None, None, None);
let result = MovieResolver::default_pipeline().resolve(&cmd, &deps).await;
assert!(result.is_err());
}
#[tokio::test]
async fn resolver_uses_cached_movie_when_external_id_matches() {
let movie = make_movie();
let repo = RepoWithExternalMovie(movie.clone());
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), None, None);
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await
.unwrap();
assert!(!is_new);
}
#[tokio::test]
async fn resolver_falls_through_to_manual_when_external_and_title_both_fail() {
let repo = RepoEmpty;
let meta = MetaErrors;
let deps = MovieResolverDeps {
repository: &repo,
metadata_client: &meta,
};
let cmd = make_cmd(Some("tt123"), Some("Inception"), Some(2010));
let (_, is_new) = MovieResolver::default_pipeline()
.resolve(&cmd, &deps)
.await
.unwrap();
assert!(is_new);
}
}

View File

@@ -0,0 +1,35 @@
use uuid::Uuid;
use domain::models::{DiaryEntry, collections::Paginated};
pub struct HtmlPageContext {
pub user_email: Option<String>,
pub user_id: Option<Uuid>,
pub register_enabled: bool,
}
pub struct LoginPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub struct RegisterPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub struct NewReviewPageData<'a> {
pub ctx: HtmlPageContext,
pub error: Option<&'a str>,
}
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>, ctx: HtmlPageContext) -> Result<String, String>;
fn render_login_page(&self, data: LoginPageData<'_>) -> Result<String, String>;
fn render_register_page(&self, data: RegisterPageData<'_>) -> Result<String, String>;
fn render_new_review_page(&self, data: NewReviewPageData<'_>) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync {
fn render_feed(&self, entries: &[DiaryEntry]) -> Result<String, String>;
}

View File

@@ -0,0 +1,13 @@
use domain::models::SortDirection;
use uuid::Uuid;
pub struct GetDiaryQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<SortDirection>,
pub movie_id: Option<Uuid>,
}
pub struct GetReviewHistoryQuery {
pub movie_id: Uuid,
}

View File

@@ -0,0 +1,27 @@
use domain::{errors::DomainError, value_objects::{ReviewId, UserId}};
use crate::{commands::DeleteReviewCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
let review_id = ReviewId::from_uuid(cmd.review_id);
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
let review = ctx
.repository
.get_review_by_id(&review_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("review {}", cmd.review_id)))?;
if review.user_id() != &requesting_user_id {
return Err(DomainError::Unauthorized("not your review".into()));
}
let movie_id = review.movie_id().clone();
ctx.repository.delete_review(&review_id).await?;
let history = ctx.repository.get_review_history(&movie_id).await?;
if history.viewings().is_empty() {
ctx.repository.delete_movie(&movie_id).await?;
}
Ok(())
}

View File

@@ -0,0 +1,29 @@
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, SortDirection,
collections::{PageParams, Paginated},
},
value_objects::MovieId,
};
use crate::{context::AppContext, queries::GetDiaryQuery};
pub async fn execute(
ctx: &AppContext,
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 filter = DiaryFilter {
sort_by: query.sort_by.unwrap_or(SortDirection::Descending),
page,
movie_id,
};
let paginated_results = ctx.repository.query_diary(&filter).await?;
Ok(paginated_results)
}

View File

@@ -0,0 +1,23 @@
use domain::{
errors::DomainError,
models::ReviewHistory,
services::review_history::{ReviewHistoryAnalyzer, Trend},
value_objects::MovieId,
};
use crate::{context::AppContext, queries::GetReviewHistoryQuery};
pub async fn execute(
ctx: &AppContext,
query: GetReviewHistoryQuery,
) -> Result<(ReviewHistory, Trend), DomainError> {
let movie_id = MovieId::from_uuid(query.movie_id);
let mut history = ctx.repository.get_review_history(&movie_id).await?;
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
ReviewHistoryAnalyzer::sort_chronologically(&mut history);
Ok((history, trend))
}

View File

@@ -0,0 +1,53 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, Review},
value_objects::{Comment, Rating, UserId},
};
use crate::{
commands::LogReviewCommand,
context::AppContext,
movie_resolver::{MovieResolver, MovieResolverDeps},
};
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> {
let rating = Rating::new(cmd.rating)?;
let user_id = UserId::from_uuid(cmd.user_id);
let comment = cmd.comment.clone().map(Comment::new).transpose()?;
let deps = MovieResolverDeps {
repository: ctx.repository.as_ref(),
metadata_client: ctx.metadata_client.as_ref(),
};
let (movie, is_new_movie) = MovieResolver::default_pipeline().resolve(&cmd, &deps).await?;
ctx.repository.upsert_movie(&movie).await?;
let review = Review::new(movie.id().clone(), user_id, rating, comment, cmd.watched_at)?;
let review_event = ctx.repository.save_review(&review).await?;
publish_events(ctx, &movie, is_new_movie, review_event).await?;
Ok(())
}
async fn publish_events(
ctx: &AppContext,
movie: &Movie,
is_new_movie: bool,
review_event: DomainEvent,
) -> Result<(), DomainError> {
if is_new_movie {
if let Some(ext_id) = movie.external_metadata_id() {
let discovery_event = DomainEvent::MovieDiscovered {
movie_id: movie.id().clone(),
external_metadata_id: ext_id.clone(),
};
ctx.event_publisher.publish(&discovery_event).await?;
}
}
ctx.event_publisher.publish(&review_event).await?;
Ok(())
}

View File

@@ -0,0 +1,39 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email};
use crate::{commands::LoginCommand, context::AppContext};
pub struct LoginResult {
pub token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: DateTime<Utc>,
}
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> Result<LoginResult, DomainError> {
let email = Email::new(cmd.email)?;
let user = ctx
.user_repository
.find_by_email(&email)
.await?
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?;
let valid = ctx
.password_hasher
.verify(&cmd.password, user.password_hash())
.await?;
if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".into()));
}
let generated = ctx.auth_service.generate_token(user.id()).await?;
Ok(LoginResult {
token: generated.token,
user_id: user.id().value(),
email: user.email().value().to_string(),
expires_at: generated.expires_at,
})
}

View File

@@ -0,0 +1,7 @@
pub mod delete_review;
pub mod get_diary;
pub mod get_review_history;
pub mod log_review;
pub mod login;
pub mod register;
pub mod sync_poster;

View File

@@ -0,0 +1,18 @@
use domain::{errors::DomainError, models::User, value_objects::Email};
use crate::{commands::RegisterCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> {
if !ctx.config.allow_registration {
return Err(DomainError::Unauthorized("Registration is disabled".into()));
}
let email = Email::new(cmd.email)?;
if ctx.user_repository.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError("Email already registered".into()));
}
let hash = ctx.password_hasher.hash(&cmd.password).await?;
ctx.user_repository.save(&User::new(email, hash)).await
}

View File

@@ -0,0 +1,47 @@
use domain::{
errors::DomainError,
value_objects::{ExternalMetadataId, MovieId},
};
use crate::{commands::SyncPosterCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
let movie_id = MovieId::from_uuid(cmd.movie_id);
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
let mut movie = match ctx.repository.get_movie_by_id(&movie_id).await? {
Some(m) => m,
None => {
tracing::warn!(
"Sync cancelled: Movie {} not found in local DB",
movie_id.value()
);
return Err(DomainError::NotFound("Movie not found".into()));
}
};
let poster_url = match ctx
.metadata_client
.get_poster_url(&external_metadata_id)
.await
{
Ok(Some(url)) => url,
Ok(None) => return Ok(()),
Err(e) => {
tracing::warn!("Warning: Failed to find poster URL metadata: {:?}", e);
return Err(e);
}
};
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
let stored_path = ctx
.poster_storage
.store_poster(&movie_id, &image_bytes)
.await?;
movie.update_poster(stored_path);
ctx.repository.upsert_movie(&movie).await?;
Ok(())
}

View File

@@ -1,7 +0,0 @@
[package]
name = "common"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = { workspace = true }

View File

@@ -1 +0,0 @@
pub mod errors;

View File

@@ -10,5 +10,4 @@ async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
common = { workspace = true }
email_address = "0.2.9" email_address = "0.2.9"

View File

@@ -13,4 +13,7 @@ pub enum DomainError {
#[error("Infrastructure failure: {0}")] #[error("Infrastructure failure: {0}")]
InfrastructureError(String), InfrastructureError(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
} }

View File

@@ -1,6 +1,6 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use crate::value_objects::{MovieId, Rating, ReviewId, UserId}; use crate::value_objects::{ExternalMetadataId, MovieId, Rating, ReviewId, UserId};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum DomainEvent { pub enum DomainEvent {
@@ -11,4 +11,8 @@ pub enum DomainEvent {
rating: Rating, rating: Rating,
watched_at: NaiveDateTime, watched_at: NaiveDateTime,
}, },
MovieDiscovered {
movie_id: MovieId,
external_metadata_id: ExternalMetadataId,
},
} }

View File

@@ -27,7 +27,7 @@ pub struct DiaryFilter {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Movie { pub struct Movie {
id: MovieId, id: MovieId,
external_metadata_id: ExternalMetadataId, external_metadata_id: Option<ExternalMetadataId>,
title: MovieTitle, title: MovieTitle,
release_year: ReleaseYear, release_year: ReleaseYear,
director: Option<String>, director: Option<String>,
@@ -36,7 +36,7 @@ pub struct Movie {
impl Movie { impl Movie {
pub fn new( pub fn new(
external_metadata_id: ExternalMetadataId, external_metadata_id: Option<ExternalMetadataId>,
title: MovieTitle, title: MovieTitle,
release_year: ReleaseYear, release_year: ReleaseYear,
director: Option<String>, director: Option<String>,
@@ -52,11 +52,33 @@ impl Movie {
} }
} }
pub fn from_persistence(
id: MovieId,
external_metadata_id: Option<ExternalMetadataId>,
title: MovieTitle,
release_year: ReleaseYear,
director: Option<String>,
poster_path: Option<PosterPath>,
) -> Self {
Self {
id,
external_metadata_id,
title,
release_year,
director,
poster_path,
}
}
pub fn update_poster(&mut self, poster_path: PosterPath) {
self.poster_path = Some(poster_path);
}
pub fn id(&self) -> &MovieId { pub fn id(&self) -> &MovieId {
&self.id &self.id
} }
pub fn external_metadata_id(&self) -> &ExternalMetadataId { pub fn external_metadata_id(&self) -> Option<&ExternalMetadataId> {
&self.external_metadata_id self.external_metadata_id.as_ref()
} }
pub fn title(&self) -> &MovieTitle { pub fn title(&self) -> &MovieTitle {
&self.title &self.title
@@ -72,6 +94,24 @@ impl Movie {
} }
} }
impl Movie {
pub fn is_manual_match(
&self,
title: &MovieTitle,
year: &ReleaseYear,
director: Option<&str>,
) -> bool {
if self.title != *title || self.release_year != *year {
return false;
}
match (self.director(), director) {
(Some(existing_dir), Some(new_dir)) => existing_dir.eq_ignore_ascii_case(new_dir),
_ => true,
}
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Review { pub struct Review {
id: ReviewId, id: ReviewId,
@@ -110,6 +150,26 @@ impl Review {
}) })
} }
pub fn from_persistence(
id: ReviewId,
movie_id: MovieId,
user_id: UserId,
rating: Rating,
comment: Option<Comment>,
watched_at: NaiveDateTime,
created_at: NaiveDateTime,
) -> Self {
Self {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
}
}
pub fn id(&self) -> &ReviewId { pub fn id(&self) -> &ReviewId {
&self.id &self.id
} }
@@ -135,14 +195,43 @@ impl Review {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct DiaryEntry { pub struct DiaryEntry {
pub movie: Movie, movie: Movie,
pub review: Review, review: Review,
}
impl DiaryEntry {
pub fn new(movie: Movie, review: Review) -> Self {
Self { movie, review }
}
pub fn movie(&self) -> &Movie {
&self.movie
}
pub fn review(&self) -> &Review {
&self.review
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ReviewHistory { pub struct ReviewHistory {
pub movie: Movie, movie: Movie,
pub viewings: Vec<Review>, viewings: Vec<Review>,
}
impl ReviewHistory {
pub fn new(movie: Movie, viewings: Vec<Review>) -> Self {
Self { movie, viewings }
}
pub fn movie(&self) -> &Movie {
&self.movie
}
pub fn viewings(&self) -> &[Review] {
&self.viewings
}
pub fn viewings_mut(&mut self) -> &mut Vec<Review> {
&mut self.viewings
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -161,6 +250,10 @@ impl User {
} }
} }
pub fn from_persistence(id: UserId, email: Email, password_hash: PasswordHash) -> Self {
Self { id, email, password_hash }
}
pub fn update_password(&mut self, new_hash: PasswordHash) { pub fn update_password(&mut self, new_hash: PasswordHash) {
self.password_hash = new_hash; self.password_hash = new_hash;
} }

View File

@@ -1,14 +1,29 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc};
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, User, collections::Paginated},
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, UserId}, value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
}; };
#[async_trait] #[async_trait]
pub trait MovieRepository: Send + Sync { pub trait MovieRepository: Send + Sync {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError>;
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError>;
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>; async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>; async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError>;
@@ -17,19 +32,34 @@ pub trait MovieRepository: Send + Sync {
-> Result<Paginated<DiaryEntry>, DomainError>; -> Result<Paginated<DiaryEntry>, DomainError>;
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError>;
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
}
pub enum MetadataSearchCriteria {
ImdbId(ExternalMetadataId),
Title { title: String, year: Option<u16> },
} }
#[async_trait] #[async_trait]
pub trait MetadataClient: Send + Sync { pub trait MetadataClient: Send + Sync {
async fn fetch_movie_metadata( async fn fetch_movie_metadata(
&self, &self,
external_metadata_id: &ExternalMetadataId, criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError>; ) -> Result<Movie, DomainError>;
async fn get_poster_url(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError>;
} }
#[async_trait] #[async_trait]
pub trait PosterFetcherClient: Send + Sync { pub trait PosterFetcherClient: Send + Sync {
async fn fetch_poster_bytes(&self, poster_url: &str) -> Result<Vec<u8>, DomainError>; async fn fetch_poster_bytes(&self, poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -43,11 +73,24 @@ pub trait PosterStorage: Send + Sync {
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>; async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
} }
pub struct GeneratedToken {
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[async_trait] #[async_trait]
pub trait AuthService: Send + Sync { pub trait AuthService: Send + Sync {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>; async fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
} }
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
}
#[async_trait] #[async_trait]
pub trait EventPublisher: Send + Sync { pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;

View File

@@ -12,35 +12,35 @@ pub enum Trend {
impl ReviewHistoryAnalyzer { impl ReviewHistoryAnalyzer {
pub fn sort_chronologically(history: &mut ReviewHistory) { pub fn sort_chronologically(history: &mut ReviewHistory) {
history history
.viewings .viewings_mut()
.sort_by(|a, b| a.watched_at().cmp(&b.watched_at())); .sort_by(|a, b| a.watched_at().cmp(&b.watched_at()));
} }
pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> { pub fn get_latest_rating(history: &ReviewHistory) -> Option<&Rating> {
history history
.viewings .viewings()
.iter() .iter()
.max_by_key(|r| r.watched_at()) .max_by_key(|r| r.watched_at())
.map(|r| r.rating()) .map(|r| r.rating())
} }
pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> { pub fn rating_trend(history: &ReviewHistory) -> Result<Trend, DomainError> {
if history.viewings.len() < 2 { if history.viewings().len() < 2 {
return Ok(Trend::Neutral); return Ok(Trend::Neutral);
} }
let mut sorted_history = history.clone(); let mut sorted_history = history.clone();
Self::sort_chronologically(&mut sorted_history); Self::sort_chronologically(&mut sorted_history);
let latest_review = sorted_history.viewings.pop().unwrap(); let latest_review = sorted_history.viewings().last().unwrap();
let latest_rating = latest_review.rating().value() as f32; let latest_rating = latest_review.rating().value() as f32;
let previous_sum: u32 = sorted_history let previous_sum: u32 = sorted_history
.viewings .viewings()
.iter() .iter()
.map(|r| r.rating().value() as u32) .map(|r| r.rating().value() as u32)
.sum(); .sum();
let historical_average = previous_sum as f32 / sorted_history.viewings.len() as f32; let historical_average = previous_sum as f32 / sorted_history.viewings().len() as f32;
if latest_rating > historical_average { if latest_rating > historical_average {
Ok(Trend::Improved) Ok(Trend::Improved)

View File

@@ -206,3 +206,23 @@ impl PasswordHash {
&self.0 &self.0
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PosterUrl(String);
impl PosterUrl {
pub fn new(url: String) -> Result<Self, DomainError> {
let trimmed = url.trim();
if trimmed.is_empty() {
Err(DomainError::ValidationError(
"Poster URL cannot be empty".into(),
))
} else {
Ok(Self(trimmed.to_string()))
}
}
pub fn value(&self) -> &str {
&self.0
}
}

View File

@@ -4,3 +4,34 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [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 }
dotenvy = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
auth = { workspace = true }
metadata = { workspace = true }
poster-fetcher = { workspace = true }
poster-storage = { workspace = true }
sqlite = { workspace = true }
sqlx = { workspace = true }
template-askama = { workspace = true }
event-publisher = { workspace = true }
rss = { workspace = true }
infer = "0.19.0"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

View File

@@ -0,0 +1,366 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
use domain::{errors::DomainError, models::SortDirection};
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: std::str::FromStr,
T::Err: std::fmt::Display,
{
let s = Option::<String>::deserialize(de)?;
match s.as_deref() {
None | Some("") => Ok(None),
Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
}
}
#[derive(Deserialize)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct LogReviewForm {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub external_metadata_id: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_title: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_release_year: Option<u16>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub manual_director: Option<String>,
pub rating: u8,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Deserialize)]
pub struct LoginForm {
pub email: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct RegisterForm {
pub email: String,
pub password: String,
}
#[derive(Deserialize)]
pub struct ErrorQuery {
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct LogReviewRequest {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Serialize)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
}
#[derive(Serialize)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Serialize)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Serialize)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Serialize)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
pub trend: String,
}
#[derive(Deserialize)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
}
#[derive(Deserialize)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
}
pub struct LogReviewData {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: NaiveDateTime,
}
#[derive(Debug)]
pub struct ParseReviewError {
pub field: &'static str,
pub message: String,
}
impl TryFrom<LogReviewForm> for LogReviewData {
type Error = ParseReviewError;
fn try_from(form: LogReviewForm) -> Result<Self, Self::Error> {
let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M"))
.map_err(|_| ParseReviewError {
field: "watched_at",
message: format!(
"invalid date '{}'; expected YYYY-MM-DDTHH:MM[:SS]",
form.watched_at
),
})?;
Ok(Self {
external_metadata_id: form.external_metadata_id.filter(|s| !s.trim().is_empty()),
manual_title: form.manual_title,
manual_release_year: form.manual_release_year,
manual_director: form.manual_director,
rating: form.rating,
comment: form.comment,
watched_at,
})
}
}
impl TryFrom<LogReviewRequest> for LogReviewData {
type Error = DomainError;
fn try_from(req: LogReviewRequest) -> Result<Self, Self::Error> {
let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S")
.map_err(|_| {
DomainError::ValidationError(
"invalid watched_at; expected YYYY-MM-DDTHH:MM:SS".into(),
)
})?;
Ok(Self {
external_metadata_id: req.external_metadata_id.filter(|s| !s.trim().is_empty()),
manual_title: req.manual_title,
manual_release_year: req.manual_release_year,
manual_director: req.manual_director,
rating: req.rating,
comment: req.comment,
watched_at,
})
}
}
impl LogReviewData {
pub fn into_command(self, user_id: Uuid) -> LogReviewCommand {
LogReviewCommand {
external_metadata_id: self.external_metadata_id,
manual_title: self.manual_title,
manual_release_year: self.manual_release_year,
manual_director: self.manual_director,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
user_id,
}
}
}
impl From<DiaryQueryParams> for GetDiaryQuery {
fn from(p: DiaryQueryParams) -> Self {
GetDiaryQuery {
limit: p.limit,
offset: p.offset,
sort_by: p.sort_by.as_deref().map(|s| {
if s == "asc" {
SortDirection::Ascending
} else {
SortDirection::Descending
}
}),
movie_id: p.movie_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_form(watched_at: &str) -> LogReviewForm {
LogReviewForm {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
}
}
fn make_request(watched_at: &str) -> LogReviewRequest {
LogReviewRequest {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
}
}
#[test]
fn form_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn form_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]
fn form_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_form("not-a-date")).is_err());
}
#[test]
fn api_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn api_rejects_datetime_without_seconds() {
assert!(LogReviewData::try_from(make_request("2024-03-15T20:30")).is_err());
}
#[test]
fn api_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_request("garbage")).is_err());
}
#[test]
fn whitespace_external_id_becomes_none_in_form() {
let mut form = make_form("2024-03-15T20:30:00");
form.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(form).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn whitespace_external_id_becomes_none_in_request() {
let mut req = make_request("2024-03-15T20:30:00");
req.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(req).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn into_command_sets_user_id() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap();
let user_id = Uuid::new_v4();
let cmd = data.into_command(user_id);
assert_eq!(cmd.user_id, user_id);
}
#[test]
fn sort_by_asc_string_becomes_ascending() {
let params = DiaryQueryParams {
sort_by: Some("asc".to_string()),
limit: None,
offset: None,
movie_id: None,
};
let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Ascending)));
}
#[test]
fn sort_by_other_string_becomes_descending() {
let params = DiaryQueryParams {
sort_by: Some("desc".to_string()),
limit: None,
offset: None,
movie_id: None,
};
let query = GetDiaryQuery::from(params);
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending)));
}
#[test]
fn diary_response_serializes_correctly() {
let resp = DiaryResponse {
items: vec![],
total_count: 0,
limit: 20,
offset: 0,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"total_count\":0"));
assert!(json.contains("\"items\":[]"));
}
#[test]
fn diary_query_params_fields_are_optional() {
let params = DiaryQueryParams {
limit: None,
offset: None,
sort_by: None,
movie_id: None,
};
assert!(params.limit.is_none());
assert!(params.sort_by.is_none());
}
#[test]
fn login_request_deserializes() {
let json = r#"{"email":"a@b.com","password":"secret"}"#;
let req: LoginRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.email, "a@b.com");
}
}

View File

@@ -0,0 +1,33 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use domain::errors::DomainError;
pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError {
fn from(err: DomainError) -> Self {
Self(err)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self.0 {
DomainError::InvalidRating { .. } => (StatusCode::BAD_REQUEST, self.0.to_string()),
DomainError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
DomainError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
DomainError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
DomainError::InfrastructureError(_) => {
tracing::error!("Internal Infrastructure Error: {:?}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
};
(status, error_message).into_response()
}
}

Some files were not shown because too many files have changed in this diff Show More