From f2f13176602e5731e59323642c8496ddfe5e6a5a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 10 May 2026 21:23:56 +0200 Subject: [PATCH] importer feature --- ...55c7e0e86b9f6549ae8ae09247806f1321086.json | 12 + ...4b41a7128a465c0957915c7ed7031c6b83fb0.json | 12 + ...8665b15c14fc0b4f6018e548b76474254073e.json | 12 + ...d52957f2f52d8a021cc6f117cb6f241689a07.json | 12 + ...5fabd813a2b39652dd7e18f03f5b10a4beaca.json | 44 + ...0a148d8dc29bbf0df715f895fa4248d83785f.json | 12 + ...bbeafaf39e2f53611dab435d485648eb7e598.json | 12 + ...ef662b169377c5bafdd03583645a39368c910.json | 56 + ...ef33ded742ef03df885686cd5198f9f8e1c01.json | 12 + ...1bd97708b401a6a2290abe6df49fcb7f28a8d.json | 44 + Cargo.lock | 104 +- Cargo.toml | 7 +- Dockerfile | 2 + README.md | 5 +- crates/adapters/event-payload/Cargo.toml | 11 +- crates/adapters/importer/Cargo.toml | 14 + crates/adapters/importer/src/error.rs | 13 + crates/adapters/importer/src/lib.rs | 12 + crates/adapters/importer/src/mapper.rs | 192 ++ crates/adapters/importer/src/parsers/csv.rs | 49 + crates/adapters/importer/src/parsers/json.rs | 43 + crates/adapters/importer/src/parsers/mod.rs | 50 + crates/adapters/importer/src/parsers/xlsx.rs | 64 + crates/adapters/importer/src/types.rs | 57 + crates/adapters/poster-sync/Cargo.toml | 10 + crates/adapters/poster-sync/src/lib.rs | 93 + .../postgres/migrations/0002_import.sql | 21 + .../adapters/postgres/src/import_profile.rs | 125 ++ .../adapters/postgres/src/import_session.rs | 129 ++ crates/adapters/postgres/src/lib.rs | 11 + .../sqlite/migrations/0008_import.sql | 21 + crates/adapters/sqlite/src/import_profile.rs | 106 + crates/adapters/sqlite/src/import_session.rs | 114 ++ crates/adapters/sqlite/src/lib.rs | 11 + crates/adapters/template-askama/src/lib.rs | 66 +- .../template-askama/templates/base.html | 1 + .../templates/import_mapping.html | 52 + .../templates/import_preview.html | 45 + .../templates/import_upload.html | 42 + crates/application/Cargo.toml | 5 + crates/application/src/commands.rs | 42 + crates/application/src/context.rs | 9 +- crates/application/src/event_handlers.rs | 62 - crates/application/src/lib.rs | 1 - crates/application/src/ports.rs | 42 + .../src/use_cases/apply_import_mapping.rs | 62 + .../src/use_cases/apply_import_profile.rs | 20 + .../cleanup_expired_import_sessions.rs | 6 + .../src/use_cases/create_import_session.rs | 44 + .../src/use_cases/delete_import_profile.rs | 12 + .../src/use_cases/execute_import.rs | 84 + .../src/use_cases/list_import_profiles.rs | 6 + crates/application/src/use_cases/mod.rs | 8 + .../src/use_cases/save_import_profile.rs | 18 + crates/domain/src/models/import_profile.rs | 17 + crates/domain/src/models/import_session.rs | 20 + crates/domain/src/models/mod.rs | 5 + crates/domain/src/ports.rs | 26 +- crates/domain/src/value_objects.rs | 2 + crates/presentation/Cargo.toml | 1 + crates/presentation/src/extractors.rs | 21 + crates/presentation/src/handlers.rs | 1711 ----------------- crates/presentation/src/handlers/api.rs | 721 +++++++ crates/presentation/src/handlers/html.rs | 918 +++++++++ crates/presentation/src/handlers/import.rs | 875 +++++++++ crates/presentation/src/handlers/mod.rs | 8 + crates/presentation/src/handlers/posters.rs | 33 + crates/presentation/src/handlers/rss.rs | 65 + crates/presentation/src/main.rs | 14 +- crates/presentation/src/openapi.rs | 30 + crates/presentation/src/routes.rs | 20 +- crates/presentation/tests/api_test.rs | 22 + crates/worker/Cargo.toml | 1 + crates/worker/src/main.rs | 37 +- test_data/generic_export.json | 8 + test_data/imdb_export.csv | 9 + test_data/letterboxd_export.csv | 11 + 77 files changed, 4884 insertions(+), 1810 deletions(-) create mode 100644 .sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json create mode 100644 .sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json create mode 100644 .sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json create mode 100644 .sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json create mode 100644 .sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json create mode 100644 .sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json create mode 100644 .sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json create mode 100644 .sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json create mode 100644 .sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json create mode 100644 .sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json create mode 100644 crates/adapters/importer/Cargo.toml create mode 100644 crates/adapters/importer/src/error.rs create mode 100644 crates/adapters/importer/src/lib.rs create mode 100644 crates/adapters/importer/src/mapper.rs create mode 100644 crates/adapters/importer/src/parsers/csv.rs create mode 100644 crates/adapters/importer/src/parsers/json.rs create mode 100644 crates/adapters/importer/src/parsers/mod.rs create mode 100644 crates/adapters/importer/src/parsers/xlsx.rs create mode 100644 crates/adapters/importer/src/types.rs create mode 100644 crates/adapters/poster-sync/Cargo.toml create mode 100644 crates/adapters/poster-sync/src/lib.rs create mode 100644 crates/adapters/postgres/migrations/0002_import.sql create mode 100644 crates/adapters/postgres/src/import_profile.rs create mode 100644 crates/adapters/postgres/src/import_session.rs create mode 100644 crates/adapters/sqlite/migrations/0008_import.sql create mode 100644 crates/adapters/sqlite/src/import_profile.rs create mode 100644 crates/adapters/sqlite/src/import_session.rs create mode 100644 crates/adapters/template-askama/templates/import_mapping.html create mode 100644 crates/adapters/template-askama/templates/import_preview.html create mode 100644 crates/adapters/template-askama/templates/import_upload.html delete mode 100644 crates/application/src/event_handlers.rs create mode 100644 crates/application/src/use_cases/apply_import_mapping.rs create mode 100644 crates/application/src/use_cases/apply_import_profile.rs create mode 100644 crates/application/src/use_cases/cleanup_expired_import_sessions.rs create mode 100644 crates/application/src/use_cases/create_import_session.rs create mode 100644 crates/application/src/use_cases/delete_import_profile.rs create mode 100644 crates/application/src/use_cases/execute_import.rs create mode 100644 crates/application/src/use_cases/list_import_profiles.rs create mode 100644 crates/application/src/use_cases/save_import_profile.rs create mode 100644 crates/domain/src/models/import_profile.rs create mode 100644 crates/domain/src/models/import_session.rs delete mode 100644 crates/presentation/src/handlers.rs create mode 100644 crates/presentation/src/handlers/api.rs create mode 100644 crates/presentation/src/handlers/html.rs create mode 100644 crates/presentation/src/handlers/import.rs create mode 100644 crates/presentation/src/handlers/mod.rs create mode 100644 crates/presentation/src/handlers/posters.rs create mode 100644 crates/presentation/src/handlers/rss.rs create mode 100644 test_data/generic_export.json create mode 100644 test_data/imdb_export.csv create mode 100644 test_data/letterboxd_export.csv diff --git a/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json b/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json new file mode 100644 index 0000000..ccad005 --- /dev/null +++ b/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086" +} diff --git a/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json b/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json new file mode 100644 index 0000000..e77c708 --- /dev/null +++ b/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0" +} diff --git a/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json b/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json new file mode 100644 index 0000000..7a604fe --- /dev/null +++ b/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e" +} diff --git a/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json b/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json new file mode 100644 index 0000000..c743c41 --- /dev/null +++ b/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07" +} diff --git a/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json b/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json new file mode 100644 index 0000000..3f0c006 --- /dev/null +++ b/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca" +} diff --git a/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json b/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json new file mode 100644 index 0000000..3d3132f --- /dev/null +++ b/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_profiles WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f" +} diff --git a/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json b/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json new file mode 100644 index 0000000..e1cbf34 --- /dev/null +++ b/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598" +} diff --git a/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json b/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json new file mode 100644 index 0000000..320b404 --- /dev/null +++ b/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "parsed_data", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "row_results", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "expires_at", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910" +} diff --git a/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json b/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json new file mode 100644 index 0000000..9f87c40 --- /dev/null +++ b/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01" +} diff --git a/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json b/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json new file mode 100644 index 0000000..5b31e39 --- /dev/null +++ b/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d" +} diff --git a/Cargo.lock b/Cargo.lock index fe3b58f..3dacc9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,8 @@ dependencies = [ "chrono", "domain", "futures", + "importer", + "serde_json", "tokio", "tracing", "uuid", @@ -572,7 +574,7 @@ dependencies = [ "derive_builder", "diligent-date-parser", "never", - "quick-xml", + "quick-xml 0.37.5", ] [[package]] @@ -653,6 +655,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -844,6 +847,21 @@ dependencies = [ "bytes", ] +[[package]] +name = "calamine" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138646b9af2c5d7f1804ea4bf93afc597737d2bd4f7341d67c48b03316976eb1" +dependencies = [ + "byteorder", + "codepage", + "encoding_rs", + "log", + "quick-xml 0.31.0", + "serde", + "zip 2.4.2", +] + [[package]] name = "castaway" version = "0.2.4" @@ -941,6 +959,15 @@ dependencies = [ "cc", ] +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + [[package]] name = "combine" version = "4.6.7" @@ -1634,6 +1661,7 @@ dependencies = [ "chrono", "domain", "serde", + "serde_json", "uuid", ] @@ -1717,6 +1745,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -2378,6 +2407,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +[[package]] +name = "importer" +version = "0.1.0" +dependencies = [ + "calamine", + "csv", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2833,6 +2873,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nats" version = "0.1.0" @@ -3059,7 +3116,7 @@ dependencies = [ "md-5", "parking_lot", "percent-encoding", - "quick-xml", + "quick-xml 0.37.5", "rand 0.8.6", "reqwest 0.12.28", "ring", @@ -3383,6 +3440,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "poster-sync" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "tokio", + "tracing", +] + [[package]] name = "postgres" version = "0.1.0" @@ -3472,6 +3539,7 @@ dependencies = [ "dotenvy", "export", "http-body-util", + "importer", "infer", "metadata", "nats", @@ -3527,6 +3595,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -3993,7 +4071,7 @@ dependencies = [ "atom_syndication", "derive_builder", "never", - "quick-xml", + "quick-xml 0.37.5", ] [[package]] @@ -5529,7 +5607,7 @@ dependencies = [ "url", "utoipa", "utoipa-swagger-ui-vendored", - "zip", + "zip 3.0.0", ] [[package]] @@ -6264,6 +6342,7 @@ dependencies = [ "nats", "poster-fetcher", "poster-storage", + "poster-sync", "postgres", "postgres-event-queue", "postgres-federation", @@ -6462,6 +6541,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zip" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index 995bc77..d188865 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/adapters/metadata", "crates/adapters/poster-fetcher", "crates/adapters/poster-storage", + "crates/adapters/poster-sync", "crates/adapters/rss", "crates/adapters/sqlite", "crates/adapters/postgres", @@ -24,6 +25,7 @@ members = [ "crates/tui", "crates/doc", "crates/worker", + "crates/adapters/importer", ] resolver = "2" @@ -48,7 +50,8 @@ sqlx = { version = "0.8.6", features = [ ] } reqwest = { version = "0.13", features = ["json", "query"] } object_store = { version = "0.11", features = ["aws"] } -axum = { version = "0.8.8", features = ["macros"] } +axum = { version = "0.8.8", features = ["macros", "multipart"] } +csv = "1" domain = { path = "crates/domain" } application = { path = "crates/application" } @@ -57,6 +60,7 @@ auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } poster-fetcher = { path = "crates/adapters/poster-fetcher" } poster-storage = { path = "crates/adapters/poster-storage" } +poster-sync = { path = "crates/adapters/poster-sync" } event-publisher = { path = "crates/adapters/event-publisher" } rss = { path = "crates/adapters/rss" } export = { path = "crates/adapters/export" } @@ -72,3 +76,4 @@ event-payload = { path = "crates/adapters/event-payload" } nats = { path = "crates/adapters/nats" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } +importer = { path = "crates/adapters/importer" } diff --git a/Dockerfile b/Dockerfile index 20c8aa2..a566726 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,9 @@ COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.tom 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/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml +COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/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/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml diff --git a/README.md b/README.md index 7d68f23..52ce4a0 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B - RSS/Atom feed for public subscription (global and per-user) - JWT authentication via cookie (HTML) or Bearer token (REST API) - ActivityPub federation — follow/unfollow remote users on any compatible server, accept/reject/remove followers, pending follow request management -- CSV and JSON diary export (full round-trip: exported files can be re-imported via the TUI bulk import) +- CSV and JSON diary export +- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface - OpenAPI documentation at `/docs` (Swagger UI) and `/scalar` (Scalar) - CSRF protection on all HTML form routes (double-submit cookie, defense-in-depth on top of `SameSite=Strict`) @@ -33,9 +34,11 @@ adapters/ metadata — TMDB / OMDb HTTP client poster-fetcher — downloads poster images poster-storage — stores posters on local filesystem or S3-compatible storage + poster-sync — event handler: triggers poster fetch+store on MovieDiscovered template-askama — Askama HTML rendering rss — RSS/Atom feed generation export — CSV and JSON diary serialization + importer — CSV/TSV/JSON/XLSX parser and column mapper for bulk import event-payload — shared event serialization DTOs (used by all event bus adapters) sqlite-event-queue — durable polling event queue backed by SQLite postgres-event-queue — durable polling event queue backed by PostgreSQL diff --git a/crates/adapters/event-payload/Cargo.toml b/crates/adapters/event-payload/Cargo.toml index c56f6ff..8c62c0a 100644 --- a/crates/adapters/event-payload/Cargo.toml +++ b/crates/adapters/event-payload/Cargo.toml @@ -4,8 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -domain = { workspace = true } -serde = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -anyhow = { workspace = true } +domain = { workspace = true } +serde = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/adapters/importer/Cargo.toml b/crates/adapters/importer/Cargo.toml new file mode 100644 index 0000000..2e793b4 --- /dev/null +++ b/crates/adapters/importer/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "importer" +version = "0.1.0" +edition = "2024" + +[features] +xlsx = ["dep:calamine"] + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +csv = { workspace = true } +calamine = { version = "0.26", optional = true } diff --git a/crates/adapters/importer/src/error.rs b/crates/adapters/importer/src/error.rs new file mode 100644 index 0000000..31ac915 --- /dev/null +++ b/crates/adapters/importer/src/error.rs @@ -0,0 +1,13 @@ +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + #[error("CSV parse error: {0}")] + Csv(String), + #[error("JSON parse error: {0}")] + Json(String), + #[error("XLSX parse error: {0}")] + Xlsx(String), + #[error("Empty file")] + Empty, + #[error("Missing header row")] + NoHeader, +} diff --git a/crates/adapters/importer/src/lib.rs b/crates/adapters/importer/src/lib.rs new file mode 100644 index 0000000..a8a62ee --- /dev/null +++ b/crates/adapters/importer/src/lib.rs @@ -0,0 +1,12 @@ +pub mod error; +pub mod mapper; +pub mod parsers; +pub mod types; + +pub use error::ImportError; +pub use mapper::apply_mapping; +pub use parsers::{parse_csv, parse_json}; +pub use types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform}; + +#[cfg(feature = "xlsx")] +pub use parsers::parse_xlsx; diff --git a/crates/adapters/importer/src/mapper.rs b/crates/adapters/importer/src/mapper.rs new file mode 100644 index 0000000..067e0a9 --- /dev/null +++ b/crates/adapters/importer/src/mapper.rs @@ -0,0 +1,192 @@ +use crate::types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform}; + +pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec { + file.rows.iter().map(|row| { + let result = map_row(row, &file.columns, mappings); + AnnotatedRow { result, is_duplicate: false } + }).collect() +} + +fn map_row(row: &[String], columns: &[String], mappings: &[FieldMapping]) -> RowResult { + let mut import_row = ImportRow::default(); + let mut errors = Vec::new(); + + for mapping in mappings { + let Some(col_idx) = columns.iter().position(|c| c == &mapping.source_column) else { + continue; + }; + let raw_value = row.get(col_idx).map(|s| s.as_str()).unwrap_or("").trim(); + if raw_value.is_empty() { + continue; + } + if let Some(value) = apply_transform(raw_value, &mapping.transform, &mut errors) { + set_field(&mut import_row, &mapping.domain_field, value); + } + } + + if import_row.title.is_none() && import_row.external_metadata_id.is_none() { + errors.push("missing required field: title or external_metadata_id".into()); + } + if import_row.rating.is_none() { + errors.push("missing required field: rating".into()); + } + if import_row.watched_at.is_none() { + errors.push("missing required field: watched_at".into()); + } + + if errors.is_empty() { + RowResult::Valid(import_row) + } else { + let raw = columns.iter() + .zip(row.iter()) + .map(|(c, v)| (c.clone(), v.clone())) + .collect(); + RowResult::Invalid { errors, raw } + } +} + +fn apply_transform(value: &str, transform: &Transform, errors: &mut Vec) -> Option { + match transform { + Transform::Identity => Some(value.to_string()), + Transform::DateFormat(_) => Some(value.to_string()), + Transform::RatingScale(factor) => { + match value.parse::() { + Ok(n) => Some((n * factor).round().to_string()), + Err(_) => { + errors.push(format!("rating '{}' is not a number", value)); + None + } + } + } + } +} + +fn set_field(row: &mut ImportRow, field: &DomainField, value: String) { + match field { + DomainField::Title => row.title = Some(value), + DomainField::ReleaseYear => row.release_year = Some(value), + DomainField::Director => row.director = Some(value), + DomainField::Rating => row.rating = Some(value), + DomainField::WatchedAt => row.watched_at = Some(value), + DomainField::Comment => row.comment = Some(value), + DomainField::ExternalMetadataId => row.external_metadata_id = Some(value), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{DomainField, FieldMapping, ParsedFile, RowResult, Transform}; + + fn sample_file() -> ParsedFile { + ParsedFile { + columns: vec!["Name".into(), "Stars".into(), "Date".into()], + rows: vec![ + vec!["Inception".into(), "10".into(), "2024-01-15".into()], + vec!["Dune".into(), "8".into(), "2024-02-20".into()], + vec!["".into(), "3".into(), "2024-03-01".into()], // missing title → invalid + ], + } + } + + fn full_mappings() -> Vec { + vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + ] + } + + #[test] + fn maps_valid_rows() { + let results = apply_mapping(&sample_file(), &full_mappings()); + assert_eq!(results.len(), 3); + // First two rows are valid + assert!(matches!(results[0].result, RowResult::Valid(_))); + assert!(matches!(results[1].result, RowResult::Valid(_))); + // is_duplicate defaults to false + assert!(!results[0].is_duplicate); + } + + #[test] + fn applies_rating_scale_transform() { + let results = apply_mapping(&sample_file(), &full_mappings()); + if let RowResult::Valid(row) = &results[0].result { + // 10 * 0.5 = 5 + assert_eq!(row.rating.as_deref(), Some("5")); + } else { + panic!("expected Valid"); + } + } + + #[test] + fn marks_missing_required_fields_invalid() { + let results = apply_mapping(&sample_file(), &full_mappings()); + // Row 2 has empty title + assert!(matches!(results[2].result, RowResult::Invalid { .. })); + } + + #[test] + fn ignores_unmapped_columns() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Extra".into()], + rows: vec![vec!["Inception".into(), "ignored".into()]], + }; + let results = apply_mapping(&file, &mappings); + assert_eq!(results.len(), 1); + // Missing rating and watched_at → invalid + assert!(matches!(results[0].result, RowResult::Invalid { .. })); + } + + #[test] + fn nonexistent_source_column_skipped() { + let mappings = vec![ + FieldMapping { source_column: "DoesNotExist".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into()], + rows: vec![vec!["Inception".into()]], + }; + let results = apply_mapping(&file, &mappings); + // Column not found → field not set → invalid (missing title, rating, watched_at) + assert!(matches!(results[0].result, RowResult::Invalid { .. })); + } + + #[test] + fn collects_all_errors_not_just_first() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + // no watched_at mapping + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Stars".into()], + rows: vec![vec!["Inception".into(), "notanumber".into()]], + }; + let results = apply_mapping(&file, &mappings); + if let RowResult::Invalid { errors, .. } = &results[0].result { + assert!(errors.iter().any(|e| e.contains("not a number")), "expected rating error, got: {:?}", errors); + assert!(errors.iter().any(|e| e.contains("watched_at")), "expected watched_at error, got: {:?}", errors); + } else { + panic!("expected Invalid"); + } + } + + #[test] + fn non_numeric_rating_produces_error_in_row() { + let mappings = vec![ + FieldMapping { source_column: "Name".into(), domain_field: DomainField::Title, transform: Transform::Identity }, + FieldMapping { source_column: "Stars".into(), domain_field: DomainField::Rating, transform: Transform::RatingScale(0.5) }, + FieldMapping { source_column: "Date".into(), domain_field: DomainField::WatchedAt, transform: Transform::Identity }, + ]; + let file = ParsedFile { + columns: vec!["Name".into(), "Stars".into(), "Date".into()], + rows: vec![vec!["Inception".into(), "five".into(), "2024-01-15".into()]], + }; + let results = apply_mapping(&file, &mappings); + assert!(matches!(results[0].result, RowResult::Invalid { .. })); + } +} diff --git a/crates/adapters/importer/src/parsers/csv.rs b/crates/adapters/importer/src/parsers/csv.rs new file mode 100644 index 0000000..c9d62cf --- /dev/null +++ b/crates/adapters/importer/src/parsers/csv.rs @@ -0,0 +1,49 @@ +use crate::{ImportError, types::ParsedFile}; + +pub fn parse_csv(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(ImportError::Empty); + } + + let delimiter = detect_delimiter(bytes); + let mut rdr = csv::ReaderBuilder::new() + .delimiter(delimiter) + .from_reader(bytes); + + let columns: Vec = rdr + .headers() + .map_err(|e| ImportError::Csv(e.to_string()))? + .iter() + .map(|s| s.trim().to_string()) + .collect(); + + if columns.is_empty() { + return Err(ImportError::NoHeader); + } + + let rows: Vec> = rdr + .records() + .map(|r| { + r.map_err(|e| ImportError::Csv(e.to_string())) + .map(|rec| { + let mut cells: Vec = rec.iter().map(|f| f.trim().to_string()).collect(); + cells.resize(columns.len(), String::new()); + cells.truncate(columns.len()); + cells + }) + }) + .collect::>()?; + + if rows.is_empty() { + return Err(ImportError::Empty); + } + + Ok(ParsedFile { columns, rows }) +} + +fn detect_delimiter(bytes: &[u8]) -> u8 { + let first_line = bytes.split(|&b| b == b'\n').next().unwrap_or(bytes); + let tabs = first_line.iter().filter(|&&b| b == b'\t').count(); + let commas = first_line.iter().filter(|&&b| b == b',').count(); + if tabs > commas { b'\t' } else { b',' } +} diff --git a/crates/adapters/importer/src/parsers/json.rs b/crates/adapters/importer/src/parsers/json.rs new file mode 100644 index 0000000..fe5c115 --- /dev/null +++ b/crates/adapters/importer/src/parsers/json.rs @@ -0,0 +1,43 @@ +use serde_json::Value; +use crate::{ImportError, types::ParsedFile}; + +pub fn parse_json(bytes: &[u8]) -> Result { + let value: Value = serde_json::from_slice(bytes) + .map_err(|e| ImportError::Json(e.to_string()))?; + + let arr = value.as_array() + .ok_or_else(|| ImportError::Json("expected a JSON array".into()))?; + + if arr.is_empty() { + return Err(ImportError::Empty); + } + + let first = arr[0].as_object() + .ok_or_else(|| ImportError::Json("array elements must be objects".into()))?; + let columns: Vec = first.keys().cloned().collect(); + + if columns.is_empty() { + return Err(ImportError::NoHeader); + } + + let rows: Vec> = arr.iter() + .enumerate() + .map(|(idx, item)| { + let obj = item.as_object() + .ok_or_else(|| ImportError::Json(format!("element at index {} is not an object", idx)))?; + Ok(columns.iter() + .map(|col| obj.get(col).map(value_to_string).unwrap_or_default()) + .collect()) + }) + .collect::>()?; + + Ok(ParsedFile { columns, rows }) +} + +fn value_to_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + } +} diff --git a/crates/adapters/importer/src/parsers/mod.rs b/crates/adapters/importer/src/parsers/mod.rs new file mode 100644 index 0000000..83d297a --- /dev/null +++ b/crates/adapters/importer/src/parsers/mod.rs @@ -0,0 +1,50 @@ +mod csv; +mod json; +#[cfg(feature = "xlsx")] +mod xlsx; + +pub use csv::parse_csv; +pub use json::parse_json; +#[cfg(feature = "xlsx")] +pub use xlsx::parse_xlsx; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csv_parses_headers_and_rows() { + let data = b"title,rating,watched_at\nInception,5,2024-01-01\nDune,4,2024-02-15\n"; + let file = parse_csv(data).unwrap(); + assert_eq!(file.columns, vec!["title", "rating", "watched_at"]); + assert_eq!(file.rows.len(), 2); + assert_eq!(file.rows[0], vec!["Inception", "5", "2024-01-01"]); + } + + #[test] + fn csv_rejects_empty() { + assert!(parse_csv(b"").is_err()); + } + + #[test] + fn tsv_parses_correctly() { + let data = b"title\trating\nInception\t5\n"; + let file = parse_csv(data).unwrap(); + assert_eq!(file.columns, vec!["title", "rating"]); + assert_eq!(file.rows[0], vec!["Inception", "5"]); + } + + #[test] + fn json_array_of_objects() { + let data = br#"[{"title":"Inception","rating":"5"},{"title":"Dune","rating":"4"}]"#; + let file = parse_json(data).unwrap(); + assert_eq!(file.columns.len(), 2); + assert!(file.columns.contains(&"title".to_string())); + assert_eq!(file.rows.len(), 2); + } + + #[test] + fn json_empty_array_errors() { + assert!(parse_json(b"[]").is_err()); + } +} diff --git a/crates/adapters/importer/src/parsers/xlsx.rs b/crates/adapters/importer/src/parsers/xlsx.rs new file mode 100644 index 0000000..4f10254 --- /dev/null +++ b/crates/adapters/importer/src/parsers/xlsx.rs @@ -0,0 +1,64 @@ +use calamine::{Reader, open_workbook_from_rs, Xlsx, Data}; +use std::io::Cursor; +use crate::{ImportError, types::ParsedFile}; + +pub fn parse_xlsx(bytes: &[u8]) -> Result { + let cursor = Cursor::new(bytes); + let mut workbook: Xlsx<_> = open_workbook_from_rs(cursor) + .map_err(|e: calamine::XlsxError| ImportError::Xlsx(e.to_string()))?; + + let sheet_name = workbook.sheet_names() + .first() + .cloned() + .ok_or(ImportError::Empty)?; + + let range = workbook.worksheet_range(&sheet_name) + .map_err(|e| ImportError::Xlsx(e.to_string()))?; + + let mut iter = range.rows(); + + let header = iter.next().ok_or(ImportError::NoHeader)?; + let columns: Vec = header.iter() + .map(|c| cell_to_string(c).trim().to_string()) + .collect(); + + if columns.is_empty() { + return Err(ImportError::NoHeader); + } + + let rows: Vec> = iter + .map(|row| { + let mut cells: Vec = row.iter().map(cell_to_string).collect(); + cells.resize(columns.len(), String::new()); + cells.truncate(columns.len()); + cells + }) + .collect(); + + if rows.is_empty() { + return Err(ImportError::Empty); + } + + Ok(ParsedFile { columns, rows }) +} + +fn cell_to_string(cell: &Data) -> String { + match cell { + Data::String(s) => s.clone(), + Data::Float(f) => { + if f.fract() == 0.0 { format!("{}", *f as i64) } else { format!("{}", f) } + } + Data::Int(i) => i.to_string(), + Data::Bool(b) => b.to_string(), + Data::DateTime(dt) => { + // ExcelDateTime::to_ymd_hms_milli() works without the chrono feature. + let (year, month, day, _, _, _, _) = dt.to_ymd_hms_milli(); + format!("{:04}-{:02}-{:02}", year, month, day) + } + Data::DateTimeIso(s) => s.clone(), + Data::DurationIso(s) => s.clone(), + Data::Empty | Data::Error(_) => String::new(), + // Fallback for unexpected calamine Data variants; renders as debug string + other => format!("{other:?}"), + } +} diff --git a/crates/adapters/importer/src/types.rs b/crates/adapters/importer/src/types.rs new file mode 100644 index 0000000..620895c --- /dev/null +++ b/crates/adapters/importer/src/types.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ParsedFile { + pub columns: Vec, + pub rows: Vec>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainField { + Title, + ReleaseYear, + Director, + Rating, + WatchedAt, + Comment, + ExternalMetadataId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Transform { + RatingScale(f64), + DateFormat(String), + Identity, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldMapping { + pub source_column: String, + pub domain_field: DomainField, + pub transform: Transform, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ImportRow { + pub title: Option, + pub release_year: Option, + pub director: Option, + pub rating: Option, + pub watched_at: Option, + pub comment: Option, + pub external_metadata_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RowResult { + Valid(ImportRow), + Invalid { errors: Vec, raw: Vec<(String, String)> }, +} + +/// Wraps a RowResult with a duplicate flag so this information persists when +/// serialised as JSON into the import_sessions.row_results DB column. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnnotatedRow { + pub result: RowResult, + pub is_duplicate: bool, +} diff --git a/crates/adapters/poster-sync/Cargo.toml b/crates/adapters/poster-sync/Cargo.toml new file mode 100644 index 0000000..4ad9be5 --- /dev/null +++ b/crates/adapters/poster-sync/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "poster-sync" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/adapters/poster-sync/src/lib.rs new file mode 100644 index 0000000..99f3c8b --- /dev/null +++ b/crates/adapters/poster-sync/src/lib.rs @@ -0,0 +1,93 @@ +use std::{sync::Arc, time::Duration}; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventHandler, MetadataClient, MovieRepository, PosterFetcherClient, PosterStorage}, + value_objects::{ExternalMetadataId, MovieId}, +}; + +pub struct PosterSyncHandler { + movie_repository: Arc, + metadata_client: Arc, + poster_fetcher: Arc, + poster_storage: Arc, + max_retries: u32, +} + +impl PosterSyncHandler { + pub fn new( + movie_repository: Arc, + metadata_client: Arc, + poster_fetcher: Arc, + poster_storage: Arc, + max_retries: u32, + ) -> Self { + Self { movie_repository, metadata_client, poster_fetcher, poster_storage, max_retries } + } + + async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { + let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? { + Some(m) => m, + None => { + tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value()); + return Err(DomainError::NotFound("Movie not found".into())); + } + }; + + let poster_url = match self.metadata_client.get_poster_url(&external_metadata_id).await { + Ok(Some(url)) => url, + Ok(None) => return Ok(()), + Err(e) => { + tracing::warn!("Failed to find poster URL: {:?}", e); + return Err(e); + } + }; + + let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; + let stored_path = self.poster_storage.store_poster(&movie_id, &image_bytes).await?; + + movie.update_poster(stored_path); + self.movie_repository.upsert_movie(&movie).await + } +} + +#[async_trait] +impl EventHandler for PosterSyncHandler { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + let (movie_id, external_metadata_id) = match event { + DomainEvent::MovieDiscovered { movie_id, external_metadata_id } => { + (movie_id.value(), external_metadata_id.value().to_owned()) + } + _ => return Ok(()), + }; + + let movie_id = MovieId::from_uuid(movie_id); + let external_metadata_id = ExternalMetadataId::new(external_metadata_id)?; + + let mut last_err: Option = None; + for attempt in 0..=self.max_retries { + match self.sync(movie_id.clone(), external_metadata_id.clone()).await { + Ok(()) => return Ok(()), + Err(e) => { + if attempt < self.max_retries { + let delay = Duration::from_secs(2u64.pow(attempt)); + tracing::warn!( + attempt = attempt + 1, + max_attempts = self.max_retries + 1, + delay_secs = delay.as_secs(), + "poster sync failed, retrying: {e}" + ); + tokio::time::sleep(delay).await; + } + last_err = Some(e); + } + } + } + + let err = last_err.expect("loop runs at least once"); + tracing::error!(attempts = self.max_retries + 1, "poster sync failed after all attempts: {err}"); + Err(err) + } +} diff --git a/crates/adapters/postgres/migrations/0002_import.sql b/crates/adapters/postgres/migrations/0002_import.sql new file mode 100644 index 0000000..97c0edd --- /dev/null +++ b/crates/adapters/postgres/migrations/0002_import.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS import_sessions ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + parsed_data TEXT NOT NULL, + field_mappings TEXT, + row_results TEXT, + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS import_profiles ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + field_mappings TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id ON import_sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_import_sessions_expires_at ON import_sessions (expires_at); +CREATE INDEX IF NOT EXISTS idx_import_profiles_user_id ON import_profiles (user_id); diff --git a/crates/adapters/postgres/src/import_profile.rs b/crates/adapters/postgres/src/import_profile.rs new file mode 100644 index 0000000..077187f --- /dev/null +++ b/crates/adapters/postgres/src/import_profile.rs @@ -0,0 +1,125 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use domain::{ + errors::DomainError, + models::ImportProfile, + ports::ImportProfileRepository, + value_objects::{ImportProfileId, UserId}, +}; +use sqlx::PgPool; + +pub struct PostgresImportProfileRepository { + pool: PgPool, +} + +impl PostgresImportProfileRepository { + pub fn new(pool: PgPool) -> Self { Self { pool } } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("DB error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl ImportProfileRepository for PostgresImportProfileRepository { + async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { + let id = p.id.value().to_string(); + let user_id = p.user_id.value().to_string(); + sqlx::query( + "INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, field_mappings = EXCLUDED.field_mappings", + ) + .bind(&id) + .bind(&user_id) + .bind(&p.name) + .bind(&p.field_mappings) + .bind(p.created_at) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + + #[derive(sqlx::FromRow)] + struct Row { + id: String, + user_id: String, + name: String, + field_mappings: String, + created_at: NaiveDateTime, + } + + let rows = sqlx::query_as::<_, Row>( + "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + + rows.into_iter().map(|r| -> Result { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: r.field_mappings, + created_at: r.created_at, + }) + }).collect() + } + + async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let uid_str = user_id.value().to_string(); + + #[derive(sqlx::FromRow)] + struct Row { + id: String, + user_id: String, + name: String, + field_mappings: String, + created_at: NaiveDateTime, + } + + let row = sqlx::query_as::<_, Row>( + "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", + ) + .bind(&id_str) + .bind(&uid_str) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(row.map(|r| -> Result { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: r.field_mappings, + created_at: r.created_at, + }) + }).transpose()?) + } + + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + sqlx::query("DELETE FROM import_profiles WHERE id = $1") + .bind(&id_str) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } +} diff --git a/crates/adapters/postgres/src/import_session.rs b/crates/adapters/postgres/src/import_session.rs new file mode 100644 index 0000000..ea802a5 --- /dev/null +++ b/crates/adapters/postgres/src/import_session.rs @@ -0,0 +1,129 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use domain::{ + errors::DomainError, + models::ImportSession, + ports::ImportSessionRepository, + value_objects::{ImportSessionId, UserId}, +}; +use sqlx::PgPool; + +pub struct PostgresImportSessionRepository { + pool: PgPool, +} + +impl PostgresImportSessionRepository { + pub fn new(pool: PgPool) -> Self { Self { pool } } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("DB error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } +} + +#[async_trait] +impl ImportSessionRepository for PostgresImportSessionRepository { + async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { + let id = s.id.value().to_string(); + let user_id = s.user_id.value().to_string(); + sqlx::query( + "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(&id) + .bind(&user_id) + .bind(&s.parsed_data) + .bind(&s.field_mappings) + .bind(&s.row_results) + .bind(s.created_at) + .bind(s.expires_at) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let uid_str = user_id.value().to_string(); + + #[derive(sqlx::FromRow)] + struct Row { + id: String, + user_id: String, + parsed_data: String, + field_mappings: Option, + row_results: Option, + created_at: NaiveDateTime, + expires_at: NaiveDateTime, + } + + let row = sqlx::query_as::<_, Row>( + "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at + FROM import_sessions WHERE id = $1 AND user_id = $2", + ) + .bind(&id_str) + .bind(&uid_str) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(row.map(|r| -> Result { + Ok(ImportSession { + id: ImportSessionId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + parsed_data: r.parsed_data, + field_mappings: r.field_mappings, + row_results: r.row_results, + created_at: r.created_at, + expires_at: r.expires_at, + }) + }).transpose()?) + } + + async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { + let id = s.id.value().to_string(); + sqlx::query( + "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", + ) + .bind(&s.field_mappings) + .bind(&s.row_results) + .bind(&id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + sqlx::query("DELETE FROM import_sessions WHERE id = $1") + .bind(&id_str) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn delete_expired(&self) -> Result { + let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()") + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(result.rows_affected()) + } + + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + let uid = user_id.value().to_string(); + sqlx::query("DELETE FROM import_sessions WHERE user_id = $1 AND expires_at < NOW()") + .bind(&uid) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } +} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 6f023b2..2dad330 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -12,6 +12,8 @@ use domain::{ }; use sqlx::PgPool; +mod import_profile; +mod import_session; mod models; mod users; @@ -20,6 +22,8 @@ use models::{ datetime_to_str, }; +pub use import_profile::PostgresImportProfileRepository; +pub use import_session::PostgresImportSessionRepository; pub use users::PostgresUserRepository; fn format_year_month(ym: &str) -> String { @@ -775,6 +779,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, + std::sync::Arc, )> { use anyhow::Context; @@ -788,6 +794,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; + let import_session_repo = std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())); + let import_profile_repo = std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())); + Ok(( pool.clone(), std::sync::Arc::clone(&repo) as _, @@ -795,5 +804,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc::clone(&repo) as _, std::sync::Arc::clone(&repo) as _, std::sync::Arc::new(PostgresUserRepository::new(pool)) as _, + import_session_repo as _, + import_profile_repo as _, )) } diff --git a/crates/adapters/sqlite/migrations/0008_import.sql b/crates/adapters/sqlite/migrations/0008_import.sql new file mode 100644 index 0000000..2b354ef --- /dev/null +++ b/crates/adapters/sqlite/migrations/0008_import.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS import_sessions ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + parsed_data TEXT NOT NULL, + field_mappings TEXT, + row_results TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS import_profiles ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + field_mappings TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id ON import_sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_import_sessions_expires_at ON import_sessions (expires_at); +CREATE INDEX IF NOT EXISTS idx_import_profiles_user_id ON import_profiles (user_id); diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/import_profile.rs new file mode 100644 index 0000000..4cd4451 --- /dev/null +++ b/crates/adapters/sqlite/src/import_profile.rs @@ -0,0 +1,106 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use domain::{ + errors::DomainError, + models::ImportProfile, + ports::ImportProfileRepository, + value_objects::{ImportProfileId, UserId}, +}; +use sqlx::SqlitePool; + +pub struct SqliteImportProfileRepository { + pool: SqlitePool, +} + +impl SqliteImportProfileRepository { + pub fn new(pool: SqlitePool) -> Self { Self { pool } } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("DB error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } + + fn parse_dt(s: &str) -> Result { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) + .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) + } +} + +#[async_trait] +impl ImportProfileRepository for SqliteImportProfileRepository { + async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { + let id = p.id.value().to_string(); + let user_id = p.user_id.value().to_string(); + let created_at = p.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query!( + "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at) + VALUES (?, ?, ?, ?, ?)", + id, user_id, p.name, p.field_mappings, created_at + ) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { + let uid = user_id.value().to_string(); + let rows = sqlx::query!( + "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC", + uid + ) + .fetch_all(&self.pool) + .await + .map_err(Self::map_err)?; + + rows.into_iter().map(|r| -> Result { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: r.field_mappings, + created_at: Self::parse_dt(&r.created_at)?, + }) + }).collect() + } + + async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let uid_str = user_id.value().to_string(); + let row = sqlx::query!( + "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?", + id_str, uid_str + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(row.map(|r| -> Result { + Ok(ImportProfile { + id: ImportProfileId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + name: r.name, + field_mappings: r.field_mappings, + created_at: Self::parse_dt(&r.created_at)?, + }) + }).transpose()?) + } + + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + sqlx::query!("DELETE FROM import_profiles WHERE id = ?", id_str) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } +} diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/import_session.rs new file mode 100644 index 0000000..010928d --- /dev/null +++ b/crates/adapters/sqlite/src/import_session.rs @@ -0,0 +1,114 @@ +use async_trait::async_trait; +use chrono::NaiveDateTime; +use domain::{ + errors::DomainError, + models::ImportSession, + ports::ImportSessionRepository, + value_objects::{ImportSessionId, UserId}, +}; +use sqlx::SqlitePool; + +pub struct SqliteImportSessionRepository { + pool: SqlitePool, +} + +impl SqliteImportSessionRepository { + pub fn new(pool: SqlitePool) -> Self { Self { pool } } + + fn map_err(e: sqlx::Error) -> DomainError { + tracing::error!("DB error: {:?}", e); + DomainError::InfrastructureError("Database operation failed".into()) + } + + fn parse_dt(s: &str) -> Result { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) + .map_err(|e| DomainError::InfrastructureError(format!("invalid datetime '{}': {}", s, e))) + } +} + +#[async_trait] +impl ImportSessionRepository for SqliteImportSessionRepository { + async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { + let id = s.id.value().to_string(); + let user_id = s.user_id.value().to_string(); + let created_at = s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(); + let expires_at = s.expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query!( + "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + id, user_id, s.parsed_data, s.field_mappings, s.row_results, created_at, expires_at + ) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError> { + let id_str = id.value().to_string(); + let uid_str = user_id.value().to_string(); + let row = sqlx::query!( + "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at + FROM import_sessions WHERE id = ? AND user_id = ?", + id_str, uid_str + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + Ok(row.map(|r| -> Result { + Ok(ImportSession { + id: ImportSessionId::from_uuid( + r.id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + user_id: UserId::from_uuid( + r.user_id.parse::().map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ), + parsed_data: r.parsed_data, + field_mappings: r.field_mappings, + row_results: r.row_results, + created_at: Self::parse_dt(&r.created_at)?, + expires_at: Self::parse_dt(&r.expires_at)?, + }) + }).transpose()?) + } + + async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { + let id = s.id.value().to_string(); + sqlx::query!( + "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", + s.field_mappings, s.row_results, id + ) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { + let id_str = id.value().to_string(); + sqlx::query!("DELETE FROM import_sessions WHERE id = ?", id_str) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } + + async fn delete_expired(&self) -> Result { + let result = sqlx::query!("DELETE FROM import_sessions WHERE expires_at < datetime('now')") + .execute(&self.pool) + .await + .map_err(Self::map_err)?; + Ok(result.rows_affected()) + } + + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { + let uid = user_id.value().to_string(); + sqlx::query!("DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", uid) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) + } +} diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index fdcdcfb..0f74e22 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -12,6 +12,8 @@ use domain::{ }; use sqlx::SqlitePool; +mod import_profile; +mod import_session; mod migrations; mod models; mod users; @@ -21,6 +23,8 @@ use models::{ datetime_to_str, }; +pub use import_profile::SqliteImportProfileRepository; +pub use import_session::SqliteImportSessionRepository; pub use users::SqliteUserRepository; fn format_year_month(ym: &str) -> String { @@ -766,6 +770,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc, std::sync::Arc, std::sync::Arc, + std::sync::Arc, + std::sync::Arc, )> { use std::str::FromStr; use anyhow::Context; @@ -786,6 +792,9 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; + let import_session_repo = std::sync::Arc::new(SqliteImportSessionRepository::new(pool.clone())); + let import_profile_repo = std::sync::Arc::new(SqliteImportProfileRepository::new(pool.clone())); + Ok(( pool.clone(), std::sync::Arc::clone(&repo) as _, @@ -793,6 +802,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<( std::sync::Arc::clone(&repo) as _, std::sync::Arc::clone(&repo) as _, std::sync::Arc::new(SqliteUserRepository::new(pool)) as _, + import_session_repo as _, + import_profile_repo as _, )) } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index ca69321..4ea56be 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,6 +1,8 @@ use application::ports::{ ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, - LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, + ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, + ImportRowStatus, ImportUploadPageData, LoginPageData, NewReviewPageData, ProfilePageData, + RegisterPageData, UsersPageData, }; use askama::Template; use chrono::Datelike; @@ -290,6 +292,34 @@ fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } +#[derive(Template)] +#[template(path = "import_upload.html")] +struct ImportUploadTemplate<'a> { + ctx: &'a HtmlPageContext, + profiles: &'a [ImportProfileView], + error: Option<&'a str>, +} + +#[derive(Template)] +#[template(path = "import_mapping.html")] +struct ImportMappingTemplate<'a> { + ctx: &'a HtmlPageContext, + session_id: &'a str, + columns: &'a [String], + sample_rows: &'a [Vec], + domain_fields: &'a [(&'static str, &'static str)], + error: Option<&'a str>, +} + +#[derive(Template)] +#[template(path = "import_preview.html")] +struct ImportPreviewTemplate<'a> { + ctx: &'a HtmlPageContext, + session_id: &'a str, + columns: &'a [String], + rows: &'a [ImportPreviewRow], +} + pub struct AskamaHtmlRenderer; impl AskamaHtmlRenderer { @@ -557,4 +587,38 @@ impl HtmlRenderer for AskamaHtmlRenderer { .render() .map_err(|e| e.to_string()) } + + fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result { + ImportUploadTemplate { + ctx: &data.ctx, + profiles: &data.profiles, + error: data.error.as_deref(), + } + .render() + .map_err(|e| e.to_string()) + } + + fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result { + ImportMappingTemplate { + ctx: &data.ctx, + session_id: &data.session_id, + columns: &data.columns, + sample_rows: &data.sample_rows, + domain_fields: &data.domain_fields, + error: data.error.as_deref(), + } + .render() + .map_err(|e| e.to_string()) + } + + fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result { + ImportPreviewTemplate { + ctx: &data.ctx, + session_id: &data.session_id, + columns: &data.columns, + rows: &data.rows, + } + .render() + .map_err(|e| e.to_string()) + } } diff --git a/crates/adapters/template-askama/templates/base.html b/crates/adapters/template-askama/templates/base.html index f9f2f7c..8ea0eac 100644 --- a/crates/adapters/template-askama/templates/base.html +++ b/crates/adapters/template-askama/templates/base.html @@ -33,6 +33,7 @@ {% if let Some(uid) = ctx.user_id %} Profile Add Review + Import Logout {% else %} Login diff --git a/crates/adapters/template-askama/templates/import_mapping.html b/crates/adapters/template-askama/templates/import_mapping.html new file mode 100644 index 0000000..0b63877 --- /dev/null +++ b/crates/adapters/template-askama/templates/import_mapping.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block content %} +

Map Columns

+{% if let Some(err) = error %} +

{{ err }}

+{% endif %} + +

Showing up to 5 sample rows. Map each column to a diary field.

+ +
+ + + + {% for col in columns %}{% endfor %} + + + + {% for row in sample_rows %} + {% for cell in row %}{% endfor %} + {% endfor %} + +
{{ col }}
{{ cell }}
+ + {% for col in columns %} +
+ {{ col }} + + + + +
+ {% endfor %} + + + +
+{% endblock %} diff --git a/crates/adapters/template-askama/templates/import_preview.html b/crates/adapters/template-askama/templates/import_preview.html new file mode 100644 index 0000000..dbd9781 --- /dev/null +++ b/crates/adapters/template-askama/templates/import_preview.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block content %} +

Preview Import

+ +
+ + + + + {% for col in columns %}{% endfor %} + + + + + {% for row in rows %} + + + {% for cell in row.cells %}{% endfor %} + + + {% endfor %} + +
Include?{{ col }}Status
+ {% match row.status %} + {% when ImportRowStatus::Invalid with (_e) %} + + {% when _ %} + + {% endmatch %} + {{ cell }} + {% match row.status %} + {% when ImportRowStatus::Valid %}✓ + {% when ImportRowStatus::Duplicate %}⚠ duplicate + {% when ImportRowStatus::Invalid with (e) %}✗ {{ e }} + {% endmatch %} +
+ + + + + +
+{% endblock %} diff --git a/crates/adapters/template-askama/templates/import_upload.html b/crates/adapters/template-askama/templates/import_upload.html new file mode 100644 index 0000000..832cfee --- /dev/null +++ b/crates/adapters/template-askama/templates/import_upload.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block content %} +

Import Reviews

+{% if let Some(err) = error %} +

{{ err }}

+{% endif %} + +{% if !profiles.is_empty() %} +
+

Saved Profiles

+
    + {% for p in profiles %} +
  • + {{ p.name }} +
    + + +
    +
  • + {% endfor %} +
+
+{% endif %} + +

Upload File

+
+ + + + +
+{% endblock %} diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index cb58687..92a85d4 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -11,6 +11,11 @@ chrono = { workspace = true } tracing = { workspace = true } futures = { workspace = true } tokio = { workspace = true } +importer = { workspace = true } +serde_json = { workspace = true } + +[features] +xlsx = ["importer/xlsx"] [dev-dependencies] tokio = { workspace = true } diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index b51f1fb..d23aaf4 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -1,5 +1,6 @@ use chrono::NaiveDateTime; use domain::models::{ExportFormat, UserRole}; +use importer::FieldMapping; use uuid::Uuid; pub struct LogReviewCommand { @@ -42,3 +43,44 @@ pub struct ExportCommand { pub user_id: Uuid, pub format: ExportFormat, } + +pub enum FileFormat { + Csv, + Json, + Xlsx, +} + +pub struct CreateImportSessionCommand { + pub user_id: Uuid, + pub bytes: Vec, + pub format: FileFormat, +} + +pub struct ApplyImportMappingCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub mappings: Vec, +} + +pub struct ExecuteImportCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub confirmed_indices: Vec, +} + +pub struct SaveImportProfileCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub name: String, +} + +pub struct ApplyImportProfileCommand { + pub user_id: Uuid, + pub session_id: Uuid, + pub profile_id: Uuid, +} + +pub struct DeleteImportProfileCommand { + pub user_id: Uuid, + pub profile_id: Uuid, +} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index cab6dcf..54ba93f 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -1,9 +1,10 @@ use std::sync::Arc; use domain::ports::{ - AuthService, DiaryExporter, DiaryRepository, EventPublisher, MetadataClient, MovieRepository, - PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, - UserRepository, + AuthService, DiaryExporter, DiaryRepository, EventPublisher, + ImportProfileRepository, ImportSessionRepository, + MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, + PosterStorage, ReviewRepository, StatsRepository, UserRepository, }; use crate::config::AppConfig; @@ -22,5 +23,7 @@ pub struct AppContext { pub auth_service: Arc, pub password_hasher: Arc, pub user_repository: Arc, + pub import_session_repository: Arc, + pub import_profile_repository: Arc, pub config: AppConfig, } diff --git a/crates/application/src/event_handlers.rs b/crates/application/src/event_handlers.rs deleted file mode 100644 index 2720de4..0000000 --- a/crates/application/src/event_handlers.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::time::Duration; - -use async_trait::async_trait; -use domain::ports::EventHandler; -use domain::{errors::DomainError, events::DomainEvent}; - -use crate::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster}; - -pub struct PosterSyncHandler { - ctx: AppContext, - max_retries: u32, -} - -impl PosterSyncHandler { - pub fn new(ctx: AppContext, max_retries: u32) -> Self { - Self { ctx, max_retries } - } -} - -#[async_trait] -impl EventHandler for PosterSyncHandler { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - let (movie_id, external_metadata_id) = match event { - DomainEvent::MovieDiscovered { - movie_id, - external_metadata_id, - } => (movie_id.value(), external_metadata_id.value().to_owned()), - _ => return Ok(()), - }; - - let mut last_err: Option = None; - for attempt in 0..=self.max_retries { - let cmd = SyncPosterCommand { - movie_id, - external_metadata_id: external_metadata_id.clone(), - }; - match sync_poster::execute(&self.ctx, cmd).await { - Ok(()) => return Ok(()), - Err(e) => { - if attempt < self.max_retries { - let delay = Duration::from_secs(2u64.pow(attempt)); - tracing::warn!( - attempt = attempt + 1, - max_attempts = self.max_retries + 1, - delay_secs = delay.as_secs(), - "poster sync failed, retrying: {e}" - ); - tokio::time::sleep(delay).await; - } - last_err = Some(e); - } - } - } - - let err = last_err.expect("loop runs at least once and always sets last_err on Err"); - tracing::error!( - attempts = self.max_retries + 1, - "poster sync failed after all attempts: {err}" - ); - Err(err) - } -} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 2007e9c..87dbbb5 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,5 +1,4 @@ pub mod commands; -pub mod event_handlers; pub mod worker; pub mod config; pub mod context; diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 09d405d..010e861 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -95,6 +95,45 @@ pub struct FollowersPageData { pub error: Option, } +pub struct ImportUploadPageData { + pub ctx: HtmlPageContext, + pub profiles: Vec, + pub error: Option, +} + +pub struct ImportProfileView { + pub id: String, + pub name: String, +} + +pub struct ImportMappingPageData { + pub ctx: HtmlPageContext, + pub session_id: String, + pub columns: Vec, + pub sample_rows: Vec>, + pub domain_fields: Vec<(&'static str, &'static str)>, + pub error: Option, +} + +pub struct ImportPreviewRow { + pub index: usize, + pub status: ImportRowStatus, + pub cells: Vec, +} + +pub enum ImportRowStatus { + Valid, + Duplicate, + Invalid(String), +} + +pub struct ImportPreviewPageData { + pub ctx: HtmlPageContext, + pub session_id: String, + pub columns: Vec, + pub rows: Vec, +} + pub trait HtmlRenderer: Send + Sync { fn render_diary_page( &self, @@ -109,6 +148,9 @@ pub trait HtmlRenderer: Send + Sync { fn render_profile_page(&self, data: ProfilePageData) -> Result; fn render_following_page(&self, data: FollowingPageData) -> Result; fn render_followers_page(&self, data: FollowersPageData) -> Result; + fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result; + fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result; + fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result; } pub trait RssFeedRenderer: Send + Sync { diff --git a/crates/application/src/use_cases/apply_import_mapping.rs b/crates/application/src/use_cases/apply_import_mapping.rs new file mode 100644 index 0000000..face46f --- /dev/null +++ b/crates/application/src/use_cases/apply_import_mapping.rs @@ -0,0 +1,62 @@ +use domain::{ + errors::DomainError, + value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, +}; +use importer::{AnnotatedRow, ParsedFile, apply_mapping}; + +use crate::{commands::ApplyImportMappingCommand, context::AppContext}; + +pub async fn execute(ctx: &AppContext, cmd: ApplyImportMappingCommand) -> Result, DomainError> { + let user_id = UserId::from_uuid(cmd.user_id); + let session_id = ImportSessionId::from_uuid(cmd.session_id); + let mappings = cmd.mappings; + let mut session = ctx.import_session_repository + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("import session".into()))?; + + let parsed: ParsedFile = serde_json::from_str(&session.parsed_data) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let mut annotated = apply_mapping(&parsed, &mappings); + + for row in annotated.iter_mut() { + if let importer::RowResult::Valid(ref import_row) = row.result { + row.is_duplicate = check_duplicate(ctx, import_row).await?; + } + } + + session.field_mappings = Some( + serde_json::to_string(&mappings) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ); + session.row_results = Some( + serde_json::to_string(&annotated) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))? + ); + + ctx.import_session_repository.update(&session).await?; + + Ok(annotated) +} + +async fn check_duplicate(ctx: &AppContext, row: &importer::ImportRow) -> Result { + if let Some(ext_id) = &row.external_metadata_id { + if let Ok(eid) = ExternalMetadataId::new(ext_id.clone()) { + if ctx.movie_repository.get_movie_by_external_id(&eid).await?.is_some() { + return Ok(true); + } + } + } + if let (Some(title), Some(year_str)) = (&row.title, &row.release_year) { + let title_vo = MovieTitle::new(title.clone()); + let year_vo = year_str.parse::().ok().and_then(|y| ReleaseYear::new(y).ok()); + if let (Ok(t), Some(y)) = (title_vo, year_vo) { + let matches = ctx.movie_repository.get_movies_by_title_and_year(&t, &y).await?; + if !matches.is_empty() { + return Ok(true); + } + } + } + Ok(false) +} diff --git a/crates/application/src/use_cases/apply_import_profile.rs b/crates/application/src/use_cases/apply_import_profile.rs new file mode 100644 index 0000000..e85bcdd --- /dev/null +++ b/crates/application/src/use_cases/apply_import_profile.rs @@ -0,0 +1,20 @@ +use domain::{errors::DomainError, value_objects::{ImportProfileId, ImportSessionId, UserId}}; +use crate::{commands::ApplyImportProfileCommand, context::AppContext}; + +/// Copies the profile's field_mappings onto the session. Caller must then invoke +/// apply_import_mapping to regenerate row_results with the new mappings. +pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result<(), DomainError> { + let user_id = UserId::from_uuid(cmd.user_id); + let session_id = ImportSessionId::from_uuid(cmd.session_id); + let profile_id = ImportProfileId::from_uuid(cmd.profile_id); + + let profile = ctx.import_profile_repository + .get(&profile_id, &user_id).await? + .ok_or_else(|| DomainError::NotFound("import profile".into()))?; + let mut session = ctx.import_session_repository + .get(&session_id, &user_id).await? + .ok_or_else(|| DomainError::NotFound("import session".into()))?; + session.field_mappings = Some(profile.field_mappings); + session.row_results = None; + ctx.import_session_repository.update(&session).await +} diff --git a/crates/application/src/use_cases/cleanup_expired_import_sessions.rs b/crates/application/src/use_cases/cleanup_expired_import_sessions.rs new file mode 100644 index 0000000..f2048a4 --- /dev/null +++ b/crates/application/src/use_cases/cleanup_expired_import_sessions.rs @@ -0,0 +1,6 @@ +use domain::errors::DomainError; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext) -> Result { + ctx.import_session_repository.delete_expired().await +} diff --git a/crates/application/src/use_cases/create_import_session.rs b/crates/application/src/use_cases/create_import_session.rs new file mode 100644 index 0000000..4d2422b --- /dev/null +++ b/crates/application/src/use_cases/create_import_session.rs @@ -0,0 +1,44 @@ +use chrono::Utc; +use domain::{errors::DomainError, models::ImportSession, value_objects::{ImportSessionId, UserId}}; +use importer::{ImportError, ParsedFile}; + +use crate::{commands::{CreateImportSessionCommand, FileFormat}, context::AppContext}; + +pub struct CreateSessionResult { + pub session_id: ImportSessionId, + pub columns: Vec, + pub sample_rows: Vec>, +} + +pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Result { + let user_id = UserId::from_uuid(cmd.user_id); + ctx.import_session_repository.delete_expired_for_user(&user_id).await?; + + let parsed = parse(cmd.bytes, cmd.format).map_err(|e| DomainError::ValidationError(e.to_string()))?; + let sample_rows = parsed.rows.iter().take(5).cloned().collect(); + let columns = parsed.columns.clone(); + + let parsed_data = serde_json::to_string(&parsed) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let now = Utc::now().naive_utc(); + let session = ImportSession::new(ImportSessionId::generate(), user_id, parsed_data, now); + let session_id = session.id.clone(); + + ctx.import_session_repository.create(&session).await?; + + Ok(CreateSessionResult { session_id, columns, sample_rows }) +} + +fn parse(bytes: Vec, format: FileFormat) -> Result { + match format { + FileFormat::Csv => importer::parse_csv(&bytes), + FileFormat::Json => importer::parse_json(&bytes), + FileFormat::Xlsx => { + #[cfg(feature = "xlsx")] + { importer::parse_xlsx(&bytes) } + #[cfg(not(feature = "xlsx"))] + { Err(ImportError::Xlsx("XLSX support not compiled in".into())) } + } + } +} diff --git a/crates/application/src/use_cases/delete_import_profile.rs b/crates/application/src/use_cases/delete_import_profile.rs new file mode 100644 index 0000000..4951cdf --- /dev/null +++ b/crates/application/src/use_cases/delete_import_profile.rs @@ -0,0 +1,12 @@ +use domain::{errors::DomainError, value_objects::{ImportProfileId, UserId}}; +use crate::{commands::DeleteImportProfileCommand, context::AppContext}; + +pub async fn execute(ctx: &AppContext, cmd: DeleteImportProfileCommand) -> Result<(), DomainError> { + let user_id = UserId::from_uuid(cmd.user_id); + let profile_id = ImportProfileId::from_uuid(cmd.profile_id); + + ctx.import_profile_repository + .get(&profile_id, &user_id).await? + .ok_or_else(|| DomainError::NotFound("import profile".into()))?; + ctx.import_profile_repository.delete(&profile_id).await +} diff --git a/crates/application/src/use_cases/execute_import.rs b/crates/application/src/use_cases/execute_import.rs new file mode 100644 index 0000000..828ac5b --- /dev/null +++ b/crates/application/src/use_cases/execute_import.rs @@ -0,0 +1,84 @@ +use chrono::NaiveDateTime; +use domain::{errors::DomainError, value_objects::{ImportSessionId, UserId}}; +use importer::{AnnotatedRow, ImportRow, RowResult}; +use uuid::Uuid; + +use crate::{commands::{ExecuteImportCommand, LogReviewCommand}, context::AppContext, use_cases::log_review}; + +pub struct ImportSummary { + pub imported: usize, + pub skipped_duplicates: usize, + pub failed: Vec<(usize, String)>, +} + +pub async fn execute(ctx: &AppContext, cmd: ExecuteImportCommand) -> Result { + let user_id = UserId::from_uuid(cmd.user_id); + let session_id = ImportSessionId::from_uuid(cmd.session_id); + let confirmed_indices = cmd.confirmed_indices; + let session = ctx.import_session_repository + .get(&session_id, &user_id) + .await? + .ok_or_else(|| DomainError::NotFound("import session".into()))?; + + let row_results: Vec = session.row_results + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + let confirmed_set: std::collections::HashSet = confirmed_indices.into_iter().collect(); + + let mut imported = 0; + let mut skipped_duplicates = 0; + let mut failed = Vec::new(); + + for (idx, annotated) in row_results.into_iter().enumerate() { + if !confirmed_set.contains(&idx) { + skipped_duplicates += 1; + continue; + } + match annotated.result { + RowResult::Valid(row) => { + match row_to_command(&row, user_id.value()) { + Ok(cmd) => { + match log_review::execute(ctx, cmd).await { + Ok(_) => imported += 1, + Err(e) => failed.push((idx, e.to_string())), + } + } + Err(e) => failed.push((idx, e)), + } + } + RowResult::Invalid { errors, .. } => { + failed.push((idx, errors.join("; "))); + } + } + } + + ctx.import_session_repository.delete(&session_id).await?; + + Ok(ImportSummary { imported, skipped_duplicates, failed }) +} + +fn row_to_command(row: &ImportRow, user_id: Uuid) -> Result { + let rating = row.rating.as_deref() + .ok_or("missing rating")? + .parse::() + .map_err(|_| "rating is not a valid u8".to_string())?; + + let watched_at_str = row.watched_at.as_deref().ok_or("missing watched_at")?; + let watched_at = NaiveDateTime::parse_from_str(&format!("{} 00:00:00", watched_at_str), "%Y-%m-%d %H:%M:%S") + .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%d %H:%M:%S")) + .or_else(|_| NaiveDateTime::parse_from_str(watched_at_str, "%Y-%m-%dT%H:%M:%S")) + .map_err(|_| format!("cannot parse watched_at: '{}'", watched_at_str))?; + + Ok(LogReviewCommand { + external_metadata_id: row.external_metadata_id.clone(), + manual_title: row.title.clone(), + manual_release_year: row.release_year.as_deref().and_then(|s| s.parse().ok()), + manual_director: row.director.clone(), + user_id, + rating, + comment: row.comment.clone(), + watched_at, + }) +} diff --git a/crates/application/src/use_cases/list_import_profiles.rs b/crates/application/src/use_cases/list_import_profiles.rs new file mode 100644 index 0000000..830b872 --- /dev/null +++ b/crates/application/src/use_cases/list_import_profiles.rs @@ -0,0 +1,6 @@ +use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId}; +use crate::context::AppContext; + +pub async fn execute(ctx: &AppContext, user_id: &UserId) -> Result, DomainError> { + ctx.import_profile_repository.list_for_user(user_id).await +} diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index 0ca71bd..a74b3a8 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -1,4 +1,12 @@ +pub mod apply_import_mapping; +pub mod apply_import_profile; +pub mod cleanup_expired_import_sessions; +pub mod create_import_session; +pub mod delete_import_profile; pub mod delete_review; +pub mod execute_import; +pub mod list_import_profiles; +pub mod save_import_profile; pub mod export_diary; pub mod get_activity_feed; pub mod get_diary; diff --git a/crates/application/src/use_cases/save_import_profile.rs b/crates/application/src/use_cases/save_import_profile.rs new file mode 100644 index 0000000..5b695f9 --- /dev/null +++ b/crates/application/src/use_cases/save_import_profile.rs @@ -0,0 +1,18 @@ +use chrono::Utc; +use domain::{errors::DomainError, models::ImportProfile, value_objects::{ImportProfileId, ImportSessionId, UserId}}; +use crate::{commands::SaveImportProfileCommand, context::AppContext}; + +pub async fn execute(ctx: &AppContext, cmd: SaveImportProfileCommand) -> Result { + let user_id = UserId::from_uuid(cmd.user_id); + let session_id = ImportSessionId::from_uuid(cmd.session_id); + + let session = ctx.import_session_repository + .get(&session_id, &user_id).await? + .ok_or_else(|| DomainError::NotFound("import session".into()))?; + let mappings = session.field_mappings + .ok_or_else(|| DomainError::ValidationError("no mapping applied to this session yet".into()))?; + let profile = ImportProfile::new(ImportProfileId::generate(), user_id, cmd.name, mappings, Utc::now().naive_utc()); + let id = profile.id.clone(); + ctx.import_profile_repository.save(&profile).await?; + Ok(id) +} diff --git a/crates/domain/src/models/import_profile.rs b/crates/domain/src/models/import_profile.rs new file mode 100644 index 0000000..ffbe072 --- /dev/null +++ b/crates/domain/src/models/import_profile.rs @@ -0,0 +1,17 @@ +use chrono::NaiveDateTime; +use crate::value_objects::{ImportProfileId, UserId}; + +#[derive(Debug, Clone)] +pub struct ImportProfile { + pub id: ImportProfileId, + pub user_id: UserId, + pub name: String, + pub field_mappings: String, + pub created_at: NaiveDateTime, +} + +impl ImportProfile { + pub fn new(id: ImportProfileId, user_id: UserId, name: String, field_mappings: String, created_at: NaiveDateTime) -> Self { + Self { id, user_id, name, field_mappings, created_at } + } +} diff --git a/crates/domain/src/models/import_session.rs b/crates/domain/src/models/import_session.rs new file mode 100644 index 0000000..fd2063d --- /dev/null +++ b/crates/domain/src/models/import_session.rs @@ -0,0 +1,20 @@ +use chrono::NaiveDateTime; +use crate::value_objects::{ImportSessionId, UserId}; + +#[derive(Debug, Clone)] +pub struct ImportSession { + pub id: ImportSessionId, + pub user_id: UserId, + pub parsed_data: String, + pub field_mappings: Option, + pub row_results: Option, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} + +impl ImportSession { + pub fn new(id: ImportSessionId, user_id: UserId, parsed_data: String, created_at: NaiveDateTime) -> Self { + let expires_at = created_at + chrono::Duration::hours(24); + Self { id, user_id, parsed_data, field_mappings: None, row_results: None, created_at, expires_at } + } +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index c7d3a9c..9c26752 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -9,6 +9,11 @@ use crate::{ }, }; pub mod collections; +pub mod import_session; +pub mod import_profile; + +pub use import_session::ImportSession; +pub use import_profile::ImportProfile; #[derive(Clone, Debug, Default)] pub enum SortDirection { diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 8782535..37061f7 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -5,13 +5,13 @@ use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, models::{ - DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, Movie, Review, ReviewHistory, User, - UserStats, UserSummary, UserTrends, + DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, ImportProfile, ImportSession, Movie, + Review, ReviewHistory, User, UserStats, UserSummary, UserTrends, collections::{PageParams, Paginated}, }, value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, - ReleaseYear, ReviewId, UserId, Username, + Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, + PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, Username, }, }; @@ -200,3 +200,21 @@ pub trait DiaryExporter: Send + Sync { pub trait EventHandler: Send + Sync { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError>; } + +#[async_trait] +pub trait ImportSessionRepository: Send + Sync { + async fn create(&self, session: &ImportSession) -> Result<(), DomainError>; + async fn get(&self, id: &ImportSessionId, user_id: &UserId) -> Result, DomainError>; + async fn update(&self, session: &ImportSession) -> Result<(), DomainError>; + async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>; + async fn delete_expired(&self) -> Result; + async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>; +} + +#[async_trait] +pub trait ImportProfileRepository: Send + Sync { + async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>; + async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; + async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result, DomainError>; + async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>; +} diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index 861f18d..59d42fb 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -23,6 +23,8 @@ macro_rules! uuid_id { uuid_id!(MovieId); uuid_id!(ReviewId); uuid_id!(UserId); +uuid_id!(ImportSessionId); +uuid_id!(ImportProfileId); #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalMetadataId(String); diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 1012893..d973faf 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -53,6 +53,7 @@ nats = { workspace = true, optional = true } rss = { workspace = true } export = { workspace = true } doc = { workspace = true } +importer = { workspace = true } sqlx = { workspace = true } utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 275cdb3..fd02414 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -326,6 +326,22 @@ mod tests { } } #[async_trait::async_trait] + impl domain::ports::ImportSessionRepository for Panic { + async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } + async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } + async fn delete_expired(&self) -> Result { panic!() } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } + } + #[async_trait::async_trait] + impl domain::ports::ImportProfileRepository for Panic { + async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } + async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } + } + #[async_trait::async_trait] impl domain::ports::DiaryExporter for Panic { async fn serialize_entries( &self, @@ -392,6 +408,9 @@ mod tests { ) -> Result { panic!() } + fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } + fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } + fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } } impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { @@ -427,6 +446,8 @@ mod tests { event_publisher: Arc::clone(&repo) as _, password_hasher: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _, + import_session_repository: Arc::clone(&repo) as _, + import_profile_repository: Arc::clone(&repo) as _, auth_service, config: AppConfig { allow_registration: false, diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs deleted file mode 100644 index 9e9376b..0000000 --- a/crates/presentation/src/handlers.rs +++ /dev/null @@ -1,1711 +0,0 @@ -const DEFAULT_PAGE_LIMIT: u32 = 5; -const RSS_FEED_LIMIT: u32 = 50; - -pub mod html { - use std::str::FromStr; - - use axum::{ - Form, - extract::{Extension, Path, Query, State}, - http::{HeaderValue, StatusCode, header::SET_COOKIE}, - response::{Html, IntoResponse, Redirect}, - }; - use chrono::Utc; - use uuid::Uuid; - - use application::{ - commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, - ports::{HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView}, - use_cases::{ - delete_review, export_diary as export_diary_uc, log_review, login as login_uc, - register as register_uc, - }, - }; - #[cfg(feature = "federation")] - use application::ports::{FollowersPageData, FollowingPageData}; - use domain::models::ExportFormat; - use domain::{errors::DomainError, value_objects::UserId}; - - use crate::{ - csrf::CsrfToken, - dtos::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm}, - extractors::{OptionalCookieUser, RequiredCookieUser}, - state::AppState, - }; - #[cfg(feature = "federation")] - use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm}; - - async fn build_page_context( - state: &AppState, - user_id: Option, - csrf_token: String, - ) -> HtmlPageContext { - let uuid = user_id.as_ref().map(|u| u.value()); - let user_email = if let Some(ref id) = user_id { - state - .app_ctx - .user_repository - .find_by_id(id) - .await - .ok() - .flatten() - .map(|u| u.email().value().to_string()) - } else { - None - }; - HtmlPageContext { - user_email, - user_id: uuid, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Movies Diary".to_string(), - canonical_url: state.app_ctx.config.base_url.clone(), - csrf_token, - page_rss_url: None, - } - } - - fn encode_error(msg: &str) -> String { - use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; - utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() - } - - fn secure_flag() -> &'static str { - if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { - "; Secure" - } else { - "" - } - } - - fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { - let val = format!( - "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", - token, - max_age, - secure_flag() - ); - ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ) - } - - pub async fn get_login_page( - State(state): State, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - register_enabled: state.app_ctx.config.allow_registration, - rss_url: "/feed.rss".to_string(), - page_title: "Login — Movies Diary".to_string(), - canonical_url: format!("{}/login", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - }; - let html = state - .html_renderer - .render_login_page(LoginPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("login template failed"); - Html(html) - } - - pub async fn post_login( - State(state): State, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match login_uc::execute( - &state.app_ctx, - LoginCommand { - email: form.email, - password: form.password, - }, - ) - .await - { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), - } - } - - pub async fn get_logout() -> impl IntoResponse { - let val = format!( - "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", - secure_flag() - ); - let cookie = ( - SET_COOKIE, - HeaderValue::from_str(&val).expect("valid cookie"), - ); - ([cookie], Redirect::to("/")).into_response() - } - - pub async fn get_register_page( - State(state): State, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - let ctx = HtmlPageContext { - user_email: None, - user_id: None, - register_enabled: true, - rss_url: "/feed.rss".to_string(), - page_title: "Register — Movies Diary".to_string(), - canonical_url: format!("{}/register", state.app_ctx.config.base_url), - csrf_token: csrf.0, - page_rss_url: None, - }; - let html = state - .html_renderer - .render_register_page(RegisterPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("register template failed"); - Html(html).into_response() - } - - pub async fn post_register( - State(state): State, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if !state.app_ctx.config.allow_registration { - return Redirect::to("/").into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let email = form.email.clone(); - let password = form.password.clone(); - match register_uc::execute( - &state.app_ctx, - RegisterCommand { - email: form.email, - username: form.username, - password: form.password, - role: domain::models::UserRole::Standard, - }, - ) - .await - { - Ok(_) => { - match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await { - Ok(result) => { - let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); - let cookie = set_cookie_header(&result.token, max_age); - ([cookie], Redirect::to("/")).into_response() - } - Err(_) => Redirect::to("/login").into_response(), - } - } - Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.") - .into_response(), - } - } - - pub async fn get_new_review_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; - ctx.page_title = "Log a Review — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); - let html = state - .html_renderer - .render_new_review_page(NewReviewPageData { - ctx, - error: params.error.as_deref(), - }) - .expect("new_review template failed"); - Html(html) - } - - pub async fn post_review( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let data = match LogReviewData::try_from(form) { - Ok(d) => d, - Err(_) => { - return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); - } - }; - - match log_review::execute(&state.app_ctx, data.into_command(user_id.value())).await { - Ok(_) => Redirect::to("/").into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() - } - } - } - - pub async fn post_delete_review( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Extension(csrf): Extension, - Path(review_id): Path, - Form(form): Form, - ) -> impl IntoResponse { - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - match delete_review::execute(&state.app_ctx, cmd).await { - Ok(()) => { - let redirect_url = form - .redirect_after - .filter(|url| { - (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') - }) - .unwrap_or_else(|| "/".to_string()); - Redirect::to(&redirect_url).into_response() - } - Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), - Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), - Err(e) => { - tracing::error!("delete_review html error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } - } - - pub async fn get_export( - State(state): State, - RequiredCookieUser(user_id): RequiredCookieUser, - Query(params): Query, - ) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let cmd = ExportCommand { - user_id: user_id.value(), - format, - }; - match export_diary_uc::execute(&state.app_ctx, cmd).await { - Ok(bytes) => ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - bytes, - ) - .into_response(), - Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), - Err(e) => { - tracing::error!("export error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } - } - - pub async fn get_activity_feed( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let limit = params.limit.unwrap_or(20); - let offset = params.offset.unwrap_or(0); - - #[cfg(feature = "federation")] - let filter_str = if params.filter == "following" && user_id.is_some() { - "following" - } else { - "all" - }; - #[cfg(not(feature = "federation"))] - let filter_str = "all"; - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - #[cfg(feature = "federation")] - let following = if filter_str == "following" { - if let Some(uid) = user_id { - let urls = state.social_query - .get_accepted_following_urls(uid.value()) - .await - .unwrap_or_default(); - let base_url = &state.app_ctx.config.base_url; - let mut local_ids = vec![uid.value()]; - let mut remote_urls = Vec::new(); - for url in urls { - if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) { - if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { - local_ids.push(parsed_id); - continue; - } - } - remote_urls.push(url); - } - Some(domain::ports::FollowingFilter { - local_user_ids: local_ids, - remote_actor_urls: remote_urls, - }) - } else { - None - } - } else { - None - }; - #[cfg(not(feature = "federation"))] - let following: Option = None; - - let search_opt = if params.search.is_empty() { - None - } else { - Some(params.search.clone()) - }; - - let query = application::queries::GetActivityFeedQuery { - limit, - offset, - sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), - search: search_opt, - following, - }; - - match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { - Ok(entries) => { - let entry_limit = entries.limit; - let entry_offset = entries.offset; - let has_more = (entry_offset as u64).saturating_add(entry_limit as u64) - < entries.total_count; - let data = application::ports::ActivityFeedPageData { - ctx, - current_offset: entry_offset, - has_more, - limit: entry_limit, - entries, - filter: filter_str.to_string(), - sort_by: sort_by_str.to_string(), - search: params.search, - }; - match state.html_renderer.render_activity_feed_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } - } - - pub async fn get_users_list( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Extension(csrf): Extension, - ) -> impl IntoResponse { - let mut ctx = build_page_context(&state, user_id, csrf.0).await; - ctx.page_title = "Members — Movies Diary".to_string(); - ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); - - #[cfg(feature = "federation")] - let (users_result, actors_result) = tokio::join!( - application::use_cases::get_users::execute( - &state.app_ctx, - application::queries::GetUsersQuery, - ), - state.social_query.list_all_followed_remote_actors() - ); - #[cfg(not(feature = "federation"))] - let (users_result, actors_result) = ( - application::use_cases::get_users::execute( - &state.app_ctx, - application::queries::GetUsersQuery, - ) - .await, - Ok::, domain::errors::DomainError>(vec![]), - ); - - match (users_result, actors_result) { - (Ok(users), Ok(remote_actors)) => { - let actor_views = remote_actors - .into_iter() - .map(|a| application::ports::RemoteActorView { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(); - let data = application::ports::UsersPageData { - ctx, - users, - remote_actors: actor_views, - }; - match state.html_renderer.render_users_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } - } - (Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - (_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } - } - - pub async fn get_user_profile( - OptionalCookieUser(user_id): OptionalCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - headers: axum::http::HeaderMap, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - // Content negotiation: AP clients request application/activity+json - #[cfg(feature = "federation")] - { - let accept = headers - .get(axum::http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if accept.contains("application/activity+json") || accept.contains("application/ld+json") { - return match state - .ap_service - .actor_json(&profile_user_uuid.to_string()) - .await - { - Ok(json) => ( - [( - axum::http::header::CONTENT_TYPE, - "application/activity+json", - )], - json, - ) - .into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - }; - } - } - - let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => { - return ( - axum::http::StatusCode::BAD_REQUEST, - "invalid view parameter", - ) - .into_response(); - } - }; - - let profile_user = match state - .app_ctx - .user_repository - .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) - .await - { - Ok(Some(u)) => u, - Ok(None) => return (StatusCode::NOT_FOUND, "User not found").into_response(), - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - let display_name = profile_user.username().value(); - ctx.page_title = format!("{}'s Diary — Movies Diary", display_name); - ctx.canonical_url = format!( - "{}/users/{}", - state.app_ctx.config.base_url, profile_user_uuid - ); - - let sort_by_str = match params.sort_by.as_str() { - "date_asc" => "date_asc", - "rating" => "rating", - "rating_asc" => "rating_asc", - _ => "date", - }; - - let is_own_profile = user_id - .as_ref() - .map(|u| u.value() == profile_user_uuid) - .unwrap_or(false); - - #[cfg(feature = "federation")] - let following_count = if is_own_profile { - if let Some(ref uid) = user_id { - state - .ap_service - .count_following(uid.value()) - .await - .unwrap_or(0) - } else { - 0 - } - } else { - 0 - }; - #[cfg(not(feature = "federation"))] - let following_count = 0usize; - - #[cfg(feature = "federation")] - let followers_count = if is_own_profile { - state - .ap_service - .count_accepted_followers(profile_user_uuid) - .await - .unwrap_or(0) - } else { - 0 - }; - #[cfg(not(feature = "federation"))] - let followers_count = 0usize; - - #[cfg(feature = "federation")] - let pending_followers: Vec = if is_own_profile { - state - .ap_service - .get_pending_followers(profile_user_uuid) - .await - .unwrap_or_default() - .into_iter() - .map(|a| application::ports::RemoteActorView { - handle: a.handle, - url: a.url, - display_name: a.display_name, - }) - .collect() - } else { - vec![] - }; - #[cfg(not(feature = "federation"))] - let pending_followers: Vec = vec![]; - - let query = application::queries::GetUserProfileQuery { - user_id: profile_user_uuid, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), - search: if params.search.is_empty() { None } else { Some(params.search.clone()) }, - }; - - match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { - Ok(profile) => { - let (offset, has_more, limit) = profile - .entries - .as_ref() - .map(|e| { - let has_more = - (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; - (e.offset, has_more, e.limit) - }) - .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); - if !is_own_profile { - ctx.page_rss_url = - Some(format!("/users/{}/feed.rss", profile_user_uuid)); - } - let data = application::ports::ProfilePageData { - ctx, - profile_user_id: profile_user_uuid, - profile_user_email: profile_user.email().value().to_string(), - stats: profile.stats, - view: profile_view.as_str().to_string(), - entries: profile.entries, - current_offset: offset, - has_more, - limit, - history: profile.history, - trends: profile.trends, - is_own_profile, - error: params.error, - following_count, - followers_count, - pending_followers, - sort_by: sort_by_str.to_string(), - search: params.search.clone(), - }; - match state.html_renderer.render_profile_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } - } - - #[cfg(feature = "federation")] - pub async fn follow_remote_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state.ap_service.follow(user_id.value(), &form.handle).await { - Ok(()) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - tracing::error!("follow error: {:?}", e); - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn unfollow_remote_user( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state - .ap_service - .unfollow(user_id.value(), &form.actor_url) - .await - { - Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)) - .into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!( - "/users/{}/following-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn accept_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state - .ap_service - .accept_follower(user_id.value(), &form.actor_url) - .await - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn reject_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state - .ap_service - .reject_follower(user_id.value(), &form.actor_url) - .await - { - Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn get_following_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Following — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/following-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - match state.ap_service.get_following(user_id.value()).await { - Ok(following) => { - let actors = following - .into_iter() - .map(|a| RemoteActorView { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(); - let data = FollowingPageData { - ctx, - user_id: profile_user_uuid, - actors, - error: params.error, - }; - match state.html_renderer.render_following_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } - } - Err(e) => { - tracing::error!("get_following error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load following list", - ) - .into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn get_followers_page( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Query(params): Query, - Extension(csrf): Extension, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; - ctx.page_title = "Followers — Movies Diary".to_string(); - ctx.canonical_url = format!( - "{}/users/{}/followers-list", - state.app_ctx.config.base_url, profile_user_uuid - ); - match state - .ap_service - .get_accepted_followers(user_id.value()) - .await - { - Ok(followers) => { - let actors = followers - .into_iter() - .map(|a| RemoteActorView { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(); - let data = FollowersPageData { - ctx, - user_id: profile_user_uuid, - actors, - error: params.error, - }; - match state.html_renderer.render_followers_page(data) { - Ok(html) => Html(html).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), - } - } - Err(e) => { - tracing::error!("get_followers error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to load followers list", - ) - .into_response() - } - } - } - - #[cfg(feature = "federation")] - pub async fn remove_follower( - RequiredCookieUser(user_id): RequiredCookieUser, - State(state): State, - Path(profile_user_uuid): Path, - Extension(csrf): Extension, - Form(form): Form, - ) -> impl IntoResponse { - if user_id.value() != profile_user_uuid { - return StatusCode::FORBIDDEN.into_response(); - } - if crate::csrf::mismatch(&csrf, &form.csrf_token) { - return StatusCode::FORBIDDEN.into_response(); - } - match state - .ap_service - .remove_follower(user_id.value(), &form.actor_url) - .await - { - Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)) - .into_response(), - Err(e) => { - let msg = encode_error(&e.to_string()); - Redirect::to(&format!( - "/users/{}/followers-list?error={}", - profile_user_uuid, msg - )) - .into_response() - } - } - } -} - -pub mod posters { - use axum::{ - extract::{Path, State}, - http::{StatusCode, header}, - response::IntoResponse, - }; - - use domain::value_objects::PosterPath; - - use crate::state::AppState; - - pub async fn get_poster( - State(state): State, - Path(path): Path, - ) -> impl IntoResponse { - // If path is a remote URL, redirect directly instead of serving from local storage. - if path.starts_with("http://") || path.starts_with("https://") { - return axum::response::Redirect::temporary(&path).into_response(); - } - - let poster_path = match PosterPath::new(path) { - Ok(p) => p, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - match state.app_ctx.poster_storage.get_poster(&poster_path).await { - Ok(bytes) => { - let mime = infer::get(&bytes) - .map(|t| t.mime_type()) - .unwrap_or("application/octet-stream"); - ([(header::CONTENT_TYPE, mime)], bytes).into_response() - } - Err(_) => StatusCode::NOT_FOUND.into_response(), - } - } -} - -pub mod rss { - use axum::{ - extract::{Path, State}, - http::header, - response::IntoResponse, - }; - use uuid::Uuid; - - use application::{queries::GetDiaryQuery, use_cases::get_diary}; - use domain::{errors::DomainError, models::SortDirection, value_objects::UserId}; - - use crate::{errors::ApiError, state::AppState}; - - pub async fn get_feed(State(state): State) -> Result { - let query = GetDiaryQuery { - limit: Some(super::RSS_FEED_LIMIT), - offset: Some(0), - sort_by: Some(SortDirection::Descending), - movie_id: None, - user_id: None, - }; - let page = get_diary::execute(&state.app_ctx, query).await?; - let xml = state - .rss_renderer - .render_feed(&page.items, "Movie Diary") - .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; - Ok(( - [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], - xml, - )) - } - - pub async fn get_user_feed( - State(state): State, - Path(user_id): Path, - ) -> Result { - let user = state - .app_ctx - .user_repository - .find_by_id(&UserId::from_uuid(user_id)) - .await - .map_err(ApiError)? - .ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?; - - let query = GetDiaryQuery { - limit: Some(super::RSS_FEED_LIMIT), - offset: Some(0), - sort_by: Some(SortDirection::Descending), - movie_id: None, - user_id: Some(user_id), - }; - let page = get_diary::execute(&state.app_ctx, query).await?; - - let display_name = user.email().value().split('@').next().unwrap_or("User"); - let title = format!("{}'s Movie Diary", display_name); - - let xml = state - .rss_renderer - .render_feed(&page.items, &title) - .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; - - Ok(( - [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], - xml, - )) - } -} - -pub mod api { - use axum::{ - Json, - extract::{Path, Query, State}, - http::StatusCode, - response::IntoResponse, - }; - use uuid::Uuid; - - use std::str::FromStr; - - use application::{ - commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand}, - queries::{GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery}, - use_cases::{ - delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, - get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users, - log_review, login as login_uc, register as register_uc, sync_poster, - }, - }; - use domain::{ - errors::DomainError, - models::{DiaryEntry, ExportFormat, Movie, Review}, - services::review_history::Trend, - value_objects::{MovieId, UserId}, - }; - - use crate::{ - dtos::{ - ActivityFeedQueryParams, ActivityFeedResponse, - DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, - FeedEntryDto, LogReviewData, LogReviewRequest, LoginRequest, - LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDto, RegisterRequest, - ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, - UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, - }, - errors::ApiError, - extractors::AuthenticatedUser, - state::AppState, - }; - #[cfg(feature = "federation")] - use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; - - #[utoipa::path( - get, path = "/api/v1/diary", - params(DiaryQueryParams), - responses( - (status = 200, body = DiaryResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn get_diary( - State(state): State, - Query(params): Query, - ) -> Result, ApiError> { - let page = get_diary::execute(&state.app_ctx, params.into()).await?; - - Ok(Json(DiaryResponse { - items: page.items.iter().map(entry_to_dto).collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) - } - - #[utoipa::path( - get, path = "/api/v1/movies/{id}/history", - params(("id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 200, body = ReviewHistoryResponse), - (status = 404, description = "Movie not found"), - ) - )] - pub async fn get_review_history( - State(state): State, - Path(movie_id): Path, - ) -> Result, ApiError> { - let (history, trend) = - get_review_history::execute(&state.app_ctx, GetReviewHistoryQuery { movie_id }).await?; - - Ok(Json(ReviewHistoryResponse { - movie: movie_to_dto(history.movie()), - viewings: history.viewings().iter().map(review_to_dto).collect(), - trend: match trend { - Trend::Improved => "improved", - Trend::Declined => "declined", - Trend::Neutral => "neutral", - } - .to_string(), - })) - } - - #[utoipa::path( - post, path = "/api/v1/reviews", - request_body = LogReviewRequest, - responses( - (status = 201, description = "Review created"), - (status = 400, description = "Invalid input"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn post_review( - State(state): State, - user: AuthenticatedUser, - Json(req): Json, - ) -> Result { - let data = LogReviewData::try_from(req).map_err(ApiError)?; - log_review::execute(&state.app_ctx, data.into_command(user.0.value())).await?; - Ok(StatusCode::CREATED) - } - - #[utoipa::path( - post, path = "/api/v1/movies/{id}/sync-poster", - params(("id" = Uuid, Path, description = "Movie ID")), - responses( - (status = 204, description = "Poster synced"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Movie not found"), - ), - security(("bearer_auth" = [])) - )] - pub async fn sync_poster( - State(state): State, - _user: AuthenticatedUser, - Path(movie_id): Path, - ) -> Result { - let movie = state - .app_ctx - .movie_repository - .get_movie_by_id(&MovieId::from_uuid(movie_id)) - .await? - .ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?; - - let external_id = movie - .external_metadata_id() - .ok_or_else(|| { - ApiError(DomainError::ValidationError( - "Movie has no external metadata ID, cannot sync poster".into(), - )) - })? - .value() - .to_string(); - - sync_poster::execute( - &state.app_ctx, - SyncPosterCommand { - movie_id, - external_metadata_id: external_id, - }, - ) - .await?; - - Ok(StatusCode::NO_CONTENT) - } - - #[utoipa::path( - post, path = "/api/v1/auth/login", - request_body = LoginRequest, - responses( - (status = 200, body = LoginResponse), - (status = 401, description = "Invalid credentials"), - ) - )] - pub async fn login( - State(state): State, - Json(req): Json, - ) -> Result, ApiError> { - let result = login_uc::execute( - &state.app_ctx, - LoginCommand { - email: req.email, - password: req.password, - }, - ) - .await?; - Ok(Json(LoginResponse { - token: result.token, - user_id: result.user_id, - email: result.email, - expires_at: result.expires_at.to_rfc3339(), - })) - } - - #[utoipa::path( - post, path = "/api/v1/auth/register", - request_body = RegisterRequest, - responses( - (status = 201, description = "User registered"), - (status = 400, description = "Invalid input"), - ) - )] - pub async fn register( - State(state): State, - Json(req): Json, - ) -> Result { - register_uc::execute( - &state.app_ctx, - RegisterCommand { - email: req.email, - username: req.username, - password: req.password, - role: domain::models::UserRole::Standard, - }, - ) - .await?; - Ok(StatusCode::CREATED) - } - - #[utoipa::path( - delete, path = "/api/v1/reviews/{id}", - params(("id" = Uuid, Path, description = "Review ID")), - responses( - (status = 204, description = "Review deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Review not found"), - ), - security(("bearer_auth" = [])) - )] - pub async fn delete_review( - State(state): State, - AuthenticatedUser(user_id): AuthenticatedUser, - Path(review_id): Path, - ) -> impl IntoResponse { - let cmd = DeleteReviewCommand { - review_id, - requesting_user_id: user_id.value(), - }; - match delete_review::execute(&state.app_ctx, cmd).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), - Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), - Err(e) => { - tracing::error!("delete_review error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } - } - - fn movie_to_dto(movie: &Movie) -> MovieDto { - MovieDto { - id: movie.id().value(), - title: movie.title().value().to_string(), - release_year: movie.release_year().value(), - director: movie.director().map(|d| d.to_string()), - poster_path: movie.poster_path().map(|p| p.value().to_string()), - } - } - - fn review_to_dto(review: &Review) -> ReviewDto { - ReviewDto { - id: review.id().value(), - rating: review.rating().value(), - comment: review.comment().map(|c| c.value().to_string()), - watched_at: review.watched_at().to_string(), - } - } - - fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { - DiaryEntryDto { - movie: movie_to_dto(entry.movie()), - review: review_to_dto(entry.review()), - } - } - - #[cfg(feature = "federation")] - fn ap_err(e: anyhow::Error) -> impl IntoResponse { - tracing::error!("ActivityPub error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR - } - - #[cfg(feature = "federation")] - #[utoipa::path( - get, path = "/api/v1/social/following", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn get_following( - State(state): State, - user: AuthenticatedUser, - ) -> impl IntoResponse { - match state.ap_service.get_following(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - get, path = "/api/v1/social/followers", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn get_followers( - State(state): State, - user: AuthenticatedUser, - ) -> impl IntoResponse { - match state.ap_service.get_accepted_followers(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - post, path = "/api/v1/social/follow", - request_body = FollowRequest, - responses( - (status = 200, description = "Follow request sent"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn follow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, - ) -> impl IntoResponse { - match state.ap_service.follow(user.0.value(), &body.handle).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - post, path = "/api/v1/social/unfollow", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Unfollowed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn unfollow( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, - ) -> impl IntoResponse { - match state.ap_service.unfollow(user.0.value(), &body.actor_url).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - post, path = "/api/v1/social/followers/accept", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower accepted"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn accept_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, - ) -> impl IntoResponse { - match state.ap_service.accept_follower(user.0.value(), &body.actor_url).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - post, path = "/api/v1/social/followers/reject", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower rejected"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn reject_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, - ) -> impl IntoResponse { - match state.ap_service.reject_follower(user.0.value(), &body.actor_url).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - post, path = "/api/v1/social/followers/remove", - request_body = ActorUrlRequest, - responses( - (status = 200, description = "Follower removed"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn remove_follower( - State(state): State, - user: AuthenticatedUser, - Json(body): Json, - ) -> impl IntoResponse { - match state.ap_service.remove_follower(user.0.value(), &body.actor_url).await { - Ok(()) => StatusCode::OK.into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[cfg(feature = "federation")] - #[utoipa::path( - get, path = "/api/v1/social/followers/pending", - responses( - (status = 200, body = ActorListResponse), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn get_pending_followers( - State(state): State, - user: AuthenticatedUser, - ) -> impl IntoResponse { - match state.ap_service.get_pending_followers(user.0.value()).await { - Ok(actors) => Json(ActorListResponse { - actors: actors - .into_iter() - .map(|a| RemoteActorDto { - handle: a.handle, - display_name: a.display_name, - url: a.url, - }) - .collect(), - }) - .into_response(), - Err(e) => ap_err(e).into_response(), - } - } - - #[utoipa::path( - get, path = "/api/v1/activity-feed", - params(ActivityFeedQueryParams), - responses((status = 200, body = ActivityFeedResponse)), - )] - pub async fn get_activity_feed( - State(state): State, - Query(params): Query, - ) -> Result, ApiError> { - let page = get_feed_uc::execute( - &state.app_ctx, - GetActivityFeedQuery { - limit: params.limit.unwrap_or(20), - offset: params.offset.unwrap_or(0), - sort_by: domain::ports::FeedSortBy::Date, - search: None, - following: None, - }, - ) - .await?; - Ok(Json(ActivityFeedResponse { - items: page - .items - .iter() - .map(|e| FeedEntryDto { - movie: movie_to_dto(e.movie()), - review: review_to_dto(e.review()), - user_email: e.user_email().to_string(), - user_display_name: e.user_display_name().to_string(), - }) - .collect(), - total_count: page.total_count, - limit: page.limit, - offset: page.offset, - })) - } - - #[utoipa::path( - get, path = "/api/v1/users", - responses((status = 200, body = UsersResponse)), - )] - pub async fn list_users( - State(state): State, - ) -> Result, ApiError> { - let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?; - Ok(Json(UsersResponse { - users: users - .iter() - .map(|u| UserSummaryDto { - id: u.user_id.value(), - email: u.email().to_string(), - total_movies: u.total_movies, - avg_rating: u.avg_rating, - }) - .collect(), - })) - } - - #[utoipa::path( - get, path = "/api/v1/users/{id}", - params( - ("id" = Uuid, Path, description = "User ID"), - UserProfileQueryParams, - ), - responses( - (status = 200, body = UserProfileResponse), - (status = 404, description = "User not found"), - ) - )] - pub async fn get_user_profile( - State(state): State, - Path(user_id): Path, - Query(params): Query, - ) -> impl IntoResponse { - let view_str = params.view.as_deref().unwrap_or("recent"); - let profile_view = match application::queries::ProfileView::from_str(view_str) { - Ok(v) => v, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - - let user = match state - .app_ctx - .user_repository - .find_by_id(&UserId::from_uuid(user_id)) - .await - { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!("user lookup: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - let profile = match get_user_profile_uc::execute( - &state.app_ctx, - GetUserProfileQuery { - user_id, - view: profile_view, - limit: params.limit, - offset: params.offset, - sort_by: domain::ports::FeedSortBy::Date, - search: None, - }, - ) - .await - { - Ok(p) => p, - Err(e) => { - tracing::error!("profile: {:?}", e); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - } - }; - - #[cfg(feature = "federation")] - let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0); - #[cfg(not(feature = "federation"))] - let following_count = 0usize; - - #[cfg(feature = "federation")] - let followers_count = state - .ap_service - .count_accepted_followers(user_id) - .await - .unwrap_or(0); - #[cfg(not(feature = "federation"))] - let followers_count = 0usize; - - let entries = profile.entries.map(|p| DiaryResponse { - items: p.items.iter().map(entry_to_dto).collect(), - total_count: p.total_count, - limit: p.limit, - offset: p.offset, - }); - - let history = profile.history.map(|months| { - months - .into_iter() - .map(|m| MonthActivityDto { - year_month: m.year_month, - month_label: m.month_label, - count: m.count, - entries: m.entries.iter().map(entry_to_dto).collect(), - }) - .collect() - }); - - let trends = profile.trends.map(|t| UserTrendsDto { - monthly_ratings: t - .monthly_ratings - .into_iter() - .map(|r| MonthlyRatingDto { - year_month: r.year_month, - month_label: r.month_label, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(), - top_directors: t - .top_directors - .into_iter() - .map(|d| DirectorStatDto { director: d.director, count: d.count }) - .collect(), - max_director_count: t.max_director_count, - }); - - Json(UserProfileResponse { - user_id, - username: user.username().value().to_string(), - stats: UserStatsDto { - total_movies: profile.stats.total_movies, - avg_rating: profile.stats.avg_rating, - favorite_director: profile.stats.favorite_director, - most_active_month: profile.stats.most_active_month, - }, - following_count, - followers_count, - entries, - history, - trends, - }) - .into_response() - } - - #[utoipa::path( - get, path = "/api/v1/diary/export", - params(ExportQueryParams), - responses( - (status = 200, description = "Diary file download", content_type = "text/csv"), - (status = 400, description = "Invalid format parameter"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer_auth" = [])) - )] - pub async fn export_diary( - State(state): State, - user: AuthenticatedUser, - Query(params): Query, - ) -> impl IntoResponse { - let format = match params.format.as_str() { - "csv" => ExportFormat::Csv, - "json" => ExportFormat::Json, - _ => return StatusCode::BAD_REQUEST.into_response(), - }; - let (content_type, filename) = match &format { - ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), - ExportFormat::Json => ("application/json", "diary.json"), - }; - let cmd = ExportCommand { user_id: user.0.value(), format }; - match export_diary_uc::execute(&state.app_ctx, cmd).await { - Ok(bytes) => ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, content_type.to_string()), - ( - axum::http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ], - bytes, - ) - .into_response(), - Err(e) => { - tracing::error!("export error: {:?}", e); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } - } -} diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs new file mode 100644 index 0000000..65df129 --- /dev/null +++ b/crates/presentation/src/handlers/api.rs @@ -0,0 +1,721 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use uuid::Uuid; + +use std::str::FromStr; + +use application::{ + commands::{ + DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand, + }, + queries::{ + GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, + }, + use_cases::{ + delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, + get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users, + log_review, login as login_uc, register as register_uc, sync_poster, + }, +}; +use domain::{ + errors::DomainError, + models::{DiaryEntry, ExportFormat, Movie, Review}, + services::review_history::Trend, + value_objects::{MovieId, UserId}, +}; + +#[cfg(feature = "federation")] +use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; +use crate::{ + dtos::{ + ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, + DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData, + LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, + MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams, + UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, + }, + errors::ApiError, + extractors::AuthenticatedUser, + state::AppState, +}; + +#[utoipa::path( + get, path = "/api/v1/diary", + params(DiaryQueryParams), + responses( + (status = 200, body = DiaryResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_diary( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_diary::execute(&state.app_ctx, params.into()).await?; + + Ok(Json(DiaryResponse { + items: page.items.iter().map(entry_to_dto).collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + get, path = "/api/v1/movies/{id}/history", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 200, body = ReviewHistoryResponse), + (status = 404, description = "Movie not found"), + ) +)] +pub async fn get_review_history( + State(state): State, + Path(movie_id): Path, +) -> Result, ApiError> { + let (history, trend) = + get_review_history::execute(&state.app_ctx, GetReviewHistoryQuery { movie_id }).await?; + + Ok(Json(ReviewHistoryResponse { + movie: movie_to_dto(history.movie()), + viewings: history.viewings().iter().map(review_to_dto).collect(), + trend: match trend { + Trend::Improved => "improved", + Trend::Declined => "declined", + Trend::Neutral => "neutral", + } + .to_string(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/reviews", + request_body = LogReviewRequest, + responses( + (status = 201, description = "Review created"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, +) -> Result { + let data = LogReviewData::try_from(req).map_err(ApiError)?; + log_review::execute(&state.app_ctx, data.into_command(user.0.value())).await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + post, path = "/api/v1/movies/{id}/sync-poster", + params(("id" = Uuid, Path, description = "Movie ID")), + responses( + (status = 204, description = "Poster synced"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Movie not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn sync_poster( + State(state): State, + _user: AuthenticatedUser, + Path(movie_id): Path, +) -> Result { + let movie = state + .app_ctx + .movie_repository + .get_movie_by_id(&MovieId::from_uuid(movie_id)) + .await? + .ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?; + + let external_id = movie + .external_metadata_id() + .ok_or_else(|| { + ApiError(DomainError::ValidationError( + "Movie has no external metadata ID, cannot sync poster".into(), + )) + })? + .value() + .to_string(); + + sync_poster::execute( + &state.app_ctx, + SyncPosterCommand { + movie_id, + external_metadata_id: external_id, + }, + ) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, path = "/api/v1/auth/login", + request_body = LoginRequest, + responses( + (status = 200, body = LoginResponse), + (status = 401, description = "Invalid credentials"), + ) +)] +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let result = login_uc::execute( + &state.app_ctx, + LoginCommand { + email: req.email, + password: req.password, + }, + ) + .await?; + Ok(Json(LoginResponse { + token: result.token, + user_id: result.user_id, + email: result.email, + expires_at: result.expires_at.to_rfc3339(), + })) +} + +#[utoipa::path( + post, path = "/api/v1/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered"), + (status = 400, description = "Invalid input"), + ) +)] +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result { + register_uc::execute( + &state.app_ctx, + RegisterCommand { + email: req.email, + username: req.username, + password: req.password, + role: domain::models::UserRole::Standard, + }, + ) + .await?; + Ok(StatusCode::CREATED) +} + +#[utoipa::path( + delete, path = "/api/v1/reviews/{id}", + params(("id" = Uuid, Path, description = "Review ID")), + responses( + (status = 204, description = "Review deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Review not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_review( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(review_id): Path, +) -> impl IntoResponse { + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + match delete_review::execute(&state.app_ctx, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), + Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), + Err(e) => { + tracing::error!("delete_review error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +fn movie_to_dto(movie: &Movie) -> MovieDto { + MovieDto { + id: movie.id().value(), + title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + director: movie.director().map(|d| d.to_string()), + poster_path: movie.poster_path().map(|p| p.value().to_string()), + } +} + +fn review_to_dto(review: &Review) -> ReviewDto { + ReviewDto { + id: review.id().value(), + rating: review.rating().value(), + comment: review.comment().map(|c| c.value().to_string()), + watched_at: review.watched_at().to_string(), + } +} + +fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { + DiaryEntryDto { + movie: movie_to_dto(entry.movie()), + review: review_to_dto(entry.review()), + } +} + +#[cfg(feature = "federation")] +fn ap_err(e: anyhow::Error) -> impl IntoResponse { + tracing::error!("ActivityPub error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR +} + +#[cfg(feature = "federation")] +#[utoipa::path( + get, path = "/api/v1/social/following", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_following( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_following(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + get, path = "/api/v1/social/followers", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_followers( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state + .ap_service + .get_accepted_followers(user.0.value()) + .await + { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + post, path = "/api/v1/social/follow", + request_body = FollowRequest, + responses( + (status = 200, description = "Follow request sent"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn follow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state.ap_service.follow(user.0.value(), &body.handle).await { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + post, path = "/api/v1/social/unfollow", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Unfollowed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn unfollow( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .unfollow(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + post, path = "/api/v1/social/followers/accept", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower accepted"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn accept_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .accept_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + post, path = "/api/v1/social/followers/reject", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower rejected"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn reject_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .reject_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + post, path = "/api/v1/social/followers/remove", + request_body = ActorUrlRequest, + responses( + (status = 200, description = "Follower removed"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn remove_follower( + State(state): State, + user: AuthenticatedUser, + Json(body): Json, +) -> impl IntoResponse { + match state + .ap_service + .remove_follower(user.0.value(), &body.actor_url) + .await + { + Ok(()) => StatusCode::OK.into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[cfg(feature = "federation")] +#[utoipa::path( + get, path = "/api/v1/social/followers/pending", + responses( + (status = 200, body = ActorListResponse), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_pending_followers( + State(state): State, + user: AuthenticatedUser, +) -> impl IntoResponse { + match state.ap_service.get_pending_followers(user.0.value()).await { + Ok(actors) => Json(ActorListResponse { + actors: actors + .into_iter() + .map(|a| RemoteActorDto { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(), + }) + .into_response(), + Err(e) => ap_err(e).into_response(), + } +} + +#[utoipa::path( + get, path = "/api/v1/activity-feed", + params(ActivityFeedQueryParams), + responses((status = 200, body = ActivityFeedResponse)), +)] +pub async fn get_activity_feed( + State(state): State, + Query(params): Query, +) -> Result, ApiError> { + let page = get_feed_uc::execute( + &state.app_ctx, + GetActivityFeedQuery { + limit: params.limit.unwrap_or(20), + offset: params.offset.unwrap_or(0), + sort_by: domain::ports::FeedSortBy::Date, + search: None, + following: None, + }, + ) + .await?; + Ok(Json(ActivityFeedResponse { + items: page + .items + .iter() + .map(|e| FeedEntryDto { + movie: movie_to_dto(e.movie()), + review: review_to_dto(e.review()), + user_email: e.user_email().to_string(), + user_display_name: e.user_display_name().to_string(), + }) + .collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) +} + +#[utoipa::path( + get, path = "/api/v1/users", + responses((status = 200, body = UsersResponse)), +)] +pub async fn list_users( + State(state): State, +) -> Result, ApiError> { + let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?; + Ok(Json(UsersResponse { + users: users + .iter() + .map(|u| UserSummaryDto { + id: u.user_id.value(), + email: u.email().to_string(), + total_movies: u.total_movies, + avg_rating: u.avg_rating, + }) + .collect(), + })) +} + +#[utoipa::path( + get, path = "/api/v1/users/{id}", + params( + ("id" = Uuid, Path, description = "User ID"), + UserProfileQueryParams, + ), + responses( + (status = 200, body = UserProfileResponse), + (status = 404, description = "User not found"), + ) +)] +pub async fn get_user_profile( + State(state): State, + Path(user_id): Path, + Query(params): Query, +) -> impl IntoResponse { + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + + let user = match state + .app_ctx + .user_repository + .find_by_id(&UserId::from_uuid(user_id)) + .await + { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!("user lookup: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let profile = match get_user_profile_uc::execute( + &state.app_ctx, + GetUserProfileQuery { + user_id, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: domain::ports::FeedSortBy::Date, + search: None, + }, + ) + .await + { + Ok(p) => p, + Err(e) => { + tracing::error!("profile: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + #[cfg(feature = "federation")] + let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0); + #[cfg(not(feature = "federation"))] + let following_count = 0usize; + + #[cfg(feature = "federation")] + let followers_count = state + .ap_service + .count_accepted_followers(user_id) + .await + .unwrap_or(0); + #[cfg(not(feature = "federation"))] + let followers_count = 0usize; + + let entries = profile.entries.map(|p| DiaryResponse { + items: p.items.iter().map(entry_to_dto).collect(), + total_count: p.total_count, + limit: p.limit, + offset: p.offset, + }); + + let history = profile.history.map(|months| { + months + .into_iter() + .map(|m| MonthActivityDto { + year_month: m.year_month, + month_label: m.month_label, + count: m.count, + entries: m.entries.iter().map(entry_to_dto).collect(), + }) + .collect() + }); + + let trends = profile.trends.map(|t| UserTrendsDto { + monthly_ratings: t + .monthly_ratings + .into_iter() + .map(|r| MonthlyRatingDto { + year_month: r.year_month, + month_label: r.month_label, + avg_rating: r.avg_rating, + count: r.count, + }) + .collect(), + top_directors: t + .top_directors + .into_iter() + .map(|d| DirectorStatDto { + director: d.director, + count: d.count, + }) + .collect(), + max_director_count: t.max_director_count, + }); + + Json(UserProfileResponse { + user_id, + username: user.username().value().to_string(), + stats: UserStatsDto { + total_movies: profile.stats.total_movies, + avg_rating: profile.stats.avg_rating, + favorite_director: profile.stats.favorite_director, + most_active_month: profile.stats.most_active_month, + }, + following_count, + followers_count, + entries, + history, + trends, + }) + .into_response() +} + +#[utoipa::path( + get, path = "/api/v1/diary/export", + params(ExportQueryParams), + responses( + (status = 200, description = "Diary file download", content_type = "text/csv"), + (status = 400, description = "Invalid format parameter"), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn export_diary( + State(state): State, + user: AuthenticatedUser, + Query(params): Query, +) -> impl IntoResponse { + let format = match params.format.as_str() { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let cmd = ExportCommand { + user_id: user.0.value(), + format, + }; + match export_diary_uc::execute(&state.app_ctx, cmd).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + bytes, + ) + .into_response(), + Err(e) => { + tracing::error!("export error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs new file mode 100644 index 0000000..7280f4f --- /dev/null +++ b/crates/presentation/src/handlers/html.rs @@ -0,0 +1,918 @@ +use std::str::FromStr; + +use axum::{ + Form, + extract::{Extension, Path, Query, State}, + http::{HeaderValue, StatusCode, header::SET_COOKIE}, + response::{Html, IntoResponse, Redirect}, +}; +use chrono::Utc; +use uuid::Uuid; + +#[cfg(feature = "federation")] +use application::ports::{FollowersPageData, FollowingPageData}; +use application::{ + commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, + ports::{ + HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView, + }, + use_cases::{ + delete_review, export_diary as export_diary_uc, log_review, login as login_uc, + register as register_uc, + }, +}; +use domain::models::ExportFormat; +use domain::{errors::DomainError, value_objects::UserId}; + +#[cfg(feature = "federation")] +use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm}; +use crate::{ + csrf::CsrfToken, + dtos::{ + ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm, + }, + extractors::{OptionalCookieUser, RequiredCookieUser}, + state::AppState, +}; + +pub(crate) async fn build_page_context( + state: &AppState, + user_id: Option, + csrf_token: String, +) -> HtmlPageContext { + let uuid = user_id.as_ref().map(|u| u.value()); + let user_email = if let Some(ref id) = user_id { + state + .app_ctx + .user_repository + .find_by_id(id) + .await + .ok() + .flatten() + .map(|u| u.email().value().to_string()) + } else { + None + }; + HtmlPageContext { + user_email, + user_id: uuid, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Movies Diary".to_string(), + canonical_url: state.app_ctx.config.base_url.clone(), + csrf_token, + page_rss_url: None, + } +} + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +fn secure_flag() -> &'static str { + if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") { + "; Secure" + } else { + "" + } +} + +fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) { + let val = format!( + "token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}", + token, + max_age, + secure_flag() + ); + ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ) +} + +pub async fn get_login_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + register_enabled: state.app_ctx.config.allow_registration, + rss_url: "/feed.rss".to_string(), + page_title: "Login — Movies Diary".to_string(), + canonical_url: format!("{}/login", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + }; + let html = state + .html_renderer + .render_login_page(LoginPageData { + ctx, + error: params.error.as_deref(), + }) + .expect("login template failed"); + Html(html) +} + +pub async fn post_login( + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match login_uc::execute( + &state.app_ctx, + LoginCommand { + email: form.email, + password: form.password, + }, + ) + .await + { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = set_cookie_header(&result.token, max_age); + ([cookie], Redirect::to("/")).into_response() + } + Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(), + } +} + +pub async fn get_logout() -> impl IntoResponse { + let val = format!( + "token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}", + secure_flag() + ); + let cookie = ( + SET_COOKIE, + HeaderValue::from_str(&val).expect("valid cookie"), + ); + ([cookie], Redirect::to("/")).into_response() +} + +pub async fn get_register_page( + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + let ctx = HtmlPageContext { + user_email: None, + user_id: None, + register_enabled: true, + rss_url: "/feed.rss".to_string(), + page_title: "Register — Movies Diary".to_string(), + canonical_url: format!("{}/register", state.app_ctx.config.base_url), + csrf_token: csrf.0, + page_rss_url: None, + }; + let html = state + .html_renderer + .render_register_page(RegisterPageData { + ctx, + error: params.error.as_deref(), + }) + .expect("register template failed"); + Html(html).into_response() +} + +pub async fn post_register( + State(state): State, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if !state.app_ctx.config.allow_registration { + return Redirect::to("/").into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let email = form.email.clone(); + let password = form.password.clone(); + match register_uc::execute( + &state.app_ctx, + RegisterCommand { + email: form.email, + username: form.username, + password: form.password, + role: domain::models::UserRole::Standard, + }, + ) + .await + { + Ok(_) => { + match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await { + Ok(result) => { + let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); + let cookie = set_cookie_header(&result.token, max_age); + ([cookie], Redirect::to("/")).into_response() + } + Err(_) => Redirect::to("/login").into_response(), + } + } + Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.") + .into_response(), + } +} + +pub async fn get_new_review_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await; + ctx.page_title = "Log a Review — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url); + let html = state + .html_renderer + .render_new_review_page(NewReviewPageData { + ctx, + error: params.error.as_deref(), + }) + .expect("new_review template failed"); + Html(html) +} + +pub async fn post_review( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let data = match LogReviewData::try_from(form) { + Ok(d) => d, + Err(_) => { + return Redirect::to("/reviews/new?error=Invalid+date+format").into_response(); + } + }; + + match log_review::execute(&state.app_ctx, data.into_command(user_id.value())).await { + Ok(_) => Redirect::to("/").into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/reviews/new?error={}", msg)).into_response() + } + } +} + +pub async fn post_delete_review( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + Path(review_id): Path, + Form(form): Form, +) -> impl IntoResponse { + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + let cmd = DeleteReviewCommand { + review_id, + requesting_user_id: user_id.value(), + }; + match delete_review::execute(&state.app_ctx, cmd).await { + Ok(()) => { + let redirect_url = form + .redirect_after + .filter(|url| { + (url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?') + }) + .unwrap_or_else(|| "/".to_string()); + Redirect::to(&redirect_url).into_response() + } + Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(), + Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), + Err(e) => { + tracing::error!("delete_review html error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn get_export( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Query(params): Query, +) -> impl IntoResponse { + let format = match params.format.as_str() { + "csv" => ExportFormat::Csv, + "json" => ExportFormat::Json, + _ => return StatusCode::BAD_REQUEST.into_response(), + }; + let (content_type, filename) = match &format { + ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"), + ExportFormat::Json => ("application/json", "diary.json"), + }; + let cmd = ExportCommand { + user_id: user_id.value(), + format, + }; + match export_diary_uc::execute(&state.app_ctx, cmd).await { + Ok(bytes) => ( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, content_type.to_string()), + ( + axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}\"", filename), + ), + ], + bytes, + ) + .into_response(), + Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(), + Err(e) => { + tracing::error!("export error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn get_activity_feed( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let limit = params.limit.unwrap_or(20); + let offset = params.offset.unwrap_or(0); + + #[cfg(feature = "federation")] + let filter_str = if params.filter == "following" && user_id.is_some() { + "following" + } else { + "all" + }; + #[cfg(not(feature = "federation"))] + let filter_str = "all"; + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + #[cfg(feature = "federation")] + let following = if filter_str == "following" { + if let Some(uid) = user_id { + let urls = state + .social_query + .get_accepted_following_urls(uid.value()) + .await + .unwrap_or_default(); + let base_url = &state.app_ctx.config.base_url; + let mut local_ids = vec![uid.value()]; + let mut remote_urls = Vec::new(); + for url in urls { + if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) { + if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) { + local_ids.push(parsed_id); + continue; + } + } + remote_urls.push(url); + } + Some(domain::ports::FollowingFilter { + local_user_ids: local_ids, + remote_actor_urls: remote_urls, + }) + } else { + None + } + } else { + None + }; + + #[cfg(not(feature = "federation"))] + let following: Option = None; + + let search_opt = if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }; + + let query = application::queries::GetActivityFeedQuery { + limit, + offset, + sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), + search: search_opt, + following, + }; + + match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { + Ok(entries) => { + let entry_limit = entries.limit; + let entry_offset = entries.offset; + let has_more = + (entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count; + let data = application::ports::ActivityFeedPageData { + ctx, + current_offset: entry_offset, + has_more, + limit: entry_limit, + entries, + filter: filter_str.to_string(), + sort_by: sort_by_str.to_string(), + search: params.search, + }; + match state.html_renderer.render_activity_feed_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +pub async fn get_users_list( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, user_id, csrf.0).await; + ctx.page_title = "Members — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); + + #[cfg(feature = "federation")] + let (users_result, actors_result) = tokio::join!( + application::use_cases::get_users::execute( + &state.app_ctx, + application::queries::GetUsersQuery, + ), + state.social_query.list_all_followed_remote_actors() + ); + #[cfg(not(feature = "federation"))] + let (users_result, actors_result) = ( + application::use_cases::get_users::execute( + &state.app_ctx, + application::queries::GetUsersQuery, + ) + .await, + Ok::, domain::errors::DomainError>(vec![]), + ); + + match (users_result, actors_result) { + (Ok(users), Ok(remote_actors)) => { + let actor_views = remote_actors + .into_iter() + .map(|a| application::ports::RemoteActorView { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(); + let data = application::ports::UsersPageData { + ctx, + users, + remote_actors: actor_views, + }; + match state.html_renderer.render_users_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + (Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + (_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +pub async fn get_user_profile( + OptionalCookieUser(user_id): OptionalCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + headers: axum::http::HeaderMap, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + // Content negotiation: AP clients request application/activity+json + #[cfg(feature = "federation")] + { + let accept = headers + .get(axum::http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if accept.contains("application/activity+json") + || accept.contains("application/ld+json") + { + return match state + .ap_service + .actor_json(&profile_user_uuid.to_string()) + .await + { + Ok(json) => ( + [( + axum::http::header::CONTENT_TYPE, + "application/activity+json", + )], + json, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + }; + } + } + + let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await; + let view_str = params.view.as_deref().unwrap_or("recent"); + let profile_view = match application::queries::ProfileView::from_str(view_str) { + Ok(v) => v, + Err(_) => { + return ( + axum::http::StatusCode::BAD_REQUEST, + "invalid view parameter", + ) + .into_response(); + } + }; + + let profile_user = match state + .app_ctx + .user_repository + .find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid)) + .await + { + Ok(Some(u)) => u, + Ok(None) => return (StatusCode::NOT_FOUND, "User not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + }; + + let display_name = profile_user.username().value(); + ctx.page_title = format!("{}'s Diary — Movies Diary", display_name); + ctx.canonical_url = format!( + "{}/users/{}", + state.app_ctx.config.base_url, profile_user_uuid + ); + + let sort_by_str = match params.sort_by.as_str() { + "date_asc" => "date_asc", + "rating" => "rating", + "rating_asc" => "rating_asc", + _ => "date", + }; + + let is_own_profile = user_id + .as_ref() + .map(|u| u.value() == profile_user_uuid) + .unwrap_or(false); + + #[cfg(feature = "federation")] + let following_count = if is_own_profile { + if let Some(ref uid) = user_id { + state + .ap_service + .count_following(uid.value()) + .await + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + }; + #[cfg(not(feature = "federation"))] + let following_count = 0usize; + + #[cfg(feature = "federation")] + let followers_count = if is_own_profile { + state + .ap_service + .count_accepted_followers(profile_user_uuid) + .await + .unwrap_or(0) + } else { + 0 + }; + #[cfg(not(feature = "federation"))] + let followers_count = 0usize; + + #[cfg(feature = "federation")] + let pending_followers: Vec = if is_own_profile { + state + .ap_service + .get_pending_followers(profile_user_uuid) + .await + .unwrap_or_default() + .into_iter() + .map(|a| application::ports::RemoteActorView { + handle: a.handle, + url: a.url, + display_name: a.display_name, + }) + .collect() + } else { + vec![] + }; + #[cfg(not(feature = "federation"))] + let pending_followers: Vec = vec![]; + + let query = application::queries::GetUserProfileQuery { + user_id: profile_user_uuid, + view: profile_view, + limit: params.limit, + offset: params.offset, + sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), + search: if params.search.is_empty() { + None + } else { + Some(params.search.clone()) + }, + }; + + match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { + Ok(profile) => { + let (offset, has_more, limit) = profile + .entries + .as_ref() + .map(|e| { + let has_more = + (e.offset as u64).saturating_add(e.limit as u64) < e.total_count; + (e.offset, has_more, e.limit) + }) + .unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT)); + if !is_own_profile { + ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); + } + let data = application::ports::ProfilePageData { + ctx, + profile_user_id: profile_user_uuid, + profile_user_email: profile_user.email().value().to_string(), + stats: profile.stats, + view: profile_view.as_str().to_string(), + entries: profile.entries, + current_offset: offset, + has_more, + limit, + history: profile.history, + trends: profile.trends, + is_own_profile, + error: params.error, + following_count, + followers_count, + pending_followers, + sort_by: sort_by_str.to_string(), + search: params.search.clone(), + }; + match state.html_renderer.render_profile_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + +#[cfg(feature = "federation")] +pub async fn follow_remote_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state.ap_service.follow(user_id.value(), &form.handle).await { + Ok(()) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + tracing::error!("follow error: {:?}", e); + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn unfollow_remote_user( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state + .ap_service + .unfollow(user_id.value(), &form.actor_url) + .await + { + Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)) + .into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!( + "/users/{}/following-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn accept_follower( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state + .ap_service + .accept_follower(user_id.value(), &form.actor_url) + .await + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn reject_follower( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state + .ap_service + .reject_follower(user_id.value(), &form.actor_url) + .await + { + Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn get_following_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Following — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/following-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + match state.ap_service.get_following(user_id.value()).await { + Ok(following) => { + let actors = following + .into_iter() + .map(|a| RemoteActorView { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(); + let data = FollowingPageData { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }; + match state.html_renderer.render_following_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => { + tracing::error!("get_following error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load following list", + ) + .into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn get_followers_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Followers — Movies Diary".to_string(); + ctx.canonical_url = format!( + "{}/users/{}/followers-list", + state.app_ctx.config.base_url, profile_user_uuid + ); + match state + .ap_service + .get_accepted_followers(user_id.value()) + .await + { + Ok(followers) => { + let actors = followers + .into_iter() + .map(|a| RemoteActorView { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }) + .collect(); + let data = FollowersPageData { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }; + match state.html_renderer.render_followers_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => { + tracing::error!("get_followers error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to load followers list", + ) + .into_response() + } + } +} + +#[cfg(feature = "federation")] +pub async fn remove_follower( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Extension(csrf): Extension, + Form(form): Form, +) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + if crate::csrf::mismatch(&csrf, &form.csrf_token) { + return StatusCode::FORBIDDEN.into_response(); + } + match state + .ap_service + .remove_follower(user_id.value(), &form.actor_url) + .await + { + Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)) + .into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!( + "/users/{}/followers-list?error={}", + profile_user_uuid, msg + )) + .into_response() + } + } +} diff --git a/crates/presentation/src/handlers/import.rs b/crates/presentation/src/handlers/import.rs new file mode 100644 index 0000000..db6672d --- /dev/null +++ b/crates/presentation/src/handlers/import.rs @@ -0,0 +1,875 @@ +use axum::{ + Extension, Form, + extract::{Multipart, Path, State}, + http::StatusCode, + response::{Html, IntoResponse, Redirect}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use application::{ + commands::{ + ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand, + ExecuteImportCommand, FileFormat, SaveImportProfileCommand, + }, + ports::{ + ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, + ImportRowStatus, ImportUploadPageData, + }, + use_cases::{ + apply_import_mapping, create_import_session, delete_import_profile, execute_import, + list_import_profiles, save_import_profile, + }, +}; +use domain::value_objects::ImportSessionId; +use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform}; + +use crate::{ + csrf::CsrfToken, + extractors::{AuthenticatedUser, RequiredCookieUser}, + state::AppState, +}; + +fn encode_error(msg: &str) -> String { + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string() +} + +fn str_to_domain_field(field: &str) -> Option { + match field { + "title" => Some(DomainField::Title), + "release_year" => Some(DomainField::ReleaseYear), + "director" => Some(DomainField::Director), + "rating" => Some(DomainField::Rating), + "watched_at" => Some(DomainField::WatchedAt), + "comment" => Some(DomainField::Comment), + "external_metadata_id" => Some(DomainField::ExternalMetadataId), + _ => None, + } +} + +fn parse_mapping_form(form: &HashMap) -> Vec { + let mut mappings = Vec::new(); + let mut i = 0usize; + loop { + if i > 64 { + break; + } + let col_key = format!("mapping_{i}_col"); + let Some(col) = form.get(&col_key).cloned() else { + break; + }; + let field_str = form + .get(&format!("mapping_{i}_field")) + .map(|s| s.as_str()) + .unwrap_or(""); + if let Some(domain_field) = str_to_domain_field(field_str) { + let transform = if domain_field == DomainField::Rating { + let scale: f64 = form + .get(&format!("mapping_{i}_scale")) + .and_then(|s| s.parse().ok()) + .unwrap_or(1.0); + Transform::RatingScale(scale) + } else if domain_field == DomainField::WatchedAt { + form.get(&format!("mapping_{i}_datefmt")) + .filter(|s| !s.is_empty()) + .cloned() + .map(Transform::DateFormat) + .unwrap_or(Transform::Identity) + } else { + Transform::Identity + }; + mappings.push(FieldMapping { + source_column: col, + domain_field, + transform, + }); + } + i += 1; + } + mappings +} + +fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow { + match &annotated.result { + RowResult::Valid(row) => { + let cells = vec![ + row.title.clone().unwrap_or_default(), + row.release_year.clone().unwrap_or_default(), + row.director.clone().unwrap_or_default(), + row.rating.clone().unwrap_or_default(), + row.watched_at.clone().unwrap_or_default(), + row.comment.clone().unwrap_or_default(), + ]; + ImportPreviewRow { + index: idx, + status: if annotated.is_duplicate { + ImportRowStatus::Duplicate + } else { + ImportRowStatus::Valid + }, + cells, + } + } + RowResult::Invalid { errors, raw } => ImportPreviewRow { + index: idx, + status: ImportRowStatus::Invalid(errors.join("; ")), + cells: raw.iter().map(|(_, v)| v.clone()).collect(), + }, + } +} + +// ── HTML wizard handlers ─────────────────────────────────────────────────── + +pub async fn get_import_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, +) -> impl IntoResponse { + let ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let profiles = list_import_profiles::execute(&state.app_ctx, &user_id) + .await + .unwrap_or_default() + .into_iter() + .map(|p| ImportProfileView { + id: p.id.value().to_string(), + name: p.name, + }) + .collect::>(); + let html = state + .html_renderer + .render_import_upload_page(ImportUploadPageData { + ctx, + profiles, + error: None, + }) + .unwrap_or_else(|e| e); + Html(html) +} + +pub async fn post_upload( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut file_bytes: Option> = None; + let mut format_str = "csv".to_string(); + + while let Ok(Some(field)) = multipart.next_field().await { + match field.name() { + Some("file") => { + if let Ok(bytes) = field.bytes().await { + file_bytes = Some(bytes.to_vec()); + } + } + Some("format") => { + if let Ok(text) = field.text().await { + format_str = text; + } + } + _ => {} + } + } + + let bytes = match file_bytes { + Some(b) if !b.is_empty() => b, + _ => return Redirect::to("/import?error=no+file+provided").into_response(), + }; + + let format = match format_str.as_str() { + "json" => FileFormat::Json, + "xlsx" => FileFormat::Xlsx, + _ => FileFormat::Csv, + }; + + match create_import_session::execute( + &state.app_ctx, + CreateImportSessionCommand { + user_id: user_id.value(), + bytes, + format, + }, + ) + .await + { + Ok(r) => { + Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response() + } + Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))) + .into_response(), + } +} + +pub async fn get_mapping_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let Ok(Some(session)) = state + .app_ctx + .import_session_repository + .get(&session_id, &user_id) + .await + else { + return Redirect::to("/import").into_response(); + }; + let Ok(parsed) = serde_json::from_str::(&session.parsed_data) else { + return Redirect::to("/import").into_response(); + }; + + let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; + let sample_rows = parsed.rows.into_iter().take(5).collect(); + let html = state + .html_renderer + .render_import_mapping_page(ImportMappingPageData { + ctx, + session_id: session_id_str, + columns: parsed.columns, + sample_rows, + domain_fields: vec![ + ("title", "Title"), + ("release_year", "Release Year"), + ("director", "Director"), + ("rating", "Rating"), + ("watched_at", "Watched At"), + ("comment", "Comment"), + ("external_metadata_id", "External ID"), + ], + error: None, + }) + .unwrap_or_else(|e| e); + Html(html).into_response() +} + +pub async fn post_mapping( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> impl IntoResponse { + let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); + if crate::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let mappings = parse_mapping_form(&form); + if mappings.is_empty() { + return Redirect::to(&format!( + "/import/{}/mapping?error=select+at+least+one+mapping", + session_id_str + )) + .into_response(); + } + match apply_import_mapping::execute( + &state.app_ctx, + ApplyImportMappingCommand { + user_id: user_id.value(), + session_id: session_id.value(), + mappings, + }, + ) + .await + { + Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(), + Err(e) => Redirect::to(&format!( + "/import/{}/mapping?error={}", + session_id_str, + encode_error(&e.to_string()) + )) + .into_response(), + } +} + +pub async fn get_preview_page( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + let Ok(Some(session)) = state + .app_ctx + .import_session_repository + .get(&session_id, &user_id) + .await + else { + return Redirect::to("/import").into_response(); + }; + + if session.row_results.is_none() { + return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response(); + } + + let parsed = + serde_json::from_str::(&session.parsed_data).unwrap_or_default(); + let annotated: Vec = session + .row_results + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + + let rows: Vec = annotated + .iter() + .enumerate() + .map(|(i, a)| annotated_to_preview_row(i, a)) + .collect(); + + let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await; + let html = state + .html_renderer + .render_import_preview_page(ImportPreviewPageData { + ctx, + session_id: session_id_str, + columns: parsed.columns, + rows, + }) + .unwrap_or_else(|e| e); + Html(html).into_response() +} + +pub async fn post_confirm( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(session_id_str): Path, + Extension(csrf): Extension, + Form(form_entries): Form>, +) -> impl IntoResponse { + let csrf_token = form_entries + .iter() + .find(|(k, _)| k == "_csrf") + .map(|(_, v)| v.as_str()) + .unwrap_or(""); + if crate::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return Redirect::to("/import").into_response(); + }; + + // Save profile if name provided + let profile_name = form_entries + .iter() + .find(|(k, _)| k == "profile_name") + .map(|(_, v)| v.clone()) + .filter(|n| !n.trim().is_empty()); + if let Some(name) = profile_name { + let _ = save_import_profile::execute( + &state.app_ctx, + SaveImportProfileCommand { + user_id: user_id.value(), + session_id: session_id.value(), + name, + }, + ) + .await; + } + + // Collect all "confirmed" checkbox values + let confirmed: Vec = form_entries + .iter() + .filter(|(k, _)| k == "confirmed") + .filter_map(|(_, v)| v.parse::().ok()) + .collect(); + + match execute_import::execute( + &state.app_ctx, + ExecuteImportCommand { + user_id: user_id.value(), + session_id: session_id.value(), + confirmed_indices: confirmed, + }, + ) + .await + { + Ok(summary) => Redirect::to(&format!( + "/import/done?imported={}&skipped={}&failed={}", + summary.imported, + summary.skipped_duplicates, + summary.failed.len() + )) + .into_response(), + Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))) + .into_response(), + } +} + +pub async fn post_delete_profile( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Path(profile_id_str): Path, + Extension(csrf): Extension, + Form(form): Form>, +) -> impl IntoResponse { + let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or(""); + if crate::csrf::mismatch(&csrf, csrf_token) { + return Redirect::to("/import").into_response(); + } + if let Ok(profile_id) = profile_id_str.parse::() { + let _ = delete_import_profile::execute( + &state.app_ctx, + DeleteImportProfileCommand { + user_id: user_id.value(), + profile_id, + }, + ) + .await; + } + Redirect::to("/import").into_response() +} + +#[derive(Deserialize)] +pub struct ImportDoneParams { + pub imported: Option, + pub skipped: Option, + pub failed: Option, +} + +pub async fn get_import_done( + State(state): State, + RequiredCookieUser(user_id): RequiredCookieUser, + Extension(csrf): Extension, + axum::extract::Query(params): axum::extract::Query, +) -> impl IntoResponse { + let _ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await; + let html = format!( + r#" +

Import Complete

+

Imported: {}

+

Skipped duplicates: {}

+

Failed: {}

+ Go to My Profile + "#, + params.imported.unwrap_or(0), + params.skipped.unwrap_or(0), + params.failed.unwrap_or(0), + user_id.value(), + ); + Html(html) +} + +// ── REST API handlers ────────────────────────────────────────────────────── + +#[derive(Serialize, utoipa::ToSchema)] +pub struct SessionCreatedResponse { + pub session_id: String, + pub columns: Vec, + pub sample_rows: Vec>, +} + +#[utoipa::path( + post, path = "/api/v1/import/sessions", + request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"), + responses( + (status = 200, body = SessionCreatedResponse), + (status = 400, description = "No file provided"), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Parse error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut file_bytes: Option> = None; + let mut format_str = "csv".to_string(); + while let Ok(Some(field)) = multipart.next_field().await { + match field.name() { + Some("file") => { + if let Ok(b) = field.bytes().await { + file_bytes = Some(b.to_vec()); + } + } + Some("format") => { + if let Ok(t) = field.text().await { + format_str = t; + } + } + _ => {} + } + } + let bytes = match file_bytes { + Some(b) if !b.is_empty() => b, + _ => { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "no file"})), + ) + .into_response(); + } + }; + let format = match format_str.as_str() { + "json" => FileFormat::Json, + "xlsx" => FileFormat::Xlsx, + _ => FileFormat::Csv, + }; + match create_import_session::execute( + &state.app_ctx, + CreateImportSessionCommand { + user_id: user_id.value(), + bytes, + format, + }, + ) + .await + { + Ok(r) => axum::Json(SessionCreatedResponse { + session_id: r.session_id.value().to_string(), + columns: r.columns, + sample_rows: r.sample_rows, + }) + .into_response(), + Err(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct SessionStateResponse { + pub session_id: String, + pub columns: Vec, + pub has_mappings: bool, + pub row_count: usize, +} + +#[utoipa::path( + get, path = "/api/v1/import/sessions/{id}", + params(("id" = String, Path, description = "Import session UUID")), + responses( + (status = 200, body = SessionStateResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_session( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + match state + .app_ctx + .import_session_repository + .get(&session_id, &user_id) + .await + { + Ok(Some(session)) => { + let parsed = serde_json::from_str::(&session.parsed_data) + .unwrap_or_default(); + let row_count = parsed.rows.len(); + axum::Json(SessionStateResponse { + session_id: session_id_str, + columns: parsed.columns, + has_mappings: session.field_mappings.is_some(), + row_count, + }) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({"error": "session not found"})), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ApiFieldMapping { + /// Column name in the source file + pub source_column: String, + /// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id + pub domain_field: String, + /// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale) + pub rating_scale: Option, + /// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y") + pub date_format: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ApplyMappingRequest { + pub mappings: Vec, +} + +#[utoipa::path( + put, path = "/api/v1/import/sessions/{id}/mapping", + params(("id" = String, Path, description = "Import session UUID")), + request_body = ApplyMappingRequest, + responses( + (status = 200, description = "Mapping applied", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Mapping error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_put_mapping( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + let mappings: Vec = body + .mappings + .into_iter() + .filter_map(|m| { + let domain_field = str_to_domain_field(&m.domain_field)?; + let transform = if domain_field == DomainField::Rating { + Transform::RatingScale(m.rating_scale.unwrap_or(1.0)) + } else if domain_field == DomainField::WatchedAt { + m.date_format + .map(Transform::DateFormat) + .unwrap_or(Transform::Identity) + } else { + Transform::Identity + }; + Some(FieldMapping { + source_column: m.source_column, + domain_field, + transform, + }) + }) + .collect(); + + match apply_import_mapping::execute( + &state.app_ctx, + ApplyImportMappingCommand { + user_id: user_id.value(), + session_id: session_id.value(), + mappings, + }, + ) + .await + { + Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(), + Err(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct ConfirmRequest { + /// Indices (0-based) of rows from the mapping preview to import + pub confirmed_indices: Vec, +} + +#[utoipa::path( + post, path = "/api/v1/import/sessions/{id}/confirm", + params(("id" = String, Path, description = "Import session UUID")), + request_body = ConfirmRequest, + responses( + (status = 200, description = "Import summary", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Session not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_confirm( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(session_id_str): Path, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let Ok(session_id) = session_id_str + .parse::() + .map(ImportSessionId::from_uuid) + else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + match execute_import::execute(&state.app_ctx, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), confirmed_indices: body.confirmed_indices }).await { + Ok(s) => axum::Json(serde_json::json!({ + "imported": s.imported, + "skipped_duplicates": s.skipped_duplicates, + "failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::>(), + })).into_response(), + Err(e) => { + let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response() + } + } +} + +#[utoipa::path( + get, path = "/api/v1/import/profiles", + responses( + (status = 200, description = "List of saved import profiles", body = inline(Vec)), + (status = 401, description = "Unauthorized"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_get_profiles( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> impl IntoResponse { + match list_import_profiles::execute(&state.app_ctx, &user_id).await { + Ok(profiles) => axum::Json( + profiles + .into_iter() + .map(|p| { + serde_json::json!({ + "id": p.id.value().to_string(), + "name": p.name, + "created_at": p.created_at.to_string(), + }) + }) + .collect::>(), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SaveProfileRequest { + /// Session UUID whose current field_mappings to save + pub session_id: String, + /// Human-readable profile name (e.g. "Letterboxd") + pub name: String, +} + +#[utoipa::path( + post, path = "/api/v1/import/profiles", + request_body = SaveProfileRequest, + responses( + (status = 200, description = "Profile saved", body = inline(serde_json::Value)), + (status = 401, description = "Unauthorized"), + (status = 422, description = "Session has no mapping yet"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_post_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let Ok(session_id) = body + .session_id + .parse::() + .map(ImportSessionId::from_uuid) + else { + return ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid session id"})), + ) + .into_response(); + }; + match save_import_profile::execute( + &state.app_ctx, + SaveImportProfileCommand { + user_id: user_id.value(), + session_id: session_id.value(), + name: body.name, + }, + ) + .await + { + Ok(id) => axum::Json(serde_json::json!({"id": id.value().to_string()})).into_response(), + Err(e) => ( + StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(), + } +} + +#[utoipa::path( + delete, path = "/api/v1/import/profiles/{id}", + params(("id" = String, Path, description = "Import profile UUID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Profile not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn api_delete_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + Path(profile_id_str): Path, +) -> impl IntoResponse { + let Ok(profile_id) = profile_id_str.parse::() else { + return StatusCode::BAD_REQUEST.into_response(); + }; + match delete_import_profile::execute( + &state.app_ctx, + DeleteImportProfileCommand { + user_id: user_id.value(), + profile_id, + }, + ) + .await + { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + let status = if matches!(e, domain::errors::DomainError::NotFound(_)) { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + status.into_response() + } + } +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs new file mode 100644 index 0000000..68fea13 --- /dev/null +++ b/crates/presentation/src/handlers/mod.rs @@ -0,0 +1,8 @@ +pub mod html; +pub mod posters; +pub mod rss; +pub mod api; +pub mod import; + +const DEFAULT_PAGE_LIMIT: u32 = 5; +const RSS_FEED_LIMIT: u32 = 50; diff --git a/crates/presentation/src/handlers/posters.rs b/crates/presentation/src/handlers/posters.rs new file mode 100644 index 0000000..d6b3024 --- /dev/null +++ b/crates/presentation/src/handlers/posters.rs @@ -0,0 +1,33 @@ +use axum::{ + extract::{Path, State}, + http::{StatusCode, header}, + response::IntoResponse, +}; + +use domain::value_objects::PosterPath; + +use crate::state::AppState; + +pub async fn get_poster( + State(state): State, + Path(path): Path, +) -> impl IntoResponse { + // If path is a remote URL, redirect directly instead of serving from local storage. + if path.starts_with("http://") || path.starts_with("https://") { + return axum::response::Redirect::temporary(&path).into_response(); + } + + let poster_path = match PosterPath::new(path) { + Ok(p) => p, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.app_ctx.poster_storage.get_poster(&poster_path).await { + Ok(bytes) => { + let mime = infer::get(&bytes) + .map(|t| t.mime_type()) + .unwrap_or("application/octet-stream"); + ([(header::CONTENT_TYPE, mime)], bytes).into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/crates/presentation/src/handlers/rss.rs b/crates/presentation/src/handlers/rss.rs new file mode 100644 index 0000000..10bc0a4 --- /dev/null +++ b/crates/presentation/src/handlers/rss.rs @@ -0,0 +1,65 @@ +use axum::{ + extract::{Path, State}, + http::header, + response::IntoResponse, +}; +use uuid::Uuid; + +use application::{queries::GetDiaryQuery, use_cases::get_diary}; +use domain::{errors::DomainError, models::SortDirection, value_objects::UserId}; + +use crate::{errors::ApiError, state::AppState}; + +pub async fn get_feed(State(state): State) -> Result { + let query = GetDiaryQuery { + limit: Some(super::RSS_FEED_LIMIT), + offset: Some(0), + sort_by: Some(SortDirection::Descending), + movie_id: None, + user_id: None, + }; + let page = get_diary::execute(&state.app_ctx, query).await?; + let xml = state + .rss_renderer + .render_feed(&page.items, "Movie Diary") + .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; + Ok(( + [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], + xml, + )) +} + +pub async fn get_user_feed( + State(state): State, + Path(user_id): Path, +) -> Result { + let user = state + .app_ctx + .user_repository + .find_by_id(&UserId::from_uuid(user_id)) + .await + .map_err(ApiError)? + .ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?; + + let query = GetDiaryQuery { + limit: Some(super::RSS_FEED_LIMIT), + offset: Some(0), + sort_by: Some(SortDirection::Descending), + movie_id: None, + user_id: Some(user_id), + }; + let page = get_diary::execute(&state.app_ctx, query).await?; + + let display_name = user.email().value().split('@').next().unwrap_or("User"); + let title = format!("{}'s Movie Diary", display_name); + + let xml = state + .rss_renderer + .render_feed(&page.items, &title) + .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; + + Ok(( + [(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")], + xml, + )) +} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index e76b10f..26d0dd9 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -14,7 +14,7 @@ use doc::ApiDocExt; use presentation::{openapi::ApiDoc, routes, state::AppState}; use utoipa::OpenApi as _; -use domain::ports::{DiaryExporter, EventPublisher}; +use domain::ports::{DiaryExporter, EventPublisher, ImportProfileRepository, ImportSessionRepository}; #[cfg(not(any(feature = "sqlite", feature = "postgres")))] compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres"); @@ -50,17 +50,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let poster_fetcher = poster_fetcher::create()?; let poster_storage = poster_storage::create()?; - let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) = + let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = match backend.as_str() { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u) = postgres::wire(&database_url).await?; - (m, r, d, s, u, DbPool::Postgres(pool)) + let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?; + (m, r, d, s, u, is, ip, DbPool::Postgres(pool)) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u) = sqlite::wire(&database_url).await?; - (m, r, d, s, u, DbPool::Sqlite(pool)) + let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?; + (m, r, d, s, u, is, ip, DbPool::Sqlite(pool)) } #[cfg(not(feature = "sqlite"))] _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), @@ -158,6 +158,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { auth_service, password_hasher, user_repository, + import_session_repository: import_session_repository as Arc, + import_profile_repository: import_profile_repository as Arc, config: app_config, }; diff --git a/crates/presentation/src/openapi.rs b/crates/presentation/src/openapi.rs index 1fcaf0c..450f0e7 100644 --- a/crates/presentation/src/openapi.rs +++ b/crates/presentation/src/openapi.rs @@ -10,6 +10,10 @@ use crate::dtos::{ ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }; +use crate::handlers::import::{ + ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, + SessionCreatedResponse, SessionStateResponse, +}; #[cfg(feature = "federation")] use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto}; @@ -45,6 +49,13 @@ impl Modify for SecurityAddon { crate::handlers::api::get_activity_feed, crate::handlers::api::list_users, crate::handlers::api::get_user_profile, + crate::handlers::import::api_post_session, + crate::handlers::import::api_get_session, + crate::handlers::import::api_put_mapping, + crate::handlers::import::api_post_confirm, + crate::handlers::import::api_get_profiles, + crate::handlers::import::api_post_profile, + crate::handlers::import::api_delete_profile, ), components(schemas( DiaryResponse, @@ -66,6 +77,12 @@ impl Modify for SecurityAddon { MonthlyRatingDto, DirectorStatDto, UserTrendsDto, + SessionCreatedResponse, + SessionStateResponse, + ApiFieldMapping, + ApplyMappingRequest, + ConfirmRequest, + SaveProfileRequest, )), modifiers(&SecurityAddon), )] @@ -99,6 +116,13 @@ pub struct ApiDoc; crate::handlers::api::accept_follower, crate::handlers::api::reject_follower, crate::handlers::api::remove_follower, + crate::handlers::import::api_post_session, + crate::handlers::import::api_get_session, + crate::handlers::import::api_put_mapping, + crate::handlers::import::api_post_confirm, + crate::handlers::import::api_get_profiles, + crate::handlers::import::api_post_profile, + crate::handlers::import::api_delete_profile, ), components(schemas( DiaryResponse, @@ -124,6 +148,12 @@ pub struct ApiDoc; MonthlyRatingDto, DirectorStatDto, UserTrendsDto, + SessionCreatedResponse, + SessionStateResponse, + ApiFieldMapping, + ApplyMappingRequest, + ConfirmRequest, + SaveProfileRequest, )), modifiers(&SecurityAddon), )] diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 60ca219..85cdaa4 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -65,17 +65,23 @@ fn html_routes(rate_limit: u64) -> Router { routing::get(handlers::posters::get_poster), ) .route("/diary/export", routing::get(handlers::html::get_export)) + .route("/import", routing::get(handlers::import::get_import_page)) + .route("/import/upload", routing::post(handlers::import::post_upload)) + .route("/import/{id}/mapping", routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping)) + .route("/import/{id}/preview", routing::get(handlers::import::get_preview_page)) + .route("/import/{id}/confirm", routing::post(handlers::import::post_confirm)) + .route("/import/done", routing::get(handlers::import::get_import_done)) + .route("/import/profiles/{profile_id}/delete", routing::post(handlers::import::post_delete_profile)) .route("/feed.rss", routing::get(handlers::rss::get_feed)) .route( "/users/{id}/feed.rss", routing::get(handlers::rss::get_user_feed), - ) - .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware)); + ); #[cfg(feature = "federation")] let base = base.merge(federation_html_routes()); - base + base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware)) } #[cfg(feature = "federation")] @@ -142,7 +148,13 @@ fn api_routes(rate_limit: u64) -> Router { routing::get(handlers::api::get_activity_feed), ) .route("/users", routing::get(handlers::api::list_users)) - .route("/users/{id}", routing::get(handlers::api::get_user_profile)); + .route("/users/{id}", routing::get(handlers::api::get_user_profile)) + .route("/import/sessions", routing::post(handlers::import::api_post_session)) + .route("/import/sessions/{id}", routing::get(handlers::import::api_get_session)) + .route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping)) + .route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm)) + .route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile)) + .route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile)); #[cfg(feature = "federation")] let base = base.merge(federation_api_routes()); diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 45edea5..d3a65a6 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -125,6 +125,26 @@ impl domain::ports::DiaryExporter for PanicExporter { } } +struct PanicImportSession; +#[async_trait] +impl domain::ports::ImportSessionRepository for PanicImportSession { + async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result, DomainError> { panic!() } + async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } + async fn delete_expired(&self) -> Result { panic!() } + async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } +} + +struct PanicImportProfile; +#[async_trait] +impl domain::ports::ImportProfileRepository for PanicImportProfile { + async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } + async fn list_for_user(&self, _: &UserId) -> Result, DomainError> { panic!() } + async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } +} + #[cfg(feature = "federation")] struct PanicSocialQuery; #[cfg(feature = "federation")] @@ -165,6 +185,8 @@ async fn test_app() -> Router { auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), user_repository: Arc::new(NobodyUserRepo), + import_session_repository: Arc::new(PanicImportSession), + import_profile_repository: Arc::new(PanicImportProfile), config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index fee9067..feefa81 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -31,6 +31,7 @@ auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } poster-storage = { workspace = true } +poster-sync = { workspace = true } export = { workspace = true } nats = { workspace = true, optional = true } sqlx = { workspace = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 03752d1..ba055b7 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Context; -use application::{config::AppConfig, context::AppContext, event_handlers::PosterSyncHandler, worker::WorkerService}; +use application::{config::AppConfig, context::AppContext, worker::WorkerService}; use export::ExportAdapter; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -25,17 +25,17 @@ async fn main() -> anyhow::Result<()> { let poster_fetcher = poster_fetcher::create()?; let poster_storage = poster_storage::create()?; - let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) = + let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = match backend.as_str() { #[cfg(feature = "postgres")] "postgres" => { - let (pool, m, r, d, s, u) = postgres::wire(&database_url).await?; - (m, r, d, s, u, DbPool::Postgres(pool)) + let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?; + (m, r, d, s, u, is, ip, DbPool::Postgres(pool)) } #[cfg(feature = "sqlite")] _ => { - let (pool, m, r, d, s, u) = sqlite::wire(&database_url).await?; - (m, r, d, s, u, DbPool::Sqlite(pool)) + let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?; + (m, r, d, s, u, is, ip, DbPool::Sqlite(pool)) } #[cfg(not(feature = "sqlite"))] _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build"), @@ -86,11 +86,34 @@ async fn main() -> anyhow::Result<()> { auth_service, password_hasher, user_repository, + import_session_repository, + import_profile_repository, config: app_config, }; + // Spawn periodic import session cleanup (hourly) + { + let cleanup_ctx = ctx.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600)); + loop { + interval.tick().await; + match application::use_cases::cleanup_expired_import_sessions::execute(&cleanup_ctx).await { + Ok(n) => tracing::info!("import session cleanup: removed {} expired sessions", n), + Err(e) => tracing::error!("import session cleanup failed: {:?}", e), + } + } + }); + } + let handlers: Vec> = { - let poster = Arc::new(PosterSyncHandler::new(ctx, 3)) as Arc; + let poster = Arc::new(poster_sync::PosterSyncHandler::new( + Arc::clone(&ctx.movie_repository), + Arc::clone(&ctx.metadata_client), + Arc::clone(&ctx.poster_fetcher), + Arc::clone(&ctx.poster_storage), + 3, + )) as Arc; #[cfg(not(feature = "federation"))] { vec![poster] } diff --git a/test_data/generic_export.json b/test_data/generic_export.json new file mode 100644 index 0000000..8968c1c --- /dev/null +++ b/test_data/generic_export.json @@ -0,0 +1,8 @@ +[ + {"movie_title": "Annihilation", "release_year": "2018", "director": "Alex Garland", "my_rating": "4", "date_watched": "2024-01-05", "notes": "Unsettling and beautiful. The lighthouse sequence is unforgettable."}, + {"movie_title": "Tár", "release_year": "2022", "director": "Todd Field", "my_rating": "5", "date_watched": "2024-01-30", "notes": "Blanchett's finest performance."}, + {"movie_title": "Arrival", "release_year": "2016", "director": "Denis Villeneuve", "my_rating": "5", "date_watched": "2024-02-14", "notes": ""}, + {"movie_title": "The Banshees of Inisherin", "release_year": "2022", "director": "Martin McDonagh", "my_rating": "4", "date_watched": "2024-03-01", "notes": "Melancholy and darkly funny in equal measure."}, + {"movie_title": "Saltburn", "release_year": "2023", "director": "Emerald Fennell", "my_rating": "3", "date_watched": "2024-03-20", "notes": "Provocative but hollow."}, + {"movie_title": "All of Us Strangers", "release_year": "2023", "director": "Andrew Haigh", "my_rating": "5", "date_watched": "2024-04-05", "notes": "Wrecked me completely."} +] diff --git a/test_data/imdb_export.csv b/test_data/imdb_export.csv new file mode 100644 index 0000000..db7747b --- /dev/null +++ b/test_data/imdb_export.csv @@ -0,0 +1,9 @@ +Title,Year,IMDb ID,Your Rating,Date Rated,Directors +Interstellar,2014,tt0816692,9,2024-01-08,Christopher Nolan +No Country for Old Men,2007,tt0477348,10,2024-01-20,Joel Coen +The Lighthouse,2019,tt7984734,8,2024-02-03,Robert Eggers +Hereditary,2018,tt7784604,7,2024-02-19,Ari Aster +The Favourite,2018,tt5765884,8,2024-03-07,Yorgos Lanthimos +Mad Max: Fury Road,2015,tt1392190,9,2024-03-25,George Miller +Portrait of a Lady on Fire,2019,tt8613070,9,2024-04-12,Céline Sciamma +The Zone of Interest,2023,tt7160372,8,2024-04-30,Jonathan Glazer diff --git a/test_data/letterboxd_export.csv b/test_data/letterboxd_export.csv new file mode 100644 index 0000000..6d5e8df --- /dev/null +++ b/test_data/letterboxd_export.csv @@ -0,0 +1,11 @@ +Name,Year,Rating,Watched Date,Review +Inception,2010,5,2024-01-15,Mind-bending masterpiece. The layered dream sequences still hold up perfectly. +The Godfather,1972,5,2024-01-22, +Dune: Part Two,2024,4,2024-03-02,Visually stunning. Chalamet carries the whole thing. +Parasite,2019,5,2024-02-10,Bong Joon-ho at his absolute best. Rewatched for the third time. +Everything Everywhere All at Once,2022,4,2024-02-28,Exhausting in the best possible way. +Oppenheimer,2023,4,2024-03-15,Three hours flew by. Murphy is phenomenal. +The Dark Knight,2008,5,2024-04-01,Still the gold standard for superhero cinema. +Past Lives,2023,4,2024-04-10,Quietly devastating. Stayed with me for days. +Aftersun,2022,5,2024-04-18, +Poor Things,2024,3,2024-05-02,Visually remarkable but style over substance for me.