importer feature

This commit is contained in:
2026-05-10 21:23:56 +02:00
parent a47e3ae4e6
commit f2f1317660
77 changed files with 4884 additions and 1810 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

104
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,192 @@
use crate::types::{AnnotatedRow, DomainField, FieldMapping, ImportRow, ParsedFile, RowResult, Transform};
pub fn apply_mapping(file: &ParsedFile, mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
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<String>) -> Option<String> {
match transform {
Transform::Identity => Some(value.to_string()),
Transform::DateFormat(_) => Some(value.to_string()),
Transform::RatingScale(factor) => {
match value.parse::<f64>() {
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<FieldMapping> {
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 { .. }));
}
}

View File

@@ -0,0 +1,49 @@
use crate::{ImportError, types::ParsedFile};
pub fn parse_csv(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
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<String> = 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<Vec<String>> = rdr
.records()
.map(|r| {
r.map_err(|e| ImportError::Csv(e.to_string()))
.map(|rec| {
let mut cells: Vec<String> = rec.iter().map(|f| f.trim().to_string()).collect();
cells.resize(columns.len(), String::new());
cells.truncate(columns.len());
cells
})
})
.collect::<Result<_, _>>()?;
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',' }
}

View File

@@ -0,0 +1,43 @@
use serde_json::Value;
use crate::{ImportError, types::ParsedFile};
pub fn parse_json(bytes: &[u8]) -> Result<ParsedFile, ImportError> {
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<String> = first.keys().cloned().collect();
if columns.is_empty() {
return Err(ImportError::NoHeader);
}
let rows: Vec<Vec<String>> = 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::<Result<_, ImportError>>()?;
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(),
}
}

View File

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

View File

@@ -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<ParsedFile, ImportError> {
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<String> = header.iter()
.map(|c| cell_to_string(c).trim().to_string())
.collect();
if columns.is_empty() {
return Err(ImportError::NoHeader);
}
let rows: Vec<Vec<String>> = iter
.map(|row| {
let mut cells: Vec<String> = 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:?}"),
}
}

View File

@@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ParsedFile {
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
}
#[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<String>,
pub release_year: Option<String>,
pub director: Option<String>,
pub rating: Option<String>,
pub watched_at: Option<String>,
pub comment: Option<String>,
pub external_metadata_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RowResult {
Valid(ImportRow),
Invalid { errors: Vec<String>, 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,
}

View File

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

View File

@@ -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<dyn MovieRepository>,
metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>,
poster_storage: Arc<dyn PosterStorage>,
max_retries: u32,
}
impl PosterSyncHandler {
pub fn new(
movie_repository: Arc<dyn MovieRepository>,
metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>,
poster_storage: Arc<dyn PosterStorage>,
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<DomainError> = 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)
}
}

View File

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

View File

@@ -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<Vec<ImportProfile>, 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<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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<Option<ImportProfile>, 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<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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)
}
}

View File

@@ -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<Option<ImportSession>, 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<String>,
row_results: Option<String>,
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<ImportSession, DomainError> {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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<u64, DomainError> {
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)
}
}

View File

@@ -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<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
)> {
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 _,
))
}

View File

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

View File

@@ -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, DomainError> {
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<Vec<ImportProfile>, 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<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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<Option<ImportProfile>, 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<ImportProfile, DomainError> {
Ok(ImportProfile {
id: ImportProfileId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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)
}
}

View File

@@ -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, DomainError> {
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<Option<ImportSession>, 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<ImportSession, DomainError> {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
r.id.parse::<uuid::Uuid>().map_err(|e| DomainError::InfrastructureError(e.to_string()))?
),
user_id: UserId::from_uuid(
r.user_id.parse::<uuid::Uuid>().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<u64, DomainError> {
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)
}
}

View File

@@ -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<dyn domain::ports::DiaryRepository>,
std::sync::Arc<dyn domain::ports::StatsRepository>,
std::sync::Arc<dyn domain::ports::UserRepository>,
std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
)> {
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 _,
))
}

View File

@@ -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<String>],
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<String, String> {
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<String, String> {
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<String, String> {
ImportPreviewTemplate {
ctx: &data.ctx,
session_id: &data.session_id,
columns: &data.columns,
rows: &data.rows,
}
.render()
.map_err(|e| e.to_string())
}
}

View File

@@ -33,6 +33,7 @@
{% if let Some(uid) = ctx.user_id %}
<a href="/users/{{ uid }}">Profile</a>
<a href="/reviews/new">Add Review</a>
<a href="/import">Import</a>
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<h1>Map Columns</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<p>Showing up to 5 sample rows. Map each column to a diary field.</p>
<form method="POST" action="/import/{{ session_id }}/mapping">
<table>
<thead>
<tr>
{% for col in columns %}<th>{{ col }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in sample_rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% for col in columns %}
<fieldset>
<legend>{{ col }}</legend>
<label>Maps to
<select name="mapping_{{ loop.index0 }}_field">
<option value="">— skip —</option>
{% for (val, label) in domain_fields %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Rating scale
<select name="mapping_{{ loop.index0 }}_scale">
<option value="1.0">Same (05)</option>
<option value="0.5">10-point (/2)</option>
<option value="0.05">Percentage (/20)</option>
</select>
</label>
<label>Date format
<input type="text" name="mapping_{{ loop.index0 }}_datefmt" placeholder="%Y-%m-%d">
</label>
<input type="hidden" name="mapping_{{ loop.index0 }}_col" value="{{ col }}">
</fieldset>
{% endfor %}
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Preview Import</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<h1>Preview Import</h1>
<form method="POST" action="/import/{{ session_id }}/confirm">
<table>
<thead>
<tr>
<th>Include?</th>
{% for col in columns %}<th>{{ col }}</th>{% endfor %}
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
{% match row.status %}
{% when ImportRowStatus::Invalid with (_e) %}
<input type="checkbox" disabled>
{% when _ %}
<input type="checkbox" name="confirmed" value="{{ row.index }}" checked>
{% endmatch %}
</td>
{% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
<td>
{% match row.status %}
{% when ImportRowStatus::Valid %}&#10003;
{% when ImportRowStatus::Duplicate %}&#9888; duplicate
{% when ImportRowStatus::Invalid with (e) %}&#10007; {{ e }}
{% endmatch %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<label>Save this mapping as a profile?
<input type="text" name="profile_name" placeholder="e.g. Letterboxd">
</label>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Import Selected</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<h1>Import Reviews</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
{% if !profiles.is_empty() %}
<section>
<h2>Saved Profiles</h2>
<ul>
{% for p in profiles %}
<li>
{{ p.name }}
<form method="POST" action="/import/profiles/{{ p.id }}/delete" style="display:inline">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Delete</button>
</form>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<h2>Upload File</h2>
<form method="POST" action="/import/upload" enctype="multipart/form-data">
<label>
File (CSV, TSV, JSON, XLSX)<br>
<input type="file" name="file" accept=".csv,.tsv,.json,.xlsx" required>
</label>
<label>
Format<br>
<select name="format">
<option value="csv">CSV / TSV</option>
<option value="json">JSON</option>
<option value="xlsx">XLSX</option>
</select>
</label>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Upload</button>
</form>
{% endblock %}

View File

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

View File

@@ -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<u8>,
pub format: FileFormat,
}
pub struct ApplyImportMappingCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub mappings: Vec<FieldMapping>,
}
pub struct ExecuteImportCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub confirmed_indices: Vec<usize>,
}
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,
}

View File

@@ -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<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub user_repository: Arc<dyn UserRepository>,
pub import_session_repository: Arc<dyn ImportSessionRepository>,
pub import_profile_repository: Arc<dyn ImportProfileRepository>,
pub config: AppConfig,
}

View File

@@ -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<DomainError> = 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)
}
}

View File

@@ -1,5 +1,4 @@
pub mod commands;
pub mod event_handlers;
pub mod worker;
pub mod config;
pub mod context;

View File

@@ -95,6 +95,45 @@ pub struct FollowersPageData {
pub error: Option<String>,
}
pub struct ImportUploadPageData {
pub ctx: HtmlPageContext,
pub profiles: Vec<ImportProfileView>,
pub error: Option<String>,
}
pub struct ImportProfileView {
pub id: String,
pub name: String,
}
pub struct ImportMappingPageData {
pub ctx: HtmlPageContext,
pub session_id: String,
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
pub domain_fields: Vec<(&'static str, &'static str)>,
pub error: Option<String>,
}
pub struct ImportPreviewRow {
pub index: usize,
pub status: ImportRowStatus,
pub cells: Vec<String>,
}
pub enum ImportRowStatus {
Valid,
Duplicate,
Invalid(String),
}
pub struct ImportPreviewPageData {
pub ctx: HtmlPageContext,
pub session_id: String,
pub columns: Vec<String>,
pub rows: Vec<ImportPreviewRow>,
}
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<String, String>;
fn render_following_page(&self, data: FollowingPageData) -> Result<String, String>;
fn render_followers_page(&self, data: FollowersPageData) -> Result<String, String>;
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
}
pub trait RssFeedRenderer: Send + Sync {

View File

@@ -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<Vec<AnnotatedRow>, 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<bool, DomainError> {
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::<u16>().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)
}

View File

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

View File

@@ -0,0 +1,6 @@
use domain::errors::DomainError;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
ctx.import_session_repository.delete_expired().await
}

View File

@@ -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<String>,
pub sample_rows: Vec<Vec<String>>,
}
pub async fn execute(ctx: &AppContext, cmd: CreateImportSessionCommand) -> Result<CreateSessionResult, DomainError> {
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<u8>, format: FileFormat) -> Result<ParsedFile, ImportError> {
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())) }
}
}
}

View File

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

View File

@@ -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<ImportSummary, DomainError> {
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<AnnotatedRow> = session.row_results
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let confirmed_set: std::collections::HashSet<usize> = 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<LogReviewCommand, String> {
let rating = row.rating.as_deref()
.ok_or("missing rating")?
.parse::<u8>()
.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,
})
}

View File

@@ -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<Vec<ImportProfile>, DomainError> {
ctx.import_profile_repository.list_for_user(user_id).await
}

View File

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

View File

@@ -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<ImportProfileId, DomainError> {
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)
}

View File

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

View File

@@ -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<String>,
pub row_results: Option<String>,
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 }
}
}

View File

@@ -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 {

View File

@@ -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<Option<ImportSession>, DomainError>;
async fn update(&self, session: &ImportSession) -> Result<(), DomainError>;
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError>;
async fn delete_expired(&self) -> Result<u64, DomainError>;
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<Vec<ImportProfile>, DomainError>;
async fn get(&self, id: &ImportProfileId, user_id: &UserId) -> Result<Option<ImportProfile>, DomainError>;
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError>;
}

View File

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

View File

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

View File

@@ -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<Option<domain::models::ImportSession>, 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<u64, DomainError> { 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<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, 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<String, String> {
panic!()
}
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, 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<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(review_id): Path<Uuid>,
) -> 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<AppState>,
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<AppState>,
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<AppState>,
user: AuthenticatedUser,
Json(body): Json<FollowRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> 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<AppState>,
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<AppState>,
Query(params): Query<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, 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<AppState>,
) -> Result<Json<UsersResponse>, 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<AppState>,
Path(user_id): Path<Uuid>,
Query(params): Query<UserProfileQueryParams>,
) -> 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<AppState>,
user: AuthenticatedUser,
Query(params): Query<ExportQueryParams>,
) -> 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()
}
}
}

View File

@@ -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<UserId>,
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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LoginForm>,
) -> 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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RegisterForm>,
) -> 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<AppState>,
Query(params): Query<ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<LogReviewForm>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(review_id): Path<Uuid>,
Form(form): Form<crate::dtos::DeleteRedirectForm>,
) -> 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Query(params): Query<crate::dtos::ExportQueryParams>,
) -> 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<AppState>,
Query(params): Query<FeedQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<domain::ports::FollowingFilter> = 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<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> 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::<Vec<domain::ports::RemoteActorInfo>, 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<crate::dtos::ProfileQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<application::ports::RemoteActorView> = 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<application::ports::RemoteActorView> = 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<UnfollowForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Query(params): Query<crate::dtos::ErrorQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> 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<AppState>,
Path(profile_user_uuid): Path<Uuid>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<FollowerActionForm>,
) -> 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()
}
}
}

View File

@@ -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<DomainField> {
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<String, String>) -> Vec<FieldMapping> {
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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
) -> 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::<Vec<_>>();
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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut file_bytes: Option<Vec<u8>> = 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.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::<importer::ParsedFile>(&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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> 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::<uuid::Uuid>()
.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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.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::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session
.row_results
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
let rows: Vec<ImportPreviewRow> = 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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(session_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form_entries): Form<Vec<(String, String)>>,
) -> 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::<uuid::Uuid>()
.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<usize> = form_entries
.iter()
.filter(|(k, _)| k == "confirmed")
.filter_map(|(_, v)| v.parse::<usize>().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<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Path(profile_id_str): Path<String>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<HashMap<String, String>>,
) -> 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::<uuid::Uuid>() {
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<usize>,
pub skipped: Option<usize>,
pub failed: Option<usize>,
}
pub async fn get_import_done(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
) -> impl IntoResponse {
let _ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let html = format!(
r#"<!doctype html><html><body>
<h1>Import Complete</h1>
<p>Imported: {}</p>
<p>Skipped duplicates: {}</p>
<p>Failed: {}</p>
<a href="/users/{}">Go to My Profile</a>
</body></html>"#,
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<String>,
pub sample_rows: Vec<Vec<String>>,
}
#[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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut file_bytes: Option<Vec<u8>> = 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<String>,
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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.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::<importer::ParsedFile>(&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<f64>,
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
pub date_format: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ApplyMappingRequest {
pub mappings: Vec<ApiFieldMapping>,
}
#[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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ApplyMappingRequest>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
let mappings: Vec<FieldMapping> = 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<usize>,
}
#[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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(session_id_str): Path<String>,
axum::Json(body): axum::Json<ConfirmRequest>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str
.parse::<uuid::Uuid>()
.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::<Vec<_>>(),
})).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<serde_json::Value>)),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn api_get_profiles(
State(state): State<AppState>,
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::<Vec<_>>(),
)
.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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<SaveProfileRequest>,
) -> impl IntoResponse {
let Ok(session_id) = body
.session_id
.parse::<uuid::Uuid>()
.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<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path(profile_id_str): Path<String>,
) -> impl IntoResponse {
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() 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()
}
}
}

View File

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

View File

@@ -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<AppState>,
Path(path): Path<String>,
) -> 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(),
}
}

View File

@@ -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<AppState>) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let user = state
.app_ctx
.user_repository
.find_by_id(&UserId::from_uuid(user_id))
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
let query = GetDiaryQuery {
limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(SortDirection::Descending),
movie_id: None,
user_id: Some(user_id),
};
let page = get_diary::execute(&state.app_ctx, query).await?;
let display_name = user.email().value().split('@').next().unwrap_or("User");
let title = format!("{}'s Movie Diary", display_name);
let xml = state
.rss_renderer
.render_feed(&page.items, &title)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok((
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
xml,
))
}

View File

@@ -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<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
config: app_config,
};

View File

@@ -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),
)]

View File

@@ -65,17 +65,23 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
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<AppState> {
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());

View File

@@ -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<Option<domain::models::ImportSession>, 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<u64, DomainError> { 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<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, 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(),

View File

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

View File

@@ -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<Arc<dyn EventHandler>> = {
let poster = Arc::new(PosterSyncHandler::new(ctx, 3)) as Arc<dyn EventHandler>;
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<dyn EventHandler>;
#[cfg(not(feature = "federation"))]
{ vec![poster] }

View File

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

View File

@@ -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
1 Title Year IMDb ID Your Rating Date Rated Directors
2 Interstellar 2014 tt0816692 9 2024-01-08 Christopher Nolan
3 No Country for Old Men 2007 tt0477348 10 2024-01-20 Joel Coen
4 The Lighthouse 2019 tt7984734 8 2024-02-03 Robert Eggers
5 Hereditary 2018 tt7784604 7 2024-02-19 Ari Aster
6 The Favourite 2018 tt5765884 8 2024-03-07 Yorgos Lanthimos
7 Mad Max: Fury Road 2015 tt1392190 9 2024-03-25 George Miller
8 Portrait of a Lady on Fire 2019 tt8613070 9 2024-04-12 Céline Sciamma
9 The Zone of Interest 2023 tt7160372 8 2024-04-30 Jonathan Glazer

View File

@@ -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.
1 Name Year Rating Watched Date Review
2 Inception 2010 5 2024-01-15 Mind-bending masterpiece. The layered dream sequences still hold up perfectly.
3 The Godfather 1972 5 2024-01-22
4 Dune: Part Two 2024 4 2024-03-02 Visually stunning. Chalamet carries the whole thing.
5 Parasite 2019 5 2024-02-10 Bong Joon-ho at his absolute best. Rewatched for the third time.
6 Everything Everywhere All at Once 2022 4 2024-02-28 Exhausting in the best possible way.
7 Oppenheimer 2023 4 2024-03-15 Three hours flew by. Murphy is phenomenal.
8 The Dark Knight 2008 5 2024-04-01 Still the gold standard for superhero cinema.
9 Past Lives 2023 4 2024-04-10 Quietly devastating. Stayed with me for days.
10 Aftersun 2022 5 2024-04-18
11 Poor Things 2024 3 2024-05-02 Visually remarkable but style over substance for me.