Compare commits
40 Commits
87fcdc12ca
...
20ac0d3adf
| Author | SHA1 | Date | |
|---|---|---|---|
| 20ac0d3adf | |||
| 8ac87a3735 | |||
| 4f0f44dec3 | |||
| 96c753c2c6 | |||
| 822f3f9d9c | |||
| 55feaa353f | |||
| 3a3f3b3889 | |||
| ef9ecbae06 | |||
| db285b513b | |||
| 2617c77b42 | |||
| e618e1aa84 | |||
| 7dc372a7b6 | |||
| e39fcf6802 | |||
| b09ef4686a | |||
| 2074a2244e | |||
| 338ecb71c8 | |||
| 8cb90b256c | |||
| c05087a6c7 | |||
| 0fdc79af23 | |||
| 262ba5ca39 | |||
| 57c720b22f | |||
| 53b7f730cb | |||
| e8fa24bf9b | |||
| 7437ed89ad | |||
| b2a41db290 | |||
| 9b932cde8e | |||
| a68e19aad7 | |||
| 371a3cdc46 | |||
| 517a18da8a | |||
| 7df24a19ee | |||
| 549923b92e | |||
| 3cbb406ea7 | |||
| 21cc6ed437 | |||
| 5dc90724d3 | |||
| 9a894c3a95 | |||
| acc20d2f43 | |||
| a95be0b131 | |||
| f10b114e83 | |||
| c020135cd1 | |||
| ad55897871 |
@@ -30,7 +30,7 @@ impl AuthConfig {
|
||||
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(86400u64);
|
||||
.unwrap_or(900u64);
|
||||
Ok(Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
|
||||
@@ -2,6 +2,7 @@ use chrono::NaiveDateTime;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::PersonId,
|
||||
value_objects::{
|
||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
||||
},
|
||||
@@ -109,6 +110,10 @@ pub enum EventPayload {
|
||||
user_id: String,
|
||||
year: u16,
|
||||
},
|
||||
PersonEnrichmentRequested {
|
||||
person_id: String,
|
||||
external_person_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -135,6 +140,7 @@ impl EventPayload {
|
||||
EventPayload::GoalCreated { .. } => "GoalCreated",
|
||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
||||
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,6 +317,13 @@ impl From<&DomainEvent> for EventPayload {
|
||||
user_id: user_id.value().to_string(),
|
||||
year: *year,
|
||||
},
|
||||
DomainEvent::PersonEnrichmentRequested {
|
||||
person_id,
|
||||
external_person_id,
|
||||
} => EventPayload::PersonEnrichmentRequested {
|
||||
person_id: person_id.value().to_string(),
|
||||
external_person_id: external_person_id.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,6 +509,13 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
year,
|
||||
}),
|
||||
EventPayload::PersonEnrichmentRequested {
|
||||
person_id,
|
||||
external_person_id,
|
||||
} => Ok(DomainEvent::PersonEnrichmentRequested {
|
||||
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
||||
external_person_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::GoalCreated { .. } => "goal.created",
|
||||
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
||||
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
||||
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE persons ADD COLUMN biography TEXT;
|
||||
ALTER TABLE persons ADD COLUMN birthday TEXT;
|
||||
ALTER TABLE persons ADD COLUMN deathday TEXT;
|
||||
ALTER TABLE persons ADD COLUMN place_of_birth TEXT;
|
||||
ALTER TABLE persons ADD COLUMN also_known_as TEXT;
|
||||
ALTER TABLE persons ADD COLUMN homepage TEXT;
|
||||
ALTER TABLE persons ADD COLUMN imdb_id TEXT;
|
||||
ALTER TABLE persons ADD COLUMN enriched_at TIMESTAMPTZ;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);
|
||||
@@ -21,6 +21,7 @@ mod models;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod refresh_sessions;
|
||||
mod remote_goals;
|
||||
mod user_settings;
|
||||
mod users;
|
||||
@@ -40,6 +41,7 @@ pub use import_session::PostgresImportSessionRepository;
|
||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||
pub use profile::PostgresMovieProfileRepository;
|
||||
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||
pub use refresh_sessions::PostgresRefreshSessionAdapter;
|
||||
pub use users::PostgresUserRepository;
|
||||
pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository};
|
||||
pub use watchlist::PostgresWatchlistRepository;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
|
||||
models::{
|
||||
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
|
||||
PersonId,
|
||||
},
|
||||
ports::{PersonCommand, PersonQuery},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
@@ -111,71 +114,61 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
}
|
||||
Ok((count, has_more))
|
||||
}
|
||||
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
id: &PersonId,
|
||||
data: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
let also_known_as_json =
|
||||
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query(
|
||||
"UPDATE persons SET biography = $1, birthday = $2, deathday = $3, place_of_birth = $4, also_known_as = $5, homepage = $6, imdb_id = $7, enriched_at = $8 WHERE id = $9",
|
||||
)
|
||||
.bind(&data.biography)
|
||||
.bind(data.birthday.map(|d| d.to_string()))
|
||||
.bind(data.deathday.map(|d| d.to_string()))
|
||||
.bind(&data.place_of_birth)
|
||||
.bind(&also_known_as_json)
|
||||
.bind(&data.homepage)
|
||||
.bind(&data.imdb_id)
|
||||
.bind(now)
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PersonQuery for PostgresPersonAdapter {
|
||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
external_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = $1",
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = $1",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(row.map(|r| {
|
||||
let ext = ExternalPersonId::new(r.external_id);
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
ext,
|
||||
r.name,
|
||||
r.known_for_department,
|
||||
r.profile_path,
|
||||
)
|
||||
}))
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
external_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = $1",
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = $1",
|
||||
)
|
||||
.bind(id.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(row.map(|r| {
|
||||
let ext = ExternalPersonId::new(r.external_id);
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
ext,
|
||||
r.name,
|
||||
r.known_for_department,
|
||||
r.profile_path,
|
||||
)
|
||||
}))
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
@@ -261,17 +254,8 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
}
|
||||
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: String,
|
||||
external_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT $1 OFFSET $2",
|
||||
let rows = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
@@ -279,19 +263,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let ext = ExternalPersonId::new(r.external_id);
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
ext,
|
||||
r.name,
|
||||
r.known_for_department,
|
||||
r.profile_path,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||
}
|
||||
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
@@ -315,3 +287,57 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Row types ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct PersonRow {
|
||||
id: String,
|
||||
external_id: String,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
biography: Option<String>,
|
||||
birthday: Option<String>,
|
||||
deathday: Option<String>,
|
||||
place_of_birth: Option<String>,
|
||||
also_known_as: Option<String>,
|
||||
homepage: Option<String>,
|
||||
imdb_id: Option<String>,
|
||||
enriched_at: Option<String>,
|
||||
}
|
||||
|
||||
impl PersonRow {
|
||||
fn into_person(self) -> Person {
|
||||
let ext = ExternalPersonId::new(self.external_id);
|
||||
let also_known_as = self
|
||||
.also_known_as
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let birthday = self
|
||||
.birthday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let deathday = self
|
||||
.deathday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let enriched_at = self
|
||||
.enriched_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|d| d.with_timezone(&chrono::Utc));
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
|
||||
ext,
|
||||
self.name,
|
||||
self.known_for_department,
|
||||
self.profile_path,
|
||||
self.biography,
|
||||
birthday,
|
||||
deathday,
|
||||
self.place_of_birth,
|
||||
also_known_as,
|
||||
self.homepage,
|
||||
self.imdb_id,
|
||||
enriched_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
112
crates/adapters/postgres/src/refresh_sessions.rs
Normal file
112
crates/adapters/postgres/src/refresh_sessions.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresRefreshSessionAdapter {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresRefreshSessionAdapter {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(session.id.to_string())
|
||||
.bind(session.user_id.value().to_string())
|
||||
.bind(&session.token)
|
||||
.bind(session.expires_at)
|
||||
.bind(session.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT id, user_id, token,
|
||||
to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS expires_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at
|
||||
FROM refresh_sessions WHERE token = $1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.map(RefreshSessionRow::into_domain).transpose()
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = $1")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = $1")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RefreshSessionRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
token: String,
|
||||
expires_at: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
impl RefreshSessionRow {
|
||||
fn into_domain(self) -> Result<RefreshSession, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&self.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?;
|
||||
let user_id = uuid::Uuid::parse_str(&self.user_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(&self.expires_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
let created_at = DateTime::parse_from_rfc3339(&self.created_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Ok(RefreshSession {
|
||||
id,
|
||||
user_id: UserId::from_uuid(user_id),
|
||||
token: self.token,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE persons ADD COLUMN biography TEXT;
|
||||
ALTER TABLE persons ADD COLUMN birthday TEXT;
|
||||
ALTER TABLE persons ADD COLUMN deathday TEXT;
|
||||
ALTER TABLE persons ADD COLUMN place_of_birth TEXT;
|
||||
ALTER TABLE persons ADD COLUMN also_known_as TEXT;
|
||||
ALTER TABLE persons ADD COLUMN homepage TEXT;
|
||||
ALTER TABLE persons ADD COLUMN imdb_id TEXT;
|
||||
ALTER TABLE persons ADD COLUMN enriched_at TEXT;
|
||||
10
crates/adapters/sqlite/migrations/0027_refresh_sessions.sql
Normal file
10
crates/adapters/sqlite/migrations/0027_refresh_sessions.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS refresh_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);
|
||||
@@ -22,6 +22,7 @@ mod models;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod refresh_sessions;
|
||||
mod remote_goals;
|
||||
mod user_settings;
|
||||
mod users;
|
||||
@@ -41,6 +42,7 @@ pub use import_session::SqliteImportSessionRepository;
|
||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||
pub use profile::SqliteMovieProfileRepository;
|
||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
||||
pub use users::SqliteUserRepository;
|
||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
||||
pub use watchlist::SqliteWatchlistRepository;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
|
||||
models::{
|
||||
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
|
||||
PersonId,
|
||||
},
|
||||
ports::{PersonCommand, PersonQuery},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
@@ -111,13 +114,39 @@ impl PersonCommand for SqlitePersonAdapter {
|
||||
}
|
||||
Ok((count, has_more))
|
||||
}
|
||||
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
id: &PersonId,
|
||||
data: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
let also_known_as_json =
|
||||
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?",
|
||||
)
|
||||
.bind(&data.biography)
|
||||
.bind(data.birthday.map(|d| d.to_string()))
|
||||
.bind(data.deathday.map(|d| d.to_string()))
|
||||
.bind(&data.place_of_birth)
|
||||
.bind(&also_known_as_json)
|
||||
.bind(&data.homepage)
|
||||
.bind(&data.imdb_id)
|
||||
.bind(&now)
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PersonQuery for SqlitePersonAdapter {
|
||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = ?",
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?",
|
||||
)
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -132,7 +161,7 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
id: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
let row = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?",
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?",
|
||||
)
|
||||
.bind(id.value())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -212,7 +241,7 @@ impl PersonQuery for SqlitePersonAdapter {
|
||||
|
||||
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, PersonRow>(
|
||||
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT ? OFFSET ?",
|
||||
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
@@ -254,17 +283,47 @@ struct PersonRow {
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
biography: Option<String>,
|
||||
birthday: Option<String>,
|
||||
deathday: Option<String>,
|
||||
place_of_birth: Option<String>,
|
||||
also_known_as: Option<String>,
|
||||
homepage: Option<String>,
|
||||
imdb_id: Option<String>,
|
||||
enriched_at: Option<String>,
|
||||
}
|
||||
|
||||
impl PersonRow {
|
||||
fn into_person(self) -> Person {
|
||||
let ext = ExternalPersonId::new(self.external_id);
|
||||
let also_known_as = self
|
||||
.also_known_as
|
||||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let birthday = self
|
||||
.birthday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let deathday = self
|
||||
.deathday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
|
||||
let enriched_at = self
|
||||
.enriched_at
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|d| d.with_timezone(&chrono::Utc));
|
||||
Person::new(
|
||||
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
|
||||
ext,
|
||||
self.name,
|
||||
self.known_for_department,
|
||||
self.profile_path,
|
||||
self.biography,
|
||||
birthday,
|
||||
deathday,
|
||||
self.place_of_birth,
|
||||
also_known_as,
|
||||
self.homepage,
|
||||
self.imdb_id,
|
||||
enriched_at,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
111
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
111
crates/adapters/sqlite/src/refresh_sessions.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteRefreshSessionAdapter {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRefreshSessionAdapter {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for SqliteRefreshSessionAdapter {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session.id.to_string())
|
||||
.bind(session.user_id.value().to_string())
|
||||
.bind(&session.token)
|
||||
.bind(session.expires_at.to_rfc3339())
|
||||
.bind(session.created_at.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
let row = sqlx::query_as::<_, RefreshSessionRow>(
|
||||
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
|
||||
row.map(RefreshSessionRow::into_domain).transpose()
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RefreshSessionRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
token: String,
|
||||
expires_at: String,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
impl RefreshSessionRow {
|
||||
fn into_domain(self) -> Result<RefreshSession, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&self.id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?;
|
||||
let user_id = uuid::Uuid::parse_str(&self.user_id)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?;
|
||||
let expires_at = DateTime::parse_from_rfc3339(&self.expires_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
let created_at = DateTime::parse_from_rfc3339(&self.created_at)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Ok(RefreshSession {
|
||||
id,
|
||||
user_id: UserId::from_uuid(user_id),
|
||||
token: self.token,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ async fn pool_with_schema() -> SqlitePool {
|
||||
"CREATE TABLE persons (
|
||||
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
|
||||
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
|
||||
known_for_department TEXT, profile_path TEXT
|
||||
known_for_department TEXT, profile_path TEXT,
|
||||
biography TEXT, birthday TEXT, deathday TEXT,
|
||||
place_of_birth TEXT, also_known_as TEXT,
|
||||
homepage TEXT, imdb_id TEXT, enriched_at TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
@@ -46,7 +49,7 @@ async fn pool_with_schema() -> SqlitePool {
|
||||
|
||||
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
|
||||
Person::new(
|
||||
Person::basic(
|
||||
PersonId::from_external(&ext),
|
||||
ext,
|
||||
name.to_string(),
|
||||
|
||||
247
crates/adapters/tmdb-enrichment/src/client.rs
Normal file
247
crates/adapters/tmdb-enrichment/src/client.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile, PersonEnrichmentData},
|
||||
ports::{MovieEnrichmentClient, PersonEnrichmentClient},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct TmdbEnrichmentClient {
|
||||
api_key: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TmdbEnrichmentClient {
|
||||
pub fn from_env() -> Result<Self, DomainError> {
|
||||
let api_key = std::env::var("TMDB_API_KEY")
|
||||
.map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
||||
Ok(Self {
|
||||
api_key,
|
||||
http: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn base(&self, path: &str) -> String {
|
||||
format!("https://api.themoviedb.org/3{}", path)
|
||||
}
|
||||
|
||||
pub(crate) async fn get<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
url: &str,
|
||||
extra: &[(&str, &str)],
|
||||
) -> Result<T, DomainError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[("api_key", self.api_key.as_str())]);
|
||||
for (k, v) in extra {
|
||||
req = req.query(&[(k, v)]);
|
||||
}
|
||||
req.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
|
||||
if let Some(numeric) = external_id.strip_prefix("tmdb:") {
|
||||
return numeric.parse::<u64>().map_err(|_| {
|
||||
DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct FindResult {
|
||||
id: u64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct FindResponse {
|
||||
movie_results: Vec<FindResult>,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/find/{}", external_id));
|
||||
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
|
||||
resp.movie_results
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|r| r.id)
|
||||
.ok_or_else(|| DomainError::NotFound(format!("TMDb: no movie for {external_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError> {
|
||||
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GenreDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDto {
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct CastDto {
|
||||
id: u64,
|
||||
name: String,
|
||||
character: String,
|
||||
order: u32,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct CrewDto {
|
||||
id: u64,
|
||||
name: String,
|
||||
job: String,
|
||||
department: String,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Credits {
|
||||
cast: Vec<CastDto>,
|
||||
crew: Vec<CrewDto>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct KeywordDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Keywords {
|
||||
keywords: Vec<KeywordDto>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Details {
|
||||
imdb_id: Option<String>,
|
||||
overview: Option<String>,
|
||||
tagline: Option<String>,
|
||||
runtime: Option<u32>,
|
||||
budget: Option<i64>,
|
||||
revenue: Option<i64>,
|
||||
vote_average: Option<f64>,
|
||||
vote_count: Option<u32>,
|
||||
original_language: Option<String>,
|
||||
genres: Vec<GenreDto>,
|
||||
belongs_to_collection: Option<CollectionDto>,
|
||||
credits: Credits,
|
||||
keywords: Keywords,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/movie/{}", tmdb_id));
|
||||
let d: Details = self
|
||||
.get(&url, &[("append_to_response", "credits,keywords")])
|
||||
.await?;
|
||||
|
||||
Ok(MovieProfile {
|
||||
movie_id,
|
||||
tmdb_id,
|
||||
imdb_id: d.imdb_id.filter(|s| !s.is_empty()),
|
||||
overview: d.overview.filter(|s| !s.is_empty()),
|
||||
tagline: d.tagline.filter(|s| !s.is_empty()),
|
||||
runtime_minutes: d.runtime,
|
||||
budget_usd: d.budget.filter(|&v| v > 0),
|
||||
revenue_usd: d.revenue.filter(|&v| v > 0),
|
||||
vote_average: d.vote_average,
|
||||
vote_count: d.vote_count,
|
||||
original_language: d.original_language,
|
||||
collection_name: d.belongs_to_collection.map(|c| c.name),
|
||||
genres: d
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|g| Genre {
|
||||
tmdb_id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
keywords: d
|
||||
.keywords
|
||||
.keywords
|
||||
.into_iter()
|
||||
.map(|k| Keyword {
|
||||
tmdb_id: k.id,
|
||||
name: k.name,
|
||||
})
|
||||
.collect(),
|
||||
cast: d
|
||||
.credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|c| CastMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
character: c.character,
|
||||
billing_order: c.order,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
crew: d
|
||||
.credits
|
||||
.crew
|
||||
.into_iter()
|
||||
.map(|c| CrewMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
job: c.job,
|
||||
department: c.department,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
enriched_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PersonEnrichmentClient for TmdbEnrichmentClient {
|
||||
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError> {
|
||||
let tmdb_id = external_id
|
||||
.strip_prefix("tmdb:")
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.ok_or_else(|| {
|
||||
DomainError::InfrastructureError(format!(
|
||||
"Cannot parse person external_id: {external_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PersonDetails {
|
||||
biography: Option<String>,
|
||||
birthday: Option<String>,
|
||||
deathday: Option<String>,
|
||||
place_of_birth: Option<String>,
|
||||
also_known_as: Option<Vec<String>>,
|
||||
homepage: Option<String>,
|
||||
imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/person/{tmdb_id}"));
|
||||
let d: PersonDetails = self.get(&url, &[]).await?;
|
||||
|
||||
Ok(PersonEnrichmentData {
|
||||
biography: d.biography.filter(|s| !s.is_empty()),
|
||||
birthday: d
|
||||
.birthday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
|
||||
deathday: d
|
||||
.deathday
|
||||
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()),
|
||||
place_of_birth: d.place_of_birth.filter(|s| !s.is_empty()),
|
||||
also_known_as: d.also_known_as.unwrap_or_default(),
|
||||
homepage: d.homepage.filter(|s| !s.is_empty()),
|
||||
imdb_id: d.imdb_id.filter(|s| !s.is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,312 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
mod client;
|
||||
mod movie_handler;
|
||||
mod person_handler;
|
||||
|
||||
use application::movies::{commands::EnrichMovieCommand, enrich_movie, request_enrichment};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
|
||||
ports::{
|
||||
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
ObjectStorage, PersonCommand, SearchCommand,
|
||||
},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// ── TMDb enrichment client ───────────────────────────────────────────────────
|
||||
|
||||
pub struct TmdbEnrichmentClient {
|
||||
api_key: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TmdbEnrichmentClient {
|
||||
pub fn from_env() -> Result<Self, DomainError> {
|
||||
let api_key = std::env::var("TMDB_API_KEY")
|
||||
.map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
|
||||
Ok(Self {
|
||||
api_key,
|
||||
http: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn base(&self, path: &str) -> String {
|
||||
format!("https://api.themoviedb.org/3{}", path)
|
||||
}
|
||||
|
||||
async fn get<T: for<'de> Deserialize<'de>>(
|
||||
&self,
|
||||
url: &str,
|
||||
extra: &[(&str, &str)],
|
||||
) -> Result<T, DomainError> {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(url)
|
||||
.query(&[("api_key", self.api_key.as_str())]);
|
||||
for (k, v) in extra {
|
||||
req = req.query(&[(k, v)]);
|
||||
}
|
||||
req.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
|
||||
if let Some(numeric) = external_id.strip_prefix("tmdb:") {
|
||||
return numeric.parse::<u64>().map_err(|_| {
|
||||
DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))
|
||||
});
|
||||
}
|
||||
|
||||
// Assume IMDb ID (tt…) — use /find
|
||||
#[derive(Deserialize)]
|
||||
struct FindResult {
|
||||
id: u64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct FindResponse {
|
||||
movie_results: Vec<FindResult>,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/find/{}", external_id));
|
||||
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
|
||||
resp.movie_results
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|r| r.id)
|
||||
.ok_or_else(|| DomainError::NotFound(format!("TMDb: no movie for {external_id}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieEnrichmentClient for TmdbEnrichmentClient {
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError> {
|
||||
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GenreDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDto {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CastDto {
|
||||
id: u64,
|
||||
name: String,
|
||||
character: String,
|
||||
order: u32,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CrewDto {
|
||||
id: u64,
|
||||
name: String,
|
||||
job: String,
|
||||
department: String,
|
||||
profile_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Credits {
|
||||
cast: Vec<CastDto>,
|
||||
crew: Vec<CrewDto>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KeywordDto {
|
||||
id: u32,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Keywords {
|
||||
keywords: Vec<KeywordDto>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Details {
|
||||
imdb_id: Option<String>,
|
||||
overview: Option<String>,
|
||||
tagline: Option<String>,
|
||||
runtime: Option<u32>,
|
||||
budget: Option<i64>,
|
||||
revenue: Option<i64>,
|
||||
vote_average: Option<f64>,
|
||||
vote_count: Option<u32>,
|
||||
original_language: Option<String>,
|
||||
genres: Vec<GenreDto>,
|
||||
belongs_to_collection: Option<CollectionDto>,
|
||||
credits: Credits,
|
||||
keywords: Keywords,
|
||||
}
|
||||
|
||||
let url = self.base(&format!("/movie/{}", tmdb_id));
|
||||
let d: Details = self
|
||||
.get(&url, &[("append_to_response", "credits,keywords")])
|
||||
.await?;
|
||||
|
||||
Ok(MovieProfile {
|
||||
movie_id,
|
||||
tmdb_id,
|
||||
imdb_id: d.imdb_id.filter(|s| !s.is_empty()),
|
||||
overview: d.overview.filter(|s| !s.is_empty()),
|
||||
tagline: d.tagline.filter(|s| !s.is_empty()),
|
||||
runtime_minutes: d.runtime,
|
||||
budget_usd: d.budget.filter(|&v| v > 0),
|
||||
revenue_usd: d.revenue.filter(|&v| v > 0),
|
||||
vote_average: d.vote_average,
|
||||
vote_count: d.vote_count,
|
||||
original_language: d.original_language,
|
||||
collection_name: d.belongs_to_collection.map(|c| c.name),
|
||||
genres: d
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|g| Genre {
|
||||
tmdb_id: g.id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
keywords: d
|
||||
.keywords
|
||||
.keywords
|
||||
.into_iter()
|
||||
.map(|k| Keyword {
|
||||
tmdb_id: k.id,
|
||||
name: k.name,
|
||||
})
|
||||
.collect(),
|
||||
cast: d
|
||||
.credits
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|c| CastMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
character: c.character,
|
||||
billing_order: c.order,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
crew: d
|
||||
.credits
|
||||
.crew
|
||||
.into_iter()
|
||||
.map(|c| CrewMember {
|
||||
tmdb_person_id: c.id,
|
||||
name: c.name,
|
||||
job: c.job,
|
||||
department: c.department,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
enriched_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrichment event handler ─────────────────────────────────────────────────
|
||||
|
||||
pub struct EnrichmentHandler {
|
||||
pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
|
||||
pub movie_repository: Arc<dyn MovieRepository>,
|
||||
pub profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub object_storage: Arc<dyn ObjectStorage>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl EnrichmentHandler {
|
||||
pub fn new(
|
||||
enrichment_client: Arc<dyn MovieEnrichmentClient>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
enrichment_client,
|
||||
movie_repository,
|
||||
profile_repo,
|
||||
person_command,
|
||||
search_command,
|
||||
object_storage,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_cast_photos(&self, profile: &MovieProfile) {
|
||||
for member in profile.cast.iter().take(5) {
|
||||
let Some(ref path) = member.profile_path else {
|
||||
continue;
|
||||
};
|
||||
let key = format!("cast{path}");
|
||||
if self.object_storage.get(&key).await.is_ok() {
|
||||
continue;
|
||||
}
|
||||
let url = format!("https://image.tmdb.org/t/p/w185{path}");
|
||||
match self.http.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await
|
||||
&& let Err(e) = self.object_storage.store(&key, &bytes).await
|
||||
{
|
||||
tracing::debug!("cast photo store failed for {path}: {e}");
|
||||
}
|
||||
}
|
||||
_ => tracing::debug!("cast photo download failed for {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for EnrichmentHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (movie_id, external_metadata_id) = match event {
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => (movie_id.clone(), external_metadata_id.clone()),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let Some(profile) = request_enrichment::fetch_if_stale(
|
||||
self.enrichment_client.as_ref(),
|
||||
&self.profile_repo,
|
||||
movie_id.clone(),
|
||||
&external_metadata_id,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.download_cast_photos(&profile).await;
|
||||
enrich_movie::execute(
|
||||
&self.movie_repository,
|
||||
&self.profile_repo,
|
||||
&self.person_command,
|
||||
&self.search_command,
|
||||
EnrichMovieCommand { movie_id, profile },
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
pub use client::TmdbEnrichmentClient;
|
||||
pub use movie_handler::MovieEnrichmentHandler;
|
||||
pub use person_handler::PersonEnrichmentHandler;
|
||||
|
||||
101
crates/adapters/tmdb-enrichment/src/movie_handler.rs
Normal file
101
crates/adapters/tmdb-enrichment/src/movie_handler.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::movies::{commands::EnrichMovieCommand, enrich_movie, request_enrichment};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::MovieProfile,
|
||||
ports::{
|
||||
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
|
||||
ObjectStorage, PersonCommand, SearchCommand,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct MovieEnrichmentHandler {
|
||||
enrichment_client: Arc<dyn MovieEnrichmentClient>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MovieEnrichmentHandler {
|
||||
pub fn new(
|
||||
enrichment_client: Arc<dyn MovieEnrichmentClient>,
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
profile_repo: Arc<dyn MovieProfileRepository>,
|
||||
person_command: Arc<dyn PersonCommand>,
|
||||
search_command: Arc<dyn SearchCommand>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
) -> Self {
|
||||
Self {
|
||||
enrichment_client,
|
||||
movie_repository,
|
||||
profile_repo,
|
||||
person_command,
|
||||
search_command,
|
||||
object_storage,
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_cast_photos(&self, profile: &MovieProfile) {
|
||||
for member in profile.cast.iter().take(5) {
|
||||
let Some(ref path) = member.profile_path else {
|
||||
continue;
|
||||
};
|
||||
let key = format!("cast{path}");
|
||||
if self.object_storage.get(&key).await.is_ok() {
|
||||
continue;
|
||||
}
|
||||
let url = format!("https://image.tmdb.org/t/p/w185{path}");
|
||||
match self.http.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await
|
||||
&& let Err(e) = self.object_storage.store(&key, &bytes).await
|
||||
{
|
||||
tracing::debug!("cast photo store failed for {path}: {e}");
|
||||
}
|
||||
}
|
||||
_ => tracing::debug!("cast photo download failed for {path}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for MovieEnrichmentHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (movie_id, external_metadata_id) = match event {
|
||||
DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
} => (movie_id.clone(), external_metadata_id.clone()),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let Some(profile) = request_enrichment::fetch_if_stale(
|
||||
self.enrichment_client.as_ref(),
|
||||
&self.profile_repo,
|
||||
movie_id.clone(),
|
||||
&external_metadata_id,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.download_cast_photos(&profile).await;
|
||||
enrich_movie::execute(
|
||||
&self.movie_repository,
|
||||
&self.profile_repo,
|
||||
&self.person_command,
|
||||
&self.search_command,
|
||||
EnrichMovieCommand { movie_id, profile },
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
29
crates/adapters/tmdb-enrichment/src/person_handler.rs
Normal file
29
crates/adapters/tmdb-enrichment/src/person_handler.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventHandler};
|
||||
|
||||
use application::context::AppContext;
|
||||
|
||||
pub struct PersonEnrichmentHandler {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl PersonEnrichmentHandler {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for PersonEnrichmentHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let (person_id, external_person_id) = match event {
|
||||
DomainEvent::PersonEnrichmentRequested {
|
||||
person_id,
|
||||
external_person_id,
|
||||
} => (person_id.clone(), external_person_id.clone()),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
application::person::enrich::execute(&self.ctx, person_id, &external_person_id).await
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub struct LoginRequest {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: String,
|
||||
@@ -22,3 +23,20 @@ pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct RefreshRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct RefreshResponse {
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct LogoutRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
@@ -70,6 +70,21 @@ pub struct PersonDto {
|
||||
pub name: String,
|
||||
pub known_for_department: Option<String>,
|
||||
pub profile_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub biography: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub birthday: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deathday: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub place_of_birth: Option<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub also_known_as: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub imdb_url: Option<String>,
|
||||
pub enriched: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct UserProfileQueryParams {
|
||||
pub view: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{errors::DomainError, value_objects::Email};
|
||||
use domain::{errors::DomainError, models::RefreshSession, value_objects::Email};
|
||||
|
||||
use crate::{auth::queries::LoginQuery, context::AppContext};
|
||||
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
@@ -33,8 +34,20 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
|
||||
|
||||
let generated = ctx.services.auth.generate_token(user.id()).await?;
|
||||
|
||||
let refresh_token = Uuid::new_v4().to_string();
|
||||
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64);
|
||||
let session = RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user.id().clone(),
|
||||
token: refresh_token.clone(),
|
||||
expires_at: refresh_expires,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
ctx.repos.refresh_session.create(&session).await?;
|
||||
|
||||
Ok(LoginResult {
|
||||
token: generated.token,
|
||||
refresh_token,
|
||||
user_id: user.id().value(),
|
||||
email: user.email().value().to_string(),
|
||||
expires_at: generated.expires_at,
|
||||
|
||||
11
crates/application/src/auth/logout.rs
Normal file
11
crates/application/src/auth/logout.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub async fn execute(ctx: &AppContext, refresh_token: &str) -> Result<(), DomainError> {
|
||||
ctx.repos.refresh_session.revoke(refresh_token).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/logout.rs"]
|
||||
mod tests;
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod commands;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod queries;
|
||||
pub mod refresh;
|
||||
pub mod register;
|
||||
pub mod register_and_login;
|
||||
|
||||
57
crates/application/src/auth/refresh.rs
Normal file
57
crates/application/src/auth/refresh.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{errors::DomainError, models::RefreshSession};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct RefreshResult {
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
old_refresh_token: &str,
|
||||
) -> Result<RefreshResult, DomainError> {
|
||||
let session = ctx
|
||||
.repos
|
||||
.refresh_session
|
||||
.get_by_token(old_refresh_token)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?;
|
||||
|
||||
if session.expires_at < Utc::now() {
|
||||
ctx.repos.refresh_session.revoke(old_refresh_token).await?;
|
||||
return Err(DomainError::Unauthorized("Refresh token expired".into()));
|
||||
}
|
||||
|
||||
// Revoke old token (rotation)
|
||||
ctx.repos.refresh_session.revoke(old_refresh_token).await?;
|
||||
|
||||
// Generate new access token
|
||||
let generated = ctx.services.auth.generate_token(&session.user_id).await?;
|
||||
|
||||
// Create new refresh session
|
||||
let new_refresh_token = Uuid::new_v4().to_string();
|
||||
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64);
|
||||
let new_session = RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: session.user_id,
|
||||
token: new_refresh_token.clone(),
|
||||
expires_at: refresh_expires,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
ctx.repos.refresh_session.create(&new_session).await?;
|
||||
|
||||
Ok(RefreshResult {
|
||||
token: generated.token,
|
||||
refresh_token: new_refresh_token,
|
||||
expires_at: generated.expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/refresh.rs"]
|
||||
mod tests;
|
||||
@@ -44,6 +44,7 @@ async fn test_login_valid_credentials_returns_token() {
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.token.is_empty());
|
||||
assert!(!result.refresh_token.is_empty());
|
||||
assert_eq!(result.email, "carol@example.com");
|
||||
}
|
||||
|
||||
|
||||
55
crates/application/src/auth/tests/logout.rs
Normal file
55
crates/application/src/auth/tests/logout.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
|
||||
use crate::{
|
||||
auth::commands::RegisterCommand,
|
||||
auth::queries::LoginQuery,
|
||||
auth::{login, logout, refresh, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
register::execute(
|
||||
&ctx,
|
||||
RegisterCommand {
|
||||
email: "bob@example.com".to_string(),
|
||||
username: "bob".to_string(),
|
||||
password: "password123".to_string(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let login_result = login::execute(
|
||||
&ctx,
|
||||
LoginQuery {
|
||||
email: "bob@example.com".into(),
|
||||
password: "password123".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
logout::execute(&ctx, &login_result.refresh_token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let refresh_attempt = refresh::execute(&ctx, &login_result.refresh_token).await;
|
||||
assert!(refresh_attempt.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_with_unknown_token_succeeds() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
let result = logout::execute(&ctx, "nonexistent-token").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
96
crates/application/src/auth/tests/refresh.rs
Normal file
96
crates/application/src/auth/tests/refresh.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
|
||||
use crate::{
|
||||
auth::commands::RegisterCommand,
|
||||
auth::queries::LoginQuery,
|
||||
auth::{login, refresh, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
};
|
||||
|
||||
async fn login_user(ctx: &crate::context::AppContext) -> login::LoginResult {
|
||||
register::execute(
|
||||
ctx,
|
||||
RegisterCommand {
|
||||
email: "alice@example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "password123".to_string(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
login::execute(
|
||||
ctx,
|
||||
LoginQuery {
|
||||
email: "alice@example.com".into(),
|
||||
password: "password123".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_tokens() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let login_result = login_user(&ctx).await;
|
||||
|
||||
let result = refresh::execute(&ctx, &login_result.refresh_token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!result.token.is_empty());
|
||||
assert!(!result.refresh_token.is_empty());
|
||||
assert_ne!(result.refresh_token, login_result.refresh_token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_token_old_one_invalid() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let login_result = login_user(&ctx).await;
|
||||
let old_token = login_result.refresh_token.clone();
|
||||
|
||||
refresh::execute(&ctx, &old_token).await.unwrap();
|
||||
|
||||
let retry = refresh::execute(&ctx, &old_token).await;
|
||||
assert!(retry.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_with_new_token_works() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let ctx = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.build();
|
||||
|
||||
let login_result = login_user(&ctx).await;
|
||||
|
||||
let first = refresh::execute(&ctx, &login_result.refresh_token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second = refresh::execute(&ctx, &first.refresh_token).await.unwrap();
|
||||
|
||||
assert!(!second.token.is_empty());
|
||||
assert_ne!(second.refresh_token, first.refresh_token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_with_unknown_token_fails() {
|
||||
let ctx = TestContextBuilder::new().build();
|
||||
|
||||
let result = refresh::execute(&ctx, "nonexistent-token").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub struct AppConfig {
|
||||
pub allow_registration: bool,
|
||||
pub base_url: String,
|
||||
pub rate_limit: u64,
|
||||
pub refresh_ttl_seconds: u64,
|
||||
pub wrapup: WrapUpConfig,
|
||||
}
|
||||
|
||||
@@ -24,10 +25,15 @@ impl AppConfig {
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60);
|
||||
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2_592_000u64);
|
||||
Self {
|
||||
allow_registration,
|
||||
base_url,
|
||||
rate_limit,
|
||||
refresh_ttl_seconds,
|
||||
wrapup: WrapUpConfig::from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ use std::sync::Arc;
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
|
||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
||||
PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
|
||||
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
|
||||
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
|
||||
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient,
|
||||
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository,
|
||||
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||
WrapUpStatsQuery,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -38,6 +39,7 @@ pub struct Repositories {
|
||||
pub goal: Arc<dyn GoalRepository>,
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -51,6 +53,7 @@ pub struct Services {
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
39
crates/application/src/jobs/enrichment_staleness.rs
Normal file
39
crates/application/src/jobs/enrichment_staleness.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct EnrichmentStalenessJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl EnrichmentStalenessJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for EnrichmentStalenessJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(3600)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let stale = self.ctx.repos.movie_profile.list_stale().await?;
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("enrichment scan: {} stale movies", stale.len());
|
||||
for (movie_id, external_metadata_id) in stale {
|
||||
let event = DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
};
|
||||
self.ctx.services.event_publisher.publish(&event).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
29
crates/application/src/jobs/import_cleanup.rs
Normal file
29
crates/application/src/jobs/import_cleanup.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PeriodicJob};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct ImportSessionCleanupJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl ImportSessionCleanupJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for ImportSessionCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(3600)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::import::cleanup::execute(&self.ctx).await?;
|
||||
tracing::info!("import session cleanup: removed {} expired sessions", n);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
11
crates/application/src/jobs/mod.rs
Normal file
11
crates/application/src/jobs/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
mod enrichment_staleness;
|
||||
mod import_cleanup;
|
||||
mod refresh_session_cleanup;
|
||||
mod watch_event_cleanup;
|
||||
mod wrapup;
|
||||
|
||||
pub use enrichment_staleness::EnrichmentStalenessJob;
|
||||
pub use import_cleanup::ImportSessionCleanupJob;
|
||||
pub use refresh_session_cleanup::RefreshSessionCleanupJob;
|
||||
pub use watch_event_cleanup::WatchEventCleanupJob;
|
||||
pub use wrapup::{WrapUpAutoGenerateJob, WrapUpCleanupJob};
|
||||
31
crates/application/src/jobs/refresh_session_cleanup.rs
Normal file
31
crates/application/src/jobs/refresh_session_cleanup.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PeriodicJob};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct RefreshSessionCleanupJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl RefreshSessionCleanupJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for RefreshSessionCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(86400)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = self.ctx.repos.refresh_session.delete_expired().await?;
|
||||
if n > 0 {
|
||||
tracing::info!("refresh session cleanup: removed {n} expired sessions");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
31
crates/application/src/jobs/watch_event_cleanup.rs
Normal file
31
crates/application/src/jobs/watch_event_cleanup.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PeriodicJob};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct WatchEventCleanupJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl WatchEventCleanupJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for WatchEventCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(86400)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::integrations::cleanup::execute(&self.ctx).await?;
|
||||
if n > 0 {
|
||||
tracing::info!("watch event cleanup: removed {n} old entries");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,91 +2,10 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Datelike;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
|
||||
use domain::{errors::DomainError, ports::PeriodicJob};
|
||||
|
||||
use crate::context::AppContext;
|
||||
|
||||
pub struct ImportSessionCleanupJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl ImportSessionCleanupJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for ImportSessionCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(3600)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::import::cleanup::execute(&self.ctx).await?;
|
||||
tracing::info!("import session cleanup: removed {} expired sessions", n);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WatchEventCleanupJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl WatchEventCleanupJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for WatchEventCleanupJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(86400)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let n = crate::integrations::cleanup::execute(&self.ctx).await?;
|
||||
if n > 0 {
|
||||
tracing::info!("watch event cleanup: removed {n} old entries");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EnrichmentStalenessJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl EnrichmentStalenessJob {
|
||||
pub fn new(ctx: AppContext) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PeriodicJob for EnrichmentStalenessJob {
|
||||
fn interval(&self) -> Duration {
|
||||
Duration::from_secs(3600)
|
||||
}
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let stale = self.ctx.repos.movie_profile.list_stale().await?;
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::info!("enrichment scan: {} stale movies", stale.len());
|
||||
for (movie_id, external_metadata_id) in stale {
|
||||
let event = DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id,
|
||||
external_metadata_id,
|
||||
};
|
||||
self.ctx.services.event_publisher.publish(&event).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WrapUpAutoGenerateJob {
|
||||
ctx: AppContext,
|
||||
}
|
||||
@@ -105,7 +24,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
||||
|
||||
async fn run(&self) -> Result<(), DomainError> {
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
// Only run in January
|
||||
if now.month() != 1 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -140,7 +58,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
||||
}
|
||||
}
|
||||
|
||||
// Global wrap-up
|
||||
let existing = self
|
||||
.ctx
|
||||
.repos
|
||||
@@ -67,7 +67,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
|
||||
for member in cast {
|
||||
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
||||
Person::new(
|
||||
Person::basic(
|
||||
PersonId::from_external(&ext),
|
||||
ext,
|
||||
member.name.clone(),
|
||||
@@ -80,7 +80,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
|
||||
for member in crew {
|
||||
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
||||
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
||||
Person::new(
|
||||
Person::basic(
|
||||
PersonId::from_external(&ext),
|
||||
ext,
|
||||
member.name.clone(),
|
||||
|
||||
@@ -83,6 +83,13 @@ impl domain::ports::PersonCommand for NoopPersonCommand {
|
||||
) -> Result<(u64, bool), domain::errors::DomainError> {
|
||||
Ok((0, false))
|
||||
}
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
_: &domain::models::PersonId,
|
||||
_: &domain::models::PersonEnrichmentData,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
39
crates/application/src/person/enrich.rs
Normal file
39
crates/application/src/person/enrich.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::context::AppContext;
|
||||
use chrono::Utc;
|
||||
use domain::{errors::DomainError, models::PersonId};
|
||||
|
||||
const STALENESS_DAYS: i64 = 90;
|
||||
|
||||
pub async fn execute(
|
||||
ctx: &AppContext,
|
||||
person_id: PersonId,
|
||||
external_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
if let Some(person) = ctx.repos.person_query.get_by_id(&person_id).await?
|
||||
&& let Some(at) = person.enriched_at()
|
||||
&& (Utc::now() - at).num_days() < STALENESS_DAYS
|
||||
{
|
||||
tracing::debug!(person_id = %person_id.value(), "person enrichment still fresh");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let client = ctx.services.person_enrichment.as_ref().ok_or_else(|| {
|
||||
DomainError::InfrastructureError("person enrichment client not configured".into())
|
||||
})?;
|
||||
|
||||
let data = match client.fetch_details(external_id).await {
|
||||
Ok(d) => d,
|
||||
Err(DomainError::NotFound(msg)) => {
|
||||
tracing::warn!("TMDb person lookup found nothing: {msg}");
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
ctx.repos
|
||||
.person_command
|
||||
.update_enrichment(&person_id, &data)
|
||||
.await?;
|
||||
tracing::info!(person_id = %person_id.value(), "person enriched");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +1,35 @@
|
||||
use crate::context::AppContext;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonId},
|
||||
};
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
|
||||
ctx.repos.person_query.get_by_id(&id).await
|
||||
let person = ctx.repos.person_query.get_by_id(&id).await?;
|
||||
if let Some(ref p) = person
|
||||
&& should_enrich(p)
|
||||
{
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::PersonEnrichmentRequested {
|
||||
person_id: id,
|
||||
external_person_id: p.external_id().value().to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(person)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
use crate::context::AppContext;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PersonCredits, PersonId},
|
||||
events::DomainEvent,
|
||||
models::{Person, PersonCredits, PersonId},
|
||||
};
|
||||
|
||||
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||
|
||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
|
||||
ctx.repos.person_query.get_credits(&id).await
|
||||
let credits = ctx.repos.person_query.get_credits(&id).await?;
|
||||
if should_enrich(&credits.person) {
|
||||
let _ = ctx
|
||||
.services
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::PersonEnrichmentRequested {
|
||||
person_id: id,
|
||||
external_person_id: credits.person.external_id().value().to_string(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(credits)
|
||||
}
|
||||
|
||||
fn should_enrich(p: &Person) -> bool {
|
||||
match p.enriched_at() {
|
||||
None => true,
|
||||
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod enrich;
|
||||
pub mod get;
|
||||
pub mod get_credits;
|
||||
|
||||
@@ -9,19 +9,20 @@ use domain::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
||||
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
|
||||
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||
WrapUpStatsQuery,
|
||||
PersonQuery, PosterFetcherClient, RefreshSessionRepository, ReviewRepository,
|
||||
SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
||||
WrapUpRepository, WrapUpStatsQuery,
|
||||
},
|
||||
testing::{
|
||||
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
|
||||
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
||||
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
|
||||
InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
|
||||
InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
|
||||
InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
|
||||
PanicPersonCommand,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -75,6 +76,7 @@ pub struct TestContextBuilder {
|
||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
||||
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
@@ -117,10 +119,12 @@ impl TestContextBuilder {
|
||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
base_url: "http://localhost:3000".into(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: crate::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
@@ -286,6 +290,7 @@ impl TestContextBuilder {
|
||||
goal: self.goal_repo,
|
||||
user_settings: self.user_settings_repo,
|
||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||
refresh_session: self.refresh_session_repo,
|
||||
},
|
||||
services: Services {
|
||||
auth: self.auth_service,
|
||||
@@ -297,6 +302,7 @@ impl TestContextBuilder {
|
||||
diary_exporter: self.diary_exporter,
|
||||
document_parser: self.document_parser,
|
||||
review_logger: self.review_logger,
|
||||
person_enrichment: None,
|
||||
},
|
||||
config: self.config,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ impl EventHandler for RecordingHandler {
|
||||
DomainEvent::GoalCreated { .. }
|
||||
| DomainEvent::GoalUpdated { .. }
|
||||
| DomainEvent::GoalDeleted { .. } => "goal",
|
||||
DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested",
|
||||
};
|
||||
self.calls.lock().unwrap().push(label);
|
||||
Ok(())
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const DEFAULT_CONCURRENCY: usize = 8;
|
||||
const DEFAULT_CONCURRENCY: usize = 4;
|
||||
|
||||
pub struct WorkerService {
|
||||
consumer: Arc<dyn EventConsumer>,
|
||||
|
||||
@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::PersonId,
|
||||
value_objects::{
|
||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
||||
},
|
||||
@@ -43,6 +44,10 @@ pub enum DomainEvent {
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: String,
|
||||
},
|
||||
PersonEnrichmentRequested {
|
||||
person_id: PersonId,
|
||||
external_person_id: String,
|
||||
},
|
||||
ImageStored {
|
||||
key: String,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod enrichment;
|
||||
mod feed;
|
||||
mod movie;
|
||||
mod refresh_session;
|
||||
mod review;
|
||||
mod stats;
|
||||
mod user;
|
||||
@@ -43,7 +44,10 @@ pub use import::{
|
||||
};
|
||||
pub use import_profile::ImportProfile;
|
||||
pub use import_session::ImportSession;
|
||||
pub use person::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId};
|
||||
pub use person::{
|
||||
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId,
|
||||
};
|
||||
pub use refresh_session::RefreshSession;
|
||||
pub use search::{
|
||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
|
||||
SearchResults,
|
||||
|
||||
@@ -46,15 +46,56 @@ pub struct Person {
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
biography: Option<String>,
|
||||
birthday: Option<chrono::NaiveDate>,
|
||||
deathday: Option<chrono::NaiveDate>,
|
||||
place_of_birth: Option<String>,
|
||||
also_known_as: Vec<String>,
|
||||
homepage: Option<String>,
|
||||
imdb_id: Option<String>,
|
||||
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
id: PersonId,
|
||||
external_id: ExternalPersonId,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
biography: Option<String>,
|
||||
birthday: Option<chrono::NaiveDate>,
|
||||
deathday: Option<chrono::NaiveDate>,
|
||||
place_of_birth: Option<String>,
|
||||
also_known_as: Vec<String>,
|
||||
homepage: Option<String>,
|
||||
imdb_id: Option<String>,
|
||||
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
external_id,
|
||||
name,
|
||||
known_for_department,
|
||||
profile_path,
|
||||
biography,
|
||||
birthday,
|
||||
deathday,
|
||||
place_of_birth,
|
||||
also_known_as,
|
||||
homepage,
|
||||
imdb_id,
|
||||
enriched_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn basic(
|
||||
id: PersonId,
|
||||
external_id: ExternalPersonId,
|
||||
name: String,
|
||||
known_for_department: Option<String>,
|
||||
profile_path: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -62,6 +103,14 @@ impl Person {
|
||||
name,
|
||||
known_for_department,
|
||||
profile_path,
|
||||
biography: None,
|
||||
birthday: None,
|
||||
deathday: None,
|
||||
place_of_birth: None,
|
||||
also_known_as: vec![],
|
||||
homepage: None,
|
||||
imdb_id: None,
|
||||
enriched_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +133,49 @@ impl Person {
|
||||
pub fn profile_path(&self) -> Option<&str> {
|
||||
self.profile_path.as_deref()
|
||||
}
|
||||
|
||||
pub fn biography(&self) -> Option<&str> {
|
||||
self.biography.as_deref()
|
||||
}
|
||||
|
||||
pub fn birthday(&self) -> Option<chrono::NaiveDate> {
|
||||
self.birthday
|
||||
}
|
||||
|
||||
pub fn deathday(&self) -> Option<chrono::NaiveDate> {
|
||||
self.deathday
|
||||
}
|
||||
|
||||
pub fn place_of_birth(&self) -> Option<&str> {
|
||||
self.place_of_birth.as_deref()
|
||||
}
|
||||
|
||||
pub fn also_known_as(&self) -> &[String] {
|
||||
&self.also_known_as
|
||||
}
|
||||
|
||||
pub fn homepage(&self) -> Option<&str> {
|
||||
self.homepage.as_deref()
|
||||
}
|
||||
|
||||
pub fn imdb_id(&self) -> Option<&str> {
|
||||
self.imdb_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn enriched_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
self.enriched_at
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PersonEnrichmentData {
|
||||
pub biography: Option<String>,
|
||||
pub birthday: Option<chrono::NaiveDate>,
|
||||
pub deathday: Option<chrono::NaiveDate>,
|
||||
pub place_of_birth: Option<String>,
|
||||
pub also_known_as: Vec<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
13
crates/domain/src/models/refresh_session.rs
Normal file
13
crates/domain/src/models/refresh_session.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RefreshSession {
|
||||
pub id: Uuid,
|
||||
pub user_id: UserId,
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
fn person_new() {
|
||||
let ext = ExternalPersonId::new("tmdb:12345");
|
||||
let pid = PersonId::from_external(&ext);
|
||||
let p = Person::new(
|
||||
let p = Person::basic(
|
||||
pid,
|
||||
ext,
|
||||
"Keanu Reeves".into(),
|
||||
@@ -38,7 +38,7 @@ fn person_id_deterministic() {
|
||||
fn person_credits_default_empty() {
|
||||
let ext = ExternalPersonId::new("tmdb:1");
|
||||
let pid = PersonId::from_external(&ext);
|
||||
let p = Person::new(pid, ext, "Test".into(), None, None);
|
||||
let p = Person::basic(pid, ext, "Test".into(), None, None);
|
||||
let credits = PersonCredits {
|
||||
person: p,
|
||||
cast: vec![],
|
||||
|
||||
@@ -10,9 +10,9 @@ use crate::{
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
|
||||
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
|
||||
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteGoalEntry,
|
||||
RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, User,
|
||||
UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
|
||||
ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
|
||||
RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults,
|
||||
User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
|
||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||
collections::{self, PageParams, Paginated},
|
||||
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
|
||||
@@ -292,6 +292,11 @@ pub trait MovieEnrichmentClient: Send + Sync {
|
||||
) -> Result<MovieProfile, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PersonEnrichmentClient: Send + Sync {
|
||||
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ImportSessionRepository: Send + Sync {
|
||||
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
|
||||
@@ -306,6 +311,15 @@ pub trait ImportSessionRepository: Send + Sync {
|
||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RefreshSessionRepository: Send + Sync {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ImportProfileRepository: Send + Sync {
|
||||
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
|
||||
@@ -339,6 +353,11 @@ pub trait PersonCommand: Send + Sync {
|
||||
&self,
|
||||
batch_size: u32,
|
||||
) -> Result<(u64, bool), DomainError>;
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
id: &PersonId,
|
||||
data: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Read port — queries persons and credits. No mutations.
|
||||
|
||||
@@ -223,7 +223,7 @@ impl PersonQuery for FakePersonQuery {
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let dummy = Person::new(
|
||||
let dummy = Person::basic(
|
||||
id.clone(),
|
||||
ExternalPersonId::new("tmdb:0"),
|
||||
"Unknown".into(),
|
||||
|
||||
@@ -6,19 +6,22 @@ use uuid::Uuid;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
|
||||
ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus,
|
||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||
ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent,
|
||||
WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
|
||||
MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
||||
MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository,
|
||||
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
|
||||
WebhookTokenRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||
@@ -777,3 +780,53 @@ impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryRefreshSessionRepository ────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryRefreshSessionRepository {
|
||||
pub store: Mutex<Vec<RefreshSession>>,
|
||||
}
|
||||
|
||||
impl InMemoryRefreshSessionRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for InMemoryRefreshSessionRepository {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().push(session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
Ok(self
|
||||
.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.token == token)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().retain(|s| s.token != token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().retain(|s| s.user_id != *user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
let before = store.len();
|
||||
let now = Utc::now();
|
||||
store.retain(|s| s.expires_at >= now);
|
||||
Ok((before - store.len()) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@ use crate::{
|
||||
models::{
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
||||
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId,
|
||||
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
||||
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits,
|
||||
PersonEnrichmentData, PersonId, RefreshSession, ReviewHistory, SearchQuery, SearchResults,
|
||||
UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
|
||||
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
|
||||
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
|
||||
UserProfileFieldsRepository,
|
||||
PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort,
|
||||
StatsRepository, UserProfileFieldsRepository,
|
||||
},
|
||||
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
|
||||
};
|
||||
@@ -103,6 +104,27 @@ impl ImportSessionRepository for PanicImportSessionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicRefreshSessionRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for PanicRefreshSessionRepository {
|
||||
async fn create(&self, _: &RefreshSession) -> Result<(), DomainError> {
|
||||
panic!("PanicRefreshSessionRepository called")
|
||||
}
|
||||
async fn get_by_token(&self, _: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
panic!("PanicRefreshSessionRepository called")
|
||||
}
|
||||
async fn revoke(&self, _: &str) -> Result<(), DomainError> {
|
||||
panic!("PanicRefreshSessionRepository called")
|
||||
}
|
||||
async fn revoke_all_for_user(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
panic!("PanicRefreshSessionRepository called")
|
||||
}
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
panic!("PanicRefreshSessionRepository called")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicImportProfileRepository;
|
||||
|
||||
#[async_trait]
|
||||
@@ -150,6 +172,13 @@ impl PersonCommand for PanicPersonCommand {
|
||||
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
|
||||
panic!("PanicPersonCommand called")
|
||||
}
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
_: &PersonId,
|
||||
_: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("PanicPersonCommand called")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PanicPersonQuery;
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::sync::Arc;
|
||||
use anyhow::Context;
|
||||
use domain::ports::{
|
||||
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
|
||||
PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, WebhookTokenRepository,
|
||||
PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository,
|
||||
WatchEventRepository, WebhookTokenRepository,
|
||||
};
|
||||
|
||||
pub enum DbPool {
|
||||
@@ -36,6 +37,7 @@ pub struct DatabaseOutput {
|
||||
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
||||
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
||||
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
@@ -77,6 +79,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
goal: w.goal,
|
||||
user_settings: w.user_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
|
||||
w.pool.clone(),
|
||||
)) as _,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
@@ -115,6 +120,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
||||
goal: w.goal,
|
||||
user_settings: w.user_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||
as _,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ use crate::{
|
||||
render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
|
||||
use api_types::{
|
||||
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
|
||||
};
|
||||
use application::ports::HtmlPageContext;
|
||||
use template_askama::{LoginTemplate, RegisterTemplate};
|
||||
|
||||
@@ -68,6 +70,7 @@ pub async fn login(
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
@@ -100,6 +103,41 @@ pub async fn register(
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshResponse),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
let result = application::auth::refresh::execute(&state.app_ctx, &req.refresh_token).await?;
|
||||
Ok(Json(RefreshResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/logout",
|
||||
request_body = LogoutRequest,
|
||||
responses(
|
||||
(status = 204, description = "Logged out"),
|
||||
)
|
||||
)]
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> StatusCode {
|
||||
let _ = application::auth::logout::execute(&state.app_ctx, &req.refresh_token).await;
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_login_page(
|
||||
|
||||
@@ -13,10 +13,10 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::render::render_page;
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping,
|
||||
apply_mapping as apply_import_mapping, apply_profile as apply_import_profile,
|
||||
commands::{
|
||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
||||
ExecuteImportCommand, SaveImportProfileCommand,
|
||||
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
|
||||
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
|
||||
},
|
||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||
execute as execute_import, list_profiles as list_import_profiles,
|
||||
@@ -859,3 +859,97 @@ pub async fn api_delete_profile(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Import session UUID"),
|
||||
("profile_id" = String, Path, description = "Import profile UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
|
||||
(status = 400, description = "Invalid ID"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session or profile not found"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_apply_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid profile id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
if let Err(e) = apply_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
};
|
||||
return (
|
||||
status,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let session = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&ImportSessionId::from_uuid(session_id), &user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(s)) => s,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mappings = session.field_mappings.unwrap_or_default();
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,16 @@ pub async fn get_person_handler(
|
||||
name: person.name().to_string(),
|
||||
known_for_department: person.known_for_department().map(str::to_string),
|
||||
profile_path: person.profile_path().map(str::to_string),
|
||||
biography: person.biography().map(str::to_string),
|
||||
birthday: person.birthday().map(|d| d.to_string()),
|
||||
deathday: person.deathday().map(|d| d.to_string()),
|
||||
place_of_birth: person.place_of_birth().map(str::to_string),
|
||||
also_known_as: person.also_known_as().to_vec(),
|
||||
homepage: person.homepage().map(str::to_string),
|
||||
imdb_url: person
|
||||
.imdb_id()
|
||||
.map(|id| format!("https://www.imdb.com/name/{id}")),
|
||||
enriched: person.enriched_at().is_some(),
|
||||
})
|
||||
.into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
@@ -136,6 +146,17 @@ pub async fn get_person_credits_handler(
|
||||
name: credits.person.name().to_string(),
|
||||
known_for_department: credits.person.known_for_department().map(str::to_string),
|
||||
profile_path: credits.person.profile_path().map(str::to_string),
|
||||
biography: credits.person.biography().map(str::to_string),
|
||||
birthday: credits.person.birthday().map(|d| d.to_string()),
|
||||
deathday: credits.person.deathday().map(|d| d.to_string()),
|
||||
place_of_birth: credits.person.place_of_birth().map(str::to_string),
|
||||
also_known_as: credits.person.also_known_as().to_vec(),
|
||||
homepage: credits.person.homepage().map(str::to_string),
|
||||
imdb_url: credits
|
||||
.person
|
||||
.imdb_id()
|
||||
.map(|id| format!("https://www.imdb.com/name/{id}")),
|
||||
enriched: credits.person.enriched_at().is_some(),
|
||||
},
|
||||
cast: credits
|
||||
.cast
|
||||
|
||||
@@ -270,7 +270,7 @@ pub async fn get_user_profile(
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
search: params.search,
|
||||
is_own_profile: viewer_id.value() == user_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -207,6 +207,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
goal: db.goal,
|
||||
user_settings: db.user_settings,
|
||||
remote_goal: db.remote_goal,
|
||||
refresh_session: db.refresh_session,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
@@ -218,6 +219,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
review_logger,
|
||||
person_enrichment: None,
|
||||
},
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
@@ -326,6 +326,8 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
)
|
||||
.route("/auth/login", routing::post(handlers::auth::login))
|
||||
.route("/auth/register", routing::post(handlers::auth::register))
|
||||
.route("/auth/refresh", routing::post(handlers::auth::refresh))
|
||||
.route("/auth/logout", routing::post(handlers::auth::api_logout))
|
||||
.route("/diary/export", routing::get(handlers::diary::export_diary))
|
||||
.route(
|
||||
"/activity-feed",
|
||||
@@ -365,6 +367,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/import/profiles/{id}",
|
||||
routing::delete(handlers::import::api_delete_profile),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/profile/{profile_id}",
|
||||
routing::put(handlers::import::api_apply_profile),
|
||||
)
|
||||
.route(
|
||||
"/profile",
|
||||
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
|
||||
|
||||
@@ -14,8 +14,8 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
|
||||
PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats,
|
||||
UserTrends,
|
||||
PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
|
||||
SearchResults, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
@@ -437,6 +437,13 @@ impl PersonCommand for Panic {
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
_: &PersonId,
|
||||
_: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PersonQuery for Panic {
|
||||
@@ -722,6 +729,31 @@ impl domain::ports::RemoteGoalRepository for Panic {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RefreshSessionRepository for Panic {
|
||||
async fn create(&self, _: &domain::models::RefreshSession) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_by_token(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Option<domain::models::RefreshSession>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn revoke(&self, _: &str) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn revoke_all_for_user(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl application::ports::ReviewLogger for Panic {
|
||||
async fn log_review(
|
||||
@@ -762,6 +794,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
goal: Arc::clone(&repo) as _,
|
||||
user_settings: Arc::clone(&repo) as _,
|
||||
remote_goal: Arc::clone(&repo) as _,
|
||||
refresh_session: Arc::clone(&repo) as _,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
@@ -773,11 +806,13 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
review_logger: Arc::clone(&repo) as _,
|
||||
person_enrichment: None,
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: application::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
|
||||
@@ -14,8 +14,8 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId,
|
||||
SearchQuery, SearchResults, User,
|
||||
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits,
|
||||
PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User,
|
||||
},
|
||||
ports::{
|
||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||
@@ -314,6 +314,13 @@ impl PersonCommand for PanicPersonCommand {
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
_: &PersonId,
|
||||
_: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicPersonQuery;
|
||||
@@ -451,6 +458,7 @@ async fn test_app() -> Router {
|
||||
goal: Arc::new(domain::testing::NoopGoalRepository),
|
||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
|
||||
},
|
||||
services: Services {
|
||||
auth: Arc::new(PanicAuth),
|
||||
@@ -462,11 +470,13 @@ async fn test_app() -> Router {
|
||||
diary_exporter: Arc::new(PanicExporter),
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
review_logger: Arc::new(PanicReviewLogger),
|
||||
person_enrichment: None,
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: application::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
|
||||
@@ -41,6 +41,7 @@ pub struct WorkerDbOutput {
|
||||
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
||||
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
||||
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
@@ -86,6 +87,9 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
|
||||
goal: w.goal,
|
||||
user_settings: w.user_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
|
||||
w.pool.clone(),
|
||||
)) as _,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
@@ -128,6 +132,8 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
|
||||
goal: w.goal,
|
||||
user_settings: w.user_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||
as _,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ use export::ExportAdapter;
|
||||
use importer::ImporterDocumentParser;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use domain::ports::{DiaryExporter, DocumentParser, EventHandler, PeriodicJob};
|
||||
use domain::ports::{
|
||||
DiaryExporter, DocumentParser, EventHandler, MovieEnrichmentClient, PeriodicJob,
|
||||
PersonEnrichmentClient,
|
||||
};
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||
compile_error!(
|
||||
@@ -74,7 +77,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
Arc::clone(&event_publisher_arc),
|
||||
));
|
||||
|
||||
let ctx = AppContext {
|
||||
let mut ctx = AppContext {
|
||||
repos: Repositories {
|
||||
movie: db.movie,
|
||||
review: db.review,
|
||||
@@ -105,6 +108,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
goal: db.goal,
|
||||
user_settings: db.user_settings,
|
||||
remote_goal: db.remote_goal,
|
||||
refresh_session: db.refresh_session,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
@@ -116,6 +120,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
review_logger,
|
||||
person_enrichment: None,
|
||||
},
|
||||
config: app_config,
|
||||
};
|
||||
@@ -124,26 +129,36 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
|
||||
// Without a key, no MovieEnrichmentRequested events are produced or handled.
|
||||
|
||||
type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
|
||||
let (enrichment_handler, enrichment_job): OptionalPair =
|
||||
type EnrichmentParts = (
|
||||
Option<Arc<dyn EventHandler>>,
|
||||
Option<Arc<dyn EventHandler>>,
|
||||
Option<Arc<dyn PeriodicJob>>,
|
||||
);
|
||||
let (enrichment_handler, person_enrichment_handler, enrichment_job): EnrichmentParts =
|
||||
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
|
||||
Ok(client) => {
|
||||
tracing::info!("TMDb enrichment enabled");
|
||||
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler::new(
|
||||
Arc::new(client),
|
||||
let client = Arc::new(client);
|
||||
let handler = Arc::new(tmdb_enrichment::MovieEnrichmentHandler::new(
|
||||
Arc::clone(&client) as Arc<dyn MovieEnrichmentClient>,
|
||||
Arc::clone(&ctx.repos.movie),
|
||||
Arc::clone(&ctx.repos.movie_profile),
|
||||
Arc::clone(&ctx.repos.person_command),
|
||||
Arc::clone(&ctx.repos.search_command),
|
||||
Arc::clone(&ctx.services.object_storage),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
ctx.services.person_enrichment =
|
||||
Some(Arc::clone(&client) as Arc<dyn PersonEnrichmentClient>);
|
||||
let person_handler =
|
||||
Arc::new(tmdb_enrichment::PersonEnrichmentHandler::new(ctx.clone()))
|
||||
as Arc<dyn EventHandler>;
|
||||
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
||||
as Arc<dyn PeriodicJob>;
|
||||
(Some(handler), Some(job))
|
||||
(Some(handler), Some(person_handler), Some(job))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("TMDb enrichment disabled: {e}");
|
||||
(None, None)
|
||||
(None, None, None)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,6 +178,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())),
|
||||
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())),
|
||||
Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())),
|
||||
Arc::new(application::jobs::RefreshSessionCleanupJob::new(
|
||||
ctx.clone(),
|
||||
)),
|
||||
];
|
||||
if let Some(job) = enrichment_job {
|
||||
periodic_jobs.push(job);
|
||||
@@ -225,6 +243,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
if let Some(e) = enrichment_handler {
|
||||
h.push(e);
|
||||
}
|
||||
if let Some(e) = person_enrichment_handler {
|
||||
h.push(e);
|
||||
}
|
||||
if let Some((ref conv_handler, _)) = conversion {
|
||||
h.push(Arc::clone(conv_handler));
|
||||
}
|
||||
@@ -281,6 +302,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
if let Some(e) = enrichment_handler {
|
||||
h.push(e);
|
||||
}
|
||||
if let Some(e) = person_enrichment_handler {
|
||||
h.push(e);
|
||||
}
|
||||
if let Some((ref conv_handler, _)) = conversion {
|
||||
h.push(Arc::clone(conv_handler));
|
||||
}
|
||||
|
||||
71
spa/src/components/horizontal-strip.tsx
Normal file
71
spa/src/components/horizontal-strip.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type HorizontalStripProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
gap?: string
|
||||
}
|
||||
|
||||
export function HorizontalStrip({ children, className, gap = "gap-3" }: HorizontalStripProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
setCanScrollLeft(el.scrollLeft > 0)
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
update()
|
||||
el.addEventListener("scroll", update, { passive: true })
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [update])
|
||||
|
||||
function scroll(dir: -1 | 1) {
|
||||
ref.current?.scrollBy({ left: dir * ref.current.clientWidth * 0.75, behavior: "smooth" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`group relative ${className ?? ""}`}>
|
||||
{canScrollLeft && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute -left-1 top-1/3 z-10 size-8 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||
onClick={() => scroll(-1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`-mx-4 flex ${gap} overflow-x-auto overscroll-x-contain px-4 pb-2`}
|
||||
style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{canScrollRight && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute -right-1 top-1/3 z-10 size-8 rounded-full opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
||||
onClick={() => scroll(1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</p>}
|
||||
</div>
|
||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||
</Link>
|
||||
|
||||
@@ -2,16 +2,18 @@ import { Link } from "@tanstack/react-router"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
||||
import { User } from "lucide-react"
|
||||
import { Search, User } from "lucide-react"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { useInfiniteDiary } from "@/hooks/use-diary"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import type { UserProfileResponse } from "@/lib/api/users"
|
||||
|
||||
type ProfileViewProps = {
|
||||
@@ -19,6 +21,8 @@ type ProfileViewProps = {
|
||||
actions?: React.ReactNode
|
||||
headerRight?: React.ReactNode
|
||||
userId?: string
|
||||
search?: string
|
||||
onSearchChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export function ProfileView({
|
||||
@@ -26,6 +30,8 @@ export function ProfileView({
|
||||
actions,
|
||||
headerRight,
|
||||
userId,
|
||||
search,
|
||||
onSearchChange,
|
||||
}: ProfileViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
|
||||
@@ -72,6 +78,18 @@ export function ProfileView({
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{onSearchChange && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("profile.searchPlaceholder")}
|
||||
value={search ?? ""}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actions}
|
||||
|
||||
<SwipeTabs
|
||||
@@ -82,13 +100,14 @@ export function ProfileView({
|
||||
{(tab) => (
|
||||
<>
|
||||
{tab === "recent" && (
|
||||
<DiaryTab key="date_desc" sortBy="date_desc" userId={userId} />
|
||||
<DiaryTab key="date_desc" sortBy="date_desc" userId={userId} search={search} />
|
||||
)}
|
||||
{tab === "top_rated" && (
|
||||
<DiaryTab
|
||||
key="rating_desc"
|
||||
sortBy="rating_desc"
|
||||
userId={userId}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
{tab === "trends" && <TrendsView data={data} />}
|
||||
@@ -108,19 +127,24 @@ function StatCell({ label, value }: { label: string; value: string | number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DiaryTab({ sortBy }: { sortBy: string; userId?: string }) {
|
||||
function DiaryTab({ sortBy, search }: { sortBy: string; userId?: string; search?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: sortBy, movie_id: undefined })
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||
const filtered = search
|
||||
? items.filter((e) =>
|
||||
e.movie.title.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: items
|
||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||
|
||||
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||
if (!items.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
|
||||
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
|
||||
|
||||
return (
|
||||
<VirtualList
|
||||
items={items}
|
||||
items={filtered}
|
||||
estimateSize={52}
|
||||
hasMore={!!hasNextPage}
|
||||
isFetching={isFetchingNextPage}
|
||||
@@ -130,7 +154,7 @@ function DiaryTab({ sortBy }: { sortBy: string; userId?: string }) {
|
||||
movie={e.movie}
|
||||
rating={e.review.rating}
|
||||
comment={e.review.comment}
|
||||
subtitle={e.review.watched_at.slice(0, 10)}
|
||||
subtitle={t("profile.watchedAgo", { when: timeAgo(e.review.watched_at) })}
|
||||
variant="compact"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { login, register } from "@/lib/api/auth"
|
||||
import { apiLogout, login, register } from "@/lib/api/auth"
|
||||
import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
|
||||
import { getRefreshToken } from "@/lib/auth"
|
||||
|
||||
export function useLogin() {
|
||||
const { login: setAuth } = useAuth()
|
||||
@@ -11,6 +12,7 @@ export function useLogin() {
|
||||
onSuccess: (res) => {
|
||||
setAuth({
|
||||
token: res.token,
|
||||
refresh_token: res.refresh_token,
|
||||
user_id: res.user_id,
|
||||
email: res.email,
|
||||
role: res.role,
|
||||
@@ -32,6 +34,12 @@ export function useLogout() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const rt = getRefreshToken()
|
||||
if (rt) {
|
||||
try {
|
||||
await apiLogout(rt)
|
||||
} catch {}
|
||||
}
|
||||
logout()
|
||||
qc.clear()
|
||||
},
|
||||
|
||||
12
spa/src/hooks/use-document-title.ts
Normal file
12
spa/src/hooks/use-document-title.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
const BASE_TITLE = "Movies Diary"
|
||||
|
||||
export function useDocumentTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} — ${BASE_TITLE}` : BASE_TITLE
|
||||
return () => {
|
||||
document.title = BASE_TITLE
|
||||
}
|
||||
}, [title])
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
applyImportProfile,
|
||||
applyMapping,
|
||||
confirmImport,
|
||||
createImportSession,
|
||||
@@ -93,3 +94,10 @@ export function useDeleteImportProfile() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useApplyImportProfile() {
|
||||
return useMutation({
|
||||
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
|
||||
applyImportProfile(sessionId, profileId),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { post } from "./client"
|
||||
import { API_URL, post } from "./client"
|
||||
|
||||
export const loginRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
@@ -9,6 +9,7 @@ export type LoginRequest = z.infer<typeof loginRequestSchema>
|
||||
|
||||
export const loginResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
user_id: z.string().uuid(),
|
||||
email: z.string(),
|
||||
role: z.string(),
|
||||
@@ -30,3 +31,25 @@ export function login(data: LoginRequest) {
|
||||
export function register(data: RegisterRequest) {
|
||||
return post("/auth/register", data)
|
||||
}
|
||||
|
||||
export type RefreshResponse = {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export async function refreshToken(
|
||||
refresh_token: string,
|
||||
): Promise<RefreshResponse> {
|
||||
const res = await fetch(`${API_URL}/api/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token }),
|
||||
})
|
||||
if (!res.ok) throw new Error("refresh failed")
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function apiLogout(refresh_token: string) {
|
||||
return post("/auth/logout", { refresh_token })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clearAuth, getToken } from "@/lib/auth"
|
||||
import { clearAuth, getAuth, getToken, setAuth } from "@/lib/auth"
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL ?? ""
|
||||
|
||||
@@ -42,6 +42,31 @@ function buildUrl(
|
||||
return qs ? `${base}?${qs}` : base
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
const auth = getAuth()
|
||||
if (!auth?.refresh_token) return false
|
||||
try {
|
||||
const res = await fetch(buildUrl("/auth/refresh"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
||||
})
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
setAuth({
|
||||
...auth,
|
||||
token: data.token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: data.expires_at,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T = void>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
@@ -55,8 +80,36 @@ async function request<T = void>(
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
clearAuth()
|
||||
window.location.href = "/app/login"
|
||||
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
|
||||
clearAuth()
|
||||
window.location.href = "/app/login"
|
||||
throw new ApiError(res.status, await res.text())
|
||||
}
|
||||
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = tryRefresh().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
const ok = await refreshPromise
|
||||
if (!ok) {
|
||||
clearAuth()
|
||||
window.location.href = "/app/login"
|
||||
throw new ApiError(res.status, await res.text())
|
||||
}
|
||||
|
||||
const retryRes = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
if (!retryRes.ok) {
|
||||
throw new ApiError(retryRes.status, await retryRes.text())
|
||||
}
|
||||
const retryText = await retryRes.text()
|
||||
return retryText ? JSON.parse(retryText) : (undefined as T)
|
||||
}
|
||||
throw new ApiError(res.status, await res.text())
|
||||
}
|
||||
@@ -118,3 +171,17 @@ export async function upload<T>(
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadWithFields<T>(
|
||||
path: string,
|
||||
file: File,
|
||||
fields: Record<string, string>,
|
||||
): Promise<T> {
|
||||
const form = new FormData()
|
||||
form.append("file", file)
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, v)
|
||||
return request<T>(buildUrl(path), {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { del, get, post, put, upload } from "./client"
|
||||
import { del, get, post, put, uploadWithFields } from "./client"
|
||||
|
||||
export const sessionCreatedResponseSchema = z.object({
|
||||
session_id: z.string(),
|
||||
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
|
||||
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
||||
|
||||
export function createImportSession(file: File) {
|
||||
return upload<SessionCreatedResponse>("/import/sessions", file)
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
const format = ext === "json" ? "json" : "csv"
|
||||
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
|
||||
}
|
||||
|
||||
export function getImportSession(id: string) {
|
||||
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
|
||||
return post(`/import/sessions/${sessionId}/confirm`, data)
|
||||
}
|
||||
|
||||
export type ImportProfile = {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function getImportProfiles() {
|
||||
return get<unknown[]>("/import/profiles")
|
||||
return get<ImportProfile[]>("/import/profiles")
|
||||
}
|
||||
|
||||
export function saveImportProfile(data: SaveProfileRequest) {
|
||||
return post("/import/profiles", data)
|
||||
return post<{ id: string }>("/import/profiles", data)
|
||||
}
|
||||
|
||||
export function deleteImportProfile(id: string) {
|
||||
return del(`/import/profiles/${id}`)
|
||||
}
|
||||
|
||||
export function applyImportProfile(sessionId: string, profileId: string) {
|
||||
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ export const personDtoSchema = z.object({
|
||||
name: z.string(),
|
||||
known_for_department: z.string().optional(),
|
||||
profile_path: z.string().optional(),
|
||||
biography: z.string().optional(),
|
||||
birthday: z.string().optional(),
|
||||
deathday: z.string().optional(),
|
||||
place_of_birth: z.string().optional(),
|
||||
also_known_as: z.array(z.string()).default([]),
|
||||
homepage: z.string().optional(),
|
||||
imdb_url: z.string().optional(),
|
||||
enriched: z.boolean().default(false),
|
||||
})
|
||||
export type PersonDto = z.infer<typeof personDtoSchema>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const userProfileQueryParamsSchema = z.object({
|
||||
view: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
export type UserProfileQueryParams = z.infer<typeof userProfileQueryParamsSchema>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const AUTH_KEY = "auth_state"
|
||||
|
||||
export type AuthState = {
|
||||
token: string
|
||||
refresh_token: string
|
||||
user_id: string
|
||||
email: string
|
||||
role: string
|
||||
@@ -30,6 +31,10 @@ export function getToken(): string | null {
|
||||
return getAuth()?.token ?? null
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return getAuth()?.refresh_token ?? null
|
||||
}
|
||||
|
||||
export function isAdmin(): boolean {
|
||||
return getAuth()?.role === "admin"
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
"run": "Run",
|
||||
"reviews": "{{count}} reviews",
|
||||
"films": "{{count}} films",
|
||||
"filmsAvg": "{{count}} films, avg {{avg}}"
|
||||
"filmsAvg": "{{count}} films, avg {{avg}}",
|
||||
"more": "Show more",
|
||||
"less": "Show less"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -122,7 +124,19 @@
|
||||
"noEntries": "No entries",
|
||||
"noTrends": "No trends yet",
|
||||
"topDirectors": "Top Directors",
|
||||
"monthlyActivity": "Monthly Activity"
|
||||
"monthlyActivity": "Monthly Activity",
|
||||
"searchPlaceholder": "Search entries...",
|
||||
"watchedAgo": "Watched {{when}}"
|
||||
},
|
||||
"person": {
|
||||
"biography": "Biography",
|
||||
"birthday": "Born",
|
||||
"deathday": "Died",
|
||||
"placeOfBirth": "Birthplace",
|
||||
"alsoKnownAs": "Also Known As",
|
||||
"links": "Links",
|
||||
"homepage": "Homepage",
|
||||
"imdb": "IMDb"
|
||||
},
|
||||
"social": {
|
||||
"title": "Social",
|
||||
@@ -143,7 +157,7 @@
|
||||
"editProfile": "Edit Profile",
|
||||
"editProfileDesc": "Username, bio",
|
||||
"import": "Import",
|
||||
"importDesc": "Import from CSV",
|
||||
"importDesc": "Import from CSV or JSON",
|
||||
"yearWrapUp": "Year Wrap-Up",
|
||||
"yearWrapUpDesc": "Annual summaries",
|
||||
"webhookTokens": "Webhook Tokens",
|
||||
@@ -163,7 +177,12 @@
|
||||
"rebuildSearchDone": "Reindex queued",
|
||||
"privacy": "Privacy",
|
||||
"federateGoals": "Share goals on Fediverse",
|
||||
"federateGoalsDesc": "Broadcast goal progress to followers"
|
||||
"federateGoalsDesc": "Broadcast goal progress to followers",
|
||||
"export": "Export",
|
||||
"exportDesc": "Download your diary",
|
||||
"exportCsv": "CSV",
|
||||
"exportJson": "JSON",
|
||||
"exporting": "Exporting..."
|
||||
},
|
||||
"goals": {
|
||||
"yearGoal": "{{year}} Goal",
|
||||
@@ -331,7 +350,7 @@
|
||||
"scale1to10": "1–10 → 1–5 (×0.5)",
|
||||
"scale1to100": "1–100 → 1–5 (×0.05)",
|
||||
"scaleLetterboxd": "0–4 Letterboxd → 1–5 (×1.25)",
|
||||
"dropCsv": "Drop a CSV file or tap to browse",
|
||||
"dropCsv": "Drop a CSV or JSON file, or tap to browse",
|
||||
"uploading": "Uploading...",
|
||||
"preview": "Preview",
|
||||
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
||||
@@ -351,6 +370,14 @@
|
||||
"importing": "Importing...",
|
||||
"importRows": "Import {{count}} rows",
|
||||
"importComplete": "Import complete!",
|
||||
"viewDiary": "View your diary"
|
||||
"viewDiary": "View your diary",
|
||||
"presets": "Presets",
|
||||
"loadPreset": "Load preset",
|
||||
"savePreset": "Save as preset",
|
||||
"presetName": "Preset name",
|
||||
"presetNamePlaceholder": "e.g. Letterboxd",
|
||||
"presetSaved": "Preset saved",
|
||||
"presetDeleted": "Preset deleted",
|
||||
"noPresets": "No saved presets"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Route as AppUsersIdRouteImport } from './routes/_app/users.$id'
|
||||
import { Route as AppSettingsWrapupRouteImport } from './routes/_app/settings/wrapup'
|
||||
import { Route as AppSettingsWebhooksRouteImport } from './routes/_app/settings/webhooks'
|
||||
import { Route as AppSettingsImportRouteImport } from './routes/_app/settings/import'
|
||||
import { Route as AppSettingsExportRouteImport } from './routes/_app/settings/export'
|
||||
import { Route as AppSettingsEditProfileRouteImport } from './routes/_app/settings/edit-profile'
|
||||
import { Route as AppSettingsBlockedRouteImport } from './routes/_app/settings/blocked'
|
||||
import { Route as AppPeopleIdRouteImport } from './routes/_app/people.$id'
|
||||
@@ -97,6 +98,11 @@ const AppSettingsImportRoute = AppSettingsImportRouteImport.update({
|
||||
path: '/settings/import',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppSettingsExportRoute = AppSettingsExportRouteImport.update({
|
||||
id: '/settings/export',
|
||||
path: '/settings/export',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
const AppSettingsEditProfileRoute = AppSettingsEditProfileRouteImport.update({
|
||||
id: '/settings/edit-profile',
|
||||
path: '/settings/edit-profile',
|
||||
@@ -130,6 +136,7 @@ export interface FileRoutesByFullPath {
|
||||
'/people/$id': typeof AppPeopleIdRoute
|
||||
'/settings/blocked': typeof AppSettingsBlockedRoute
|
||||
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||
'/settings/export': typeof AppSettingsExportRoute
|
||||
'/settings/import': typeof AppSettingsImportRoute
|
||||
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||
@@ -149,6 +156,7 @@ export interface FileRoutesByTo {
|
||||
'/people/$id': typeof AppPeopleIdRoute
|
||||
'/settings/blocked': typeof AppSettingsBlockedRoute
|
||||
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||
'/settings/export': typeof AppSettingsExportRoute
|
||||
'/settings/import': typeof AppSettingsImportRoute
|
||||
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||
@@ -170,6 +178,7 @@ export interface FileRoutesById {
|
||||
'/_app/people/$id': typeof AppPeopleIdRoute
|
||||
'/_app/settings/blocked': typeof AppSettingsBlockedRoute
|
||||
'/_app/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||
'/_app/settings/export': typeof AppSettingsExportRoute
|
||||
'/_app/settings/import': typeof AppSettingsImportRoute
|
||||
'/_app/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||
'/_app/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
|
||||
| '/people/$id'
|
||||
| '/settings/blocked'
|
||||
| '/settings/edit-profile'
|
||||
| '/settings/export'
|
||||
| '/settings/import'
|
||||
| '/settings/webhooks'
|
||||
| '/settings/wrapup'
|
||||
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
|
||||
| '/people/$id'
|
||||
| '/settings/blocked'
|
||||
| '/settings/edit-profile'
|
||||
| '/settings/export'
|
||||
| '/settings/import'
|
||||
| '/settings/webhooks'
|
||||
| '/settings/wrapup'
|
||||
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/people/$id'
|
||||
| '/_app/settings/blocked'
|
||||
| '/_app/settings/edit-profile'
|
||||
| '/_app/settings/export'
|
||||
| '/_app/settings/import'
|
||||
| '/_app/settings/webhooks'
|
||||
| '/_app/settings/wrapup'
|
||||
@@ -344,6 +356,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppSettingsImportRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/settings/export': {
|
||||
id: '/_app/settings/export'
|
||||
path: '/settings/export'
|
||||
fullPath: '/settings/export'
|
||||
preLoaderRoute: typeof AppSettingsExportRouteImport
|
||||
parentRoute: typeof AppRoute
|
||||
}
|
||||
'/_app/settings/edit-profile': {
|
||||
id: '/_app/settings/edit-profile'
|
||||
path: '/settings/edit-profile'
|
||||
@@ -385,6 +404,7 @@ interface AppRouteChildren {
|
||||
AppPeopleIdRoute: typeof AppPeopleIdRoute
|
||||
AppSettingsBlockedRoute: typeof AppSettingsBlockedRoute
|
||||
AppSettingsEditProfileRoute: typeof AppSettingsEditProfileRoute
|
||||
AppSettingsExportRoute: typeof AppSettingsExportRoute
|
||||
AppSettingsImportRoute: typeof AppSettingsImportRoute
|
||||
AppSettingsWebhooksRoute: typeof AppSettingsWebhooksRoute
|
||||
AppSettingsWrapupRoute: typeof AppSettingsWrapupRoute
|
||||
@@ -403,6 +423,7 @@ const AppRouteChildren: AppRouteChildren = {
|
||||
AppPeopleIdRoute: AppPeopleIdRoute,
|
||||
AppSettingsBlockedRoute: AppSettingsBlockedRoute,
|
||||
AppSettingsEditProfileRoute: AppSettingsEditProfileRoute,
|
||||
AppSettingsExportRoute: AppSettingsExportRoute,
|
||||
AppSettingsImportRoute: AppSettingsImportRoute,
|
||||
AppSettingsWebhooksRoute: AppSettingsWebhooksRoute,
|
||||
AppSettingsWrapupRoute: AppSettingsWrapupRoute,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { BookOpen, ChevronLeft, ChevronRight, Download } from "lucide-react"
|
||||
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { format, startOfMonth, subMonths } from "date-fns"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
@@ -10,8 +10,7 @@ import { VirtualList } from "@/components/virtual-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
||||
import { API_URL } from "@/lib/api/client"
|
||||
import { getToken } from "@/lib/auth"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||
|
||||
export const Route = createFileRoute("/_app/diary")({
|
||||
@@ -29,6 +28,7 @@ function groupByDate(items: DiaryEntryDto[]) {
|
||||
|
||||
function DiaryPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("diary.title"))
|
||||
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: "desc" })
|
||||
@@ -75,28 +75,7 @@ function DiaryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={async () => {
|
||||
const res = await fetch(`${API_URL}/api/v1/diary/export?format=csv`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "diary.csv"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl bg-card px-3 py-2">
|
||||
<Button variant="ghost" size="icon" onClick={goBack} disabled={!canGoBack}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BackButton } from "@/components/back-button"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
@@ -12,6 +13,7 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||
import { timeAgo, shortDate } from "@/lib/date"
|
||||
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import {
|
||||
useWatchlistStatus,
|
||||
useAddToWatchlist,
|
||||
@@ -29,6 +31,7 @@ function MovieDetailPage() {
|
||||
const { data, isPending } = useMovie(id)
|
||||
const { data: profile } = useMovieProfile(id)
|
||||
const { data: history } = useMovieHistory(id)
|
||||
useDocumentTitle(data?.movie.title)
|
||||
|
||||
if (isPending) return <DetailSkeleton />
|
||||
if (!data) return null
|
||||
@@ -229,7 +232,7 @@ function HeroSection({
|
||||
|
||||
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
|
||||
return (
|
||||
<div className="-mx-4 flex gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
|
||||
<HorizontalStrip gap="gap-2.5">
|
||||
{items.map((person, i) => {
|
||||
const subtitle = type === "cast"
|
||||
? (person as CastMemberDto).character
|
||||
@@ -251,7 +254,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</HorizontalStrip>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Film, User } from "lucide-react"
|
||||
import { Calendar, ChevronDown, ExternalLink, Film, Globe, MapPin, User } from "lucide-react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { tmdbProfileUrl } from "@/lib/api/client"
|
||||
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||
import { usePersonCredits } from "@/hooks/use-search"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import { shortDate } from "@/lib/date"
|
||||
import { differenceInYears, parseISO } from "date-fns"
|
||||
|
||||
export const Route = createFileRoute("/_app/people/$id")({
|
||||
component: PersonDetailPage,
|
||||
@@ -16,100 +26,216 @@ function PersonDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
const { id } = Route.useParams()
|
||||
const { data, isPending } = usePersonCredits(id)
|
||||
useDocumentTitle(data?.person.name)
|
||||
|
||||
if (isPending) return <PersonSkeleton />
|
||||
if (!data) return null
|
||||
|
||||
const { person, cast, crew } = data
|
||||
|
||||
const age = person.birthday
|
||||
? differenceInYears(
|
||||
person.deathday ? parseISO(person.deathday) : new Date(),
|
||||
parseISO(person.birthday),
|
||||
)
|
||||
: null
|
||||
|
||||
const creditTabs = [
|
||||
...(cast.length > 0 ? [{ value: "cast", label: t("movie.cast") + ` (${cast.length})` }] : []),
|
||||
...(crew.length > 0 ? [{ value: "crew", label: t("movie.crew") + ` (${crew.length})` }] : []),
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<BackButton />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
|
||||
{/* Header */}
|
||||
<div className="flex gap-4">
|
||||
<div className="size-24 flex-shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||
{person.profile_path ? (
|
||||
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<User className="size-6 text-muted-foreground/40" />
|
||||
<User className="size-10 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h1 className="text-xl font-bold">{person.name}</h1>
|
||||
{person.known_for_department && (
|
||||
<p className="text-sm text-muted-foreground">{person.known_for_department}</p>
|
||||
<Badge variant="secondary">{person.known_for_department}</Badge>
|
||||
)}
|
||||
{(person.homepage || person.imdb_url) && (
|
||||
<div className="flex gap-1.5 pt-1">
|
||||
{person.imdb_url && (
|
||||
<a href={person.imdb_url} target="_blank" rel="noopener noreferrer">
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<ExternalLink className="size-2.5" />
|
||||
IMDb
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
{person.homepage && (
|
||||
<a href={person.homepage} target="_blank" rel="noopener noreferrer">
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<Globe className="size-2.5" />
|
||||
{t("person.homepage")}
|
||||
</Badge>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cast.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("movie.castCredits", { count: cast.length })}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{cast.map((c) => (
|
||||
<MovieCard
|
||||
key={`${c.movie_id}-${c.character}`}
|
||||
movie={{
|
||||
id: c.movie_id,
|
||||
title: c.title,
|
||||
release_year: c.release_year ?? 0,
|
||||
poster_path: c.poster_path,
|
||||
genres: [],
|
||||
}}
|
||||
subtitle={c.character}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Details card */}
|
||||
{(person.birthday || person.place_of_birth) && (
|
||||
<Card size="sm">
|
||||
<CardContent className="space-y-2">
|
||||
{person.birthday && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="size-3.5 text-muted-foreground" />
|
||||
<span>{shortDate(person.birthday)}</span>
|
||||
{age != null && (
|
||||
<span className="text-muted-foreground">({age})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{person.deathday && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="size-3.5 text-muted-foreground" />
|
||||
<span>{shortDate(person.deathday)}</span>
|
||||
<span className="text-muted-foreground">({t("person.deathday").toLowerCase()})</span>
|
||||
</div>
|
||||
)}
|
||||
{person.place_of_birth && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="size-3.5 text-muted-foreground" />
|
||||
<span>{person.place_of_birth}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{crew.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("movie.crewCredits", { count: crew.length })}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{crew.map((c) => (
|
||||
<MovieCard
|
||||
key={`${c.movie_id}-${c.job}`}
|
||||
movie={{
|
||||
id: c.movie_id,
|
||||
title: c.title,
|
||||
release_year: c.release_year ?? 0,
|
||||
poster_path: c.poster_path,
|
||||
genres: [],
|
||||
}}
|
||||
subtitle={`${c.job} (${c.department})`}
|
||||
variant="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Biography */}
|
||||
{person.biography && <BiographySection text={person.biography} />}
|
||||
|
||||
{/* Also known as */}
|
||||
{person.also_known_as?.length > 0 && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xs">{t("person.alsoKnownAs")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{person.also_known_as.map((name) => (
|
||||
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||
{name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{cast.length === 0 && crew.length === 0 && (
|
||||
<Separator />
|
||||
|
||||
{/* Filmography */}
|
||||
{creditTabs.length > 0 ? (
|
||||
<SwipeTabs tabs={creditTabs} defaultValue={creditTabs[0].value} tabsListClassName="w-full">
|
||||
{(tab) => (
|
||||
<>
|
||||
{tab === "cast" && <FilmStrip items={cast.map((c) => ({ movieId: c.movie_id, title: c.title, year: c.release_year, posterPath: c.poster_path, subtitle: c.character }))} />}
|
||||
{tab === "crew" && <FilmStrip items={crew.map((c) => ({ movieId: c.movie_id, title: c.title, year: c.release_year, posterPath: c.poster_path, subtitle: `${c.job}` }))} />}
|
||||
</>
|
||||
)}
|
||||
</SwipeTabs>
|
||||
) : (
|
||||
<EmptyState icon={Film} title={t("movie.noCredits")} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FilmStripItem = {
|
||||
movieId: string
|
||||
title: string
|
||||
year?: number
|
||||
posterPath?: string
|
||||
subtitle: string
|
||||
}
|
||||
|
||||
function FilmStrip({ items }: { items: FilmStripItem[] }) {
|
||||
return (
|
||||
<HorizontalStrip>
|
||||
{items.map((item, i) => (
|
||||
<Link key={`${item.movieId}-${i}`} to="/movies/$id" params={{ id: item.movieId }} className="w-28 flex-shrink-0">
|
||||
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-muted">
|
||||
{item.posterPath ? (
|
||||
<img src={posterUrl(item.posterPath)} alt="" className="size-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<Film className="size-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-xs font-semibold">{item.title}</p>
|
||||
{item.year && <p className="text-[10px] text-muted-foreground">{item.year}</p>}
|
||||
<p className="truncate text-[10px] italic text-muted-foreground">{item.subtitle}</p>
|
||||
</Link>
|
||||
))}
|
||||
</HorizontalStrip>
|
||||
)
|
||||
}
|
||||
|
||||
const BIO_COLLAPSE_THRESHOLD = 300
|
||||
|
||||
function BiographySection({ text }: { text: string }) {
|
||||
const { t } = useTranslation()
|
||||
const isLong = text.length > BIO_COLLAPSE_THRESHOLD
|
||||
const [expanded, setExpanded] = useState(!isLong)
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xs">{t("person.biography")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-sm leading-relaxed text-muted-foreground ${!expanded ? "line-clamp-4" : ""}`}>
|
||||
{text}
|
||||
</p>
|
||||
{isLong && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-auto p-0 text-xs text-primary"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<ChevronDown className={`mr-1 size-3 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
{expanded ? t("common.less") : t("common.more")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function PersonSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="size-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="size-24 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-20 rounded-xl" />
|
||||
<Skeleton className="h-32 rounded-xl" />
|
||||
<Skeleton className="h-1 w-full" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDeleteGoal } from "@/hooks/use-goals"
|
||||
import { GoalCard } from "@/components/goal-card"
|
||||
import { GoalSheet } from "@/components/goal-sheet"
|
||||
import { toast } from "sonner"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { GoalDto } from "@/lib/api/users"
|
||||
|
||||
export const Route = createFileRoute("/_app/profile")({
|
||||
@@ -20,10 +21,13 @@ export const Route = createFileRoute("/_app/profile")({
|
||||
function ProfilePage() {
|
||||
const { t } = useTranslation()
|
||||
const { auth } = useAuth()
|
||||
useDocumentTitle(t("profile.title"))
|
||||
const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
|
||||
view: "trends",
|
||||
})
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
if (!auth) return null
|
||||
if (isPending) return <ProfileSkeleton />
|
||||
if (!data) return null
|
||||
@@ -39,6 +43,8 @@ function ProfilePage() {
|
||||
|
||||
<ProfileView
|
||||
data={data}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
actions={
|
||||
<>
|
||||
<GoalSection goals={data.goals ?? []} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InfiniteScroll } from "@/components/infinite-scroll"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useInfiniteSearch } from "@/hooks/use-search"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import { useAddToWatchlist } from "@/hooks/use-watchlist"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/search")({
|
||||
|
||||
function SearchPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("search.placeholder"))
|
||||
const addToWatchlist = useAddToWatchlist()
|
||||
const [query, setQuery] = useState("")
|
||||
const debouncedQuery = useDebounce(query, 300)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useAddBlockedDomain,
|
||||
useRemoveBlockedDomain,
|
||||
} from "@/hooks/use-social"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/blocked")({
|
||||
component: BlockedPage,
|
||||
@@ -22,6 +23,7 @@ export const Route = createFileRoute("/_app/settings/blocked")({
|
||||
|
||||
function BlockedPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("blocked.title"))
|
||||
const isAdmin = useIsAdmin()
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/edit-profile")({
|
||||
component: EditProfilePage,
|
||||
@@ -17,6 +18,7 @@ export const Route = createFileRoute("/_app/settings/edit-profile")({
|
||||
|
||||
function EditProfilePage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("editProfile.title"))
|
||||
const { data, isPending } = useProfile()
|
||||
const update = useUpdateProfile()
|
||||
const updateFields = useUpdateProfileFields()
|
||||
|
||||
88
spa/src/routes/_app/settings/export.tsx
Normal file
88
spa/src/routes/_app/settings/export.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, Upload } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { API_URL } from "@/lib/api/client"
|
||||
import { getToken } from "@/lib/auth"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/export")({
|
||||
component: ExportPage,
|
||||
})
|
||||
|
||||
function ExportPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("settings.export"))
|
||||
const [exporting, setExporting] = useState<string | null>(null)
|
||||
|
||||
async function handleExport(format: "csv" | "json") {
|
||||
setExporting(format)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/v1/diary/export?format=${format}`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `diary.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} finally {
|
||||
setExporting(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/settings" className="text-muted-foreground">
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">{t("settings.export")}</h1>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl bg-card">
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<span className="text-muted-foreground">
|
||||
<Upload className="size-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{t("settings.exportCsv")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.exportDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("csv")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting === "csv" ? t("settings.exporting") : t("common.run")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<span className="text-muted-foreground">
|
||||
<Upload className="size-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{t("settings.exportJson")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.exportDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("json")}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting === "json" ? t("settings.exporting") : t("common.run")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
import { useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ArrowLeft, CheckCircle, Upload } from "lucide-react"
|
||||
import { ArrowLeft, CheckCircle, Trash2, Upload } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -25,9 +26,14 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
useCreateImportSession,
|
||||
useApplyMapping,
|
||||
useApplyImportProfile,
|
||||
useConfirmImport,
|
||||
useImportPreview,
|
||||
useImportProfiles,
|
||||
useSaveImportProfile,
|
||||
useDeleteImportProfile,
|
||||
} from "@/hooks/use-imports"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/import")({
|
||||
@@ -36,6 +42,7 @@ export const Route = createFileRoute("/_app/settings/import")({
|
||||
|
||||
function ImportPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("import.title"))
|
||||
|
||||
const DOMAIN_FIELDS = [
|
||||
{ value: "title", label: t("import.fieldTitle") },
|
||||
@@ -63,7 +70,10 @@ function ImportPage() {
|
||||
|
||||
const createSession = useCreateImportSession()
|
||||
const applyMapping = useApplyMapping()
|
||||
const applyProfile = useApplyImportProfile()
|
||||
const confirmImport = useConfirmImport()
|
||||
const { data: profiles } = useImportProfiles()
|
||||
const deleteProfile = useDeleteImportProfile()
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
createSession.mutate(file, {
|
||||
@@ -113,6 +123,14 @@ function ImportPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const handleApplyProfile = (profileId: string) => {
|
||||
if (!session) return
|
||||
applyProfile.mutate(
|
||||
{ sessionId: session.session_id, profileId },
|
||||
{ onSuccess: () => setStep(2) },
|
||||
)
|
||||
}
|
||||
|
||||
const hasRatingMapping = Object.values(mappings).includes("rating")
|
||||
|
||||
return (
|
||||
@@ -160,7 +178,7 @@ function ImportPage() {
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
accept=".csv,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
@@ -212,6 +230,40 @@ function ImportPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Presets */}
|
||||
{profiles && profiles.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("import.presets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 justify-start"
|
||||
onClick={() => handleApplyProfile(p.id)}
|
||||
disabled={applyProfile.isPending}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground"
|
||||
onClick={() => deleteProfile.mutate(p.id, {
|
||||
onSuccess: () => toast.success(t("import.presetDeleted")),
|
||||
})}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Column mapping */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -330,6 +382,8 @@ function ConfirmStep({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
||||
const saveProfile = useSaveImportProfile()
|
||||
const [presetName, setPresetName] = useState("")
|
||||
|
||||
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||
|
||||
@@ -385,6 +439,33 @@ function ConfirmStep({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("import.savePreset")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<Input
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder={t("import.presetNamePlaceholder")}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!presetName.trim() || saveProfile.isPending || saveProfile.isSuccess}
|
||||
onClick={() =>
|
||||
saveProfile.mutate(
|
||||
{ session_id: sessionId, name: presetName.trim() },
|
||||
{ onSuccess: () => toast.success(t("import.presetSaved")) },
|
||||
)
|
||||
}
|
||||
>
|
||||
{saveProfile.isSuccess ? t("import.presetSaved") : t("common.save")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
|
||||
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
||||
</Button>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ShieldBan,
|
||||
Sparkles,
|
||||
Target,
|
||||
Upload,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -18,6 +19,7 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
||||
import { reindexSearch } from "@/lib/api/users"
|
||||
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/")({
|
||||
component: SettingsPage,
|
||||
@@ -32,6 +34,7 @@ type SettingsItem = {
|
||||
|
||||
function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("settings.title"))
|
||||
const { logout } = useAuth()
|
||||
const isAdmin = useIsAdmin()
|
||||
const navigate = useNavigate()
|
||||
@@ -50,6 +53,12 @@ function SettingsPage() {
|
||||
label: t("settings.import"),
|
||||
description: t("settings.importDesc"),
|
||||
to: "/settings/import",
|
||||
icon: <Upload className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: t("settings.export"),
|
||||
description: t("settings.exportDesc"),
|
||||
to: "/settings/export",
|
||||
icon: <Download className="size-4" />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useDeleteToken,
|
||||
} from "@/hooks/use-webhooks"
|
||||
import { API_URL } from "@/lib/api/client"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/webhooks")({
|
||||
component: WebhooksPage,
|
||||
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/_app/settings/webhooks")({
|
||||
|
||||
function WebhooksPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("webhooks.title"))
|
||||
const { data: tokens, isPending } = useWebhookTokens()
|
||||
const generate = useGenerateToken()
|
||||
const remove = useDeleteToken()
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useDeleteWrapUp,
|
||||
} from "@/hooks/use-wrapup"
|
||||
import { useUsers } from "@/hooks/use-users"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/settings/wrapup")({
|
||||
component: WrapupPage,
|
||||
@@ -30,6 +31,7 @@ export const Route = createFileRoute("/_app/settings/wrapup")({
|
||||
|
||||
function WrapupPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("wrapup.title"))
|
||||
const { auth } = useAuth()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { data, isPending } = useWrapUps()
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useUserFollowing,
|
||||
useUserFollowers,
|
||||
} from "@/hooks/use-social"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { RemoteActorDto } from "@/lib/api/social"
|
||||
|
||||
type SearchParams = { user?: string }
|
||||
@@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/social")({
|
||||
|
||||
function SocialPage() {
|
||||
const { t } = useTranslation()
|
||||
useDocumentTitle(t("social.title"))
|
||||
const { user: viewUserId } = Route.useSearch()
|
||||
const { auth } = useAuth()
|
||||
const isSelf = !viewUserId || viewUserId === auth?.user_id
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { UserCheck, UserPlus } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
@@ -8,6 +9,7 @@ import { GoalCard } from "@/components/goal-card"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useUserProfile } from "@/hooks/use-users"
|
||||
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
|
||||
export const Route = createFileRoute("/_app/users/$id")({
|
||||
component: UserProfilePage,
|
||||
@@ -22,9 +24,11 @@ function UserProfilePage() {
|
||||
const followMutation = useFollow()
|
||||
const unfollowMutation = useUnfollow()
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
useDocumentTitle(data?.username)
|
||||
|
||||
if (isPending) return <ProfileSkeleton />
|
||||
if (!data) return null
|
||||
|
||||
const isSelf = auth?.user_id === id
|
||||
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
|
||||
|
||||
@@ -35,6 +39,8 @@ function UserProfilePage() {
|
||||
<ProfileView
|
||||
data={data}
|
||||
userId={id}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
actions={
|
||||
data.goals?.length ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -17,6 +17,7 @@ import { RankCard } from "@/components/wrapup-rank-card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import { fmtUsd } from "@/lib/format"
|
||||
import { useWrapUpReport } from "@/hooks/use-wrapup"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import type { MovieRef } from "@/lib/api/wrapup"
|
||||
|
||||
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
|
||||
@@ -39,6 +40,7 @@ function WrapUpReportPage() {
|
||||
const { data: report, isPending } = useWrapUpReport(id)
|
||||
const [showShare, setShowShare] = useState(false)
|
||||
|
||||
useDocumentTitle(report ? `${report.date_range.start.slice(0, 4)} Wrap-Up` : undefined)
|
||||
if (isPending) return <ReportSkeleton />
|
||||
if (!report) return null
|
||||
|
||||
|
||||
Reference in New Issue
Block a user