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")
|
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(86400u64);
|
.unwrap_or(900u64);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
secret,
|
secret,
|
||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use chrono::NaiveDateTime;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
|
models::PersonId,
|
||||||
value_objects::{
|
value_objects::{
|
||||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
||||||
},
|
},
|
||||||
@@ -109,6 +110,10 @@ pub enum EventPayload {
|
|||||||
user_id: String,
|
user_id: String,
|
||||||
year: u16,
|
year: u16,
|
||||||
},
|
},
|
||||||
|
PersonEnrichmentRequested {
|
||||||
|
person_id: String,
|
||||||
|
external_person_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -135,6 +140,7 @@ impl EventPayload {
|
|||||||
EventPayload::GoalCreated { .. } => "GoalCreated",
|
EventPayload::GoalCreated { .. } => "GoalCreated",
|
||||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
||||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
||||||
|
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +317,13 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
user_id: user_id.value().to_string(),
|
user_id: user_id.value().to_string(),
|
||||||
year: *year,
|
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")?),
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
year,
|
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::GoalCreated { .. } => "goal.created",
|
||||||
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
||||||
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
||||||
|
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
|
||||||
};
|
};
|
||||||
format!("{prefix}.{suffix}")
|
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 persons;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod profile_fields;
|
mod profile_fields;
|
||||||
|
mod refresh_sessions;
|
||||||
mod remote_goals;
|
mod remote_goals;
|
||||||
mod user_settings;
|
mod user_settings;
|
||||||
mod users;
|
mod users;
|
||||||
@@ -40,6 +41,7 @@ pub use import_session::PostgresImportSessionRepository;
|
|||||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||||
pub use profile::PostgresMovieProfileRepository;
|
pub use profile::PostgresMovieProfileRepository;
|
||||||
pub use profile_fields::PostgresProfileFieldsRepository;
|
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||||
|
pub use refresh_sessions::PostgresRefreshSessionAdapter;
|
||||||
pub use users::PostgresUserRepository;
|
pub use users::PostgresUserRepository;
|
||||||
pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository};
|
pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository};
|
||||||
pub use watchlist::PostgresWatchlistRepository;
|
pub use watchlist::PostgresWatchlistRepository;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
|
models::{
|
||||||
|
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
|
||||||
|
PersonId,
|
||||||
|
},
|
||||||
ports::{PersonCommand, PersonQuery},
|
ports::{PersonCommand, PersonQuery},
|
||||||
value_objects::MovieId,
|
value_objects::MovieId,
|
||||||
};
|
};
|
||||||
@@ -111,71 +114,61 @@ impl PersonCommand for PostgresPersonAdapter {
|
|||||||
}
|
}
|
||||||
Ok((count, has_more))
|
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]
|
#[async_trait]
|
||||||
impl PersonQuery for PostgresPersonAdapter {
|
impl PersonQuery for PostgresPersonAdapter {
|
||||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
let row = sqlx::query_as::<_, PersonRow>(
|
||||||
struct Row {
|
"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",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
.bind(id.value().to_string())
|
.bind(id.value().to_string())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(map_err)?;
|
||||||
|
|
||||||
Ok(row.map(|r| {
|
Ok(row.map(PersonRow::into_person))
|
||||||
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,
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_by_external_id(
|
async fn get_by_external_id(
|
||||||
&self,
|
&self,
|
||||||
id: &ExternalPersonId,
|
id: &ExternalPersonId,
|
||||||
) -> Result<Option<Person>, DomainError> {
|
) -> Result<Option<Person>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
let row = sqlx::query_as::<_, PersonRow>(
|
||||||
struct Row {
|
"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",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
.bind(id.value())
|
.bind(id.value())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(map_err)?;
|
||||||
|
|
||||||
Ok(row.map(|r| {
|
Ok(row.map(PersonRow::into_person))
|
||||||
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,
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
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> {
|
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
let rows = sqlx::query_as::<_, PersonRow>(
|
||||||
struct Row {
|
"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",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
.bind(limit as i64)
|
.bind(limit as i64)
|
||||||
.bind(offset as i64)
|
.bind(offset as i64)
|
||||||
@@ -279,19 +263,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(map_err)?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||||
.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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||||
@@ -315,3 +287,57 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.collect())
|
.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 persons;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod profile_fields;
|
mod profile_fields;
|
||||||
|
mod refresh_sessions;
|
||||||
mod remote_goals;
|
mod remote_goals;
|
||||||
mod user_settings;
|
mod user_settings;
|
||||||
mod users;
|
mod users;
|
||||||
@@ -41,6 +42,7 @@ pub use import_session::SqliteImportSessionRepository;
|
|||||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||||
pub use profile::SqliteMovieProfileRepository;
|
pub use profile::SqliteMovieProfileRepository;
|
||||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||||
|
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
||||||
pub use users::SqliteUserRepository;
|
pub use users::SqliteUserRepository;
|
||||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
||||||
pub use watchlist::SqliteWatchlistRepository;
|
pub use watchlist::SqliteWatchlistRepository;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
|
models::{
|
||||||
|
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
|
||||||
|
PersonId,
|
||||||
|
},
|
||||||
ports::{PersonCommand, PersonQuery},
|
ports::{PersonCommand, PersonQuery},
|
||||||
value_objects::MovieId,
|
value_objects::MovieId,
|
||||||
};
|
};
|
||||||
@@ -111,13 +114,39 @@ impl PersonCommand for SqlitePersonAdapter {
|
|||||||
}
|
}
|
||||||
Ok((count, has_more))
|
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]
|
#[async_trait]
|
||||||
impl PersonQuery for SqlitePersonAdapter {
|
impl PersonQuery for SqlitePersonAdapter {
|
||||||
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||||
let row = sqlx::query_as::<_, PersonRow>(
|
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())
|
.bind(id.value().to_string())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -132,7 +161,7 @@ impl PersonQuery for SqlitePersonAdapter {
|
|||||||
id: &ExternalPersonId,
|
id: &ExternalPersonId,
|
||||||
) -> Result<Option<Person>, DomainError> {
|
) -> Result<Option<Person>, DomainError> {
|
||||||
let row = sqlx::query_as::<_, PersonRow>(
|
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())
|
.bind(id.value())
|
||||||
.fetch_optional(&self.pool)
|
.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> {
|
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, PersonRow>(
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
@@ -254,17 +283,47 @@ struct PersonRow {
|
|||||||
name: String,
|
name: String,
|
||||||
known_for_department: Option<String>,
|
known_for_department: Option<String>,
|
||||||
profile_path: 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 {
|
impl PersonRow {
|
||||||
fn into_person(self) -> Person {
|
fn into_person(self) -> Person {
|
||||||
let ext = ExternalPersonId::new(self.external_id);
|
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(
|
Person::new(
|
||||||
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
|
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
|
||||||
ext,
|
ext,
|
||||||
self.name,
|
self.name,
|
||||||
self.known_for_department,
|
self.known_for_department,
|
||||||
self.profile_path,
|
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 (
|
"CREATE TABLE persons (
|
||||||
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
|
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
|
||||||
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
|
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)
|
.execute(&pool)
|
||||||
@@ -46,7 +49,7 @@ async fn pool_with_schema() -> SqlitePool {
|
|||||||
|
|
||||||
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
|
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
|
||||||
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
|
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
|
||||||
Person::new(
|
Person::basic(
|
||||||
PersonId::from_external(&ext),
|
PersonId::from_external(&ext),
|
||||||
ext,
|
ext,
|
||||||
name.to_string(),
|
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};
|
pub use client::TmdbEnrichmentClient;
|
||||||
use async_trait::async_trait;
|
pub use movie_handler::MovieEnrichmentHandler;
|
||||||
use chrono::Utc;
|
pub use person_handler::PersonEnrichmentHandler;
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct LoginResponse {
|
pub struct LoginResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub expires_at: String,
|
pub expires_at: String,
|
||||||
@@ -22,3 +23,20 @@ pub struct RegisterRequest {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub password: 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 name: String,
|
||||||
pub known_for_department: Option<String>,
|
pub known_for_department: Option<String>,
|
||||||
pub profile_path: 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)]
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub struct UserProfileQueryParams {
|
|||||||
pub view: Option<String>,
|
pub view: Option<String>,
|
||||||
pub limit: Option<u32>,
|
pub limit: Option<u32>,
|
||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
|
pub search: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use uuid::Uuid;
|
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};
|
use crate::{auth::queries::LoginQuery, context::AppContext};
|
||||||
|
|
||||||
pub struct LoginResult {
|
pub struct LoginResult {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub expires_at: DateTime<Utc>,
|
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 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 {
|
Ok(LoginResult {
|
||||||
token: generated.token,
|
token: generated.token,
|
||||||
|
refresh_token,
|
||||||
user_id: user.id().value(),
|
user_id: user.id().value(),
|
||||||
email: user.email().value().to_string(),
|
email: user.email().value().to_string(),
|
||||||
expires_at: generated.expires_at,
|
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 commands;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod refresh;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod register_and_login;
|
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();
|
.unwrap();
|
||||||
|
|
||||||
assert!(!result.token.is_empty());
|
assert!(!result.token.is_empty());
|
||||||
|
assert!(!result.refresh_token.is_empty());
|
||||||
assert_eq!(result.email, "carol@example.com");
|
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 allow_registration: bool,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub rate_limit: u64,
|
pub rate_limit: u64,
|
||||||
|
pub refresh_ttl_seconds: u64,
|
||||||
pub wrapup: WrapUpConfig,
|
pub wrapup: WrapUpConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +25,15 @@ impl AppConfig {
|
|||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
.unwrap_or(60);
|
.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 {
|
Self {
|
||||||
allow_registration,
|
allow_registration,
|
||||||
base_url,
|
base_url,
|
||||||
rate_limit,
|
rate_limit,
|
||||||
|
refresh_ttl_seconds,
|
||||||
wrapup: WrapUpConfig::from_env(),
|
wrapup: WrapUpConfig::from_env(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ use std::sync::Arc;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
|
||||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
||||||
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
|
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient,
|
||||||
PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
|
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository,
|
||||||
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
|
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
|
||||||
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
|
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||||
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
|
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||||
|
WrapUpStatsQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -38,6 +39,7 @@ pub struct Repositories {
|
|||||||
pub goal: Arc<dyn GoalRepository>,
|
pub goal: Arc<dyn GoalRepository>,
|
||||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||||
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
||||||
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -51,6 +53,7 @@ pub struct Services {
|
|||||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||||
pub document_parser: Arc<dyn DocumentParser>,
|
pub document_parser: Arc<dyn DocumentParser>,
|
||||||
pub review_logger: Arc<dyn ReviewLogger>,
|
pub review_logger: Arc<dyn ReviewLogger>,
|
||||||
|
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[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 async_trait::async_trait;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
|
use domain::{errors::DomainError, ports::PeriodicJob};
|
||||||
|
|
||||||
use crate::context::AppContext;
|
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 {
|
pub struct WrapUpAutoGenerateJob {
|
||||||
ctx: AppContext,
|
ctx: AppContext,
|
||||||
}
|
}
|
||||||
@@ -105,7 +24,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
|||||||
|
|
||||||
async fn run(&self) -> Result<(), DomainError> {
|
async fn run(&self) -> Result<(), DomainError> {
|
||||||
let now = chrono::Utc::now().naive_utc();
|
let now = chrono::Utc::now().naive_utc();
|
||||||
// Only run in January
|
|
||||||
if now.month() != 1 {
|
if now.month() != 1 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -140,7 +58,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global wrap-up
|
|
||||||
let existing = self
|
let existing = self
|
||||||
.ctx
|
.ctx
|
||||||
.repos
|
.repos
|
||||||
@@ -67,7 +67,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
|
|||||||
for member in cast {
|
for member in cast {
|
||||||
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
||||||
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
||||||
Person::new(
|
Person::basic(
|
||||||
PersonId::from_external(&ext),
|
PersonId::from_external(&ext),
|
||||||
ext,
|
ext,
|
||||||
member.name.clone(),
|
member.name.clone(),
|
||||||
@@ -80,7 +80,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
|
|||||||
for member in crew {
|
for member in crew {
|
||||||
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
seen.entry(member.tmdb_person_id).or_insert_with(|| {
|
||||||
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
|
||||||
Person::new(
|
Person::basic(
|
||||||
PersonId::from_external(&ext),
|
PersonId::from_external(&ext),
|
||||||
ext,
|
ext,
|
||||||
member.name.clone(),
|
member.name.clone(),
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ impl domain::ports::PersonCommand for NoopPersonCommand {
|
|||||||
) -> Result<(u64, bool), domain::errors::DomainError> {
|
) -> Result<(u64, bool), domain::errors::DomainError> {
|
||||||
Ok((0, false))
|
Ok((0, false))
|
||||||
}
|
}
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
_: &domain::models::PersonId,
|
||||||
|
_: &domain::models::PersonEnrichmentData,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 crate::context::AppContext;
|
||||||
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{Person, PersonId},
|
models::{Person, PersonId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ENRICHMENT_TTL_DAYS: i64 = 90;
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
use crate::context::AppContext;
|
use crate::context::AppContext;
|
||||||
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
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> {
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
|
pub mod enrich;
|
||||||
pub mod get;
|
pub mod get;
|
||||||
pub mod get_credits;
|
pub mod get_credits;
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ use domain::{
|
|||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
|
||||||
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
|
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
|
||||||
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
PersonQuery, PosterFetcherClient, RefreshSessionRepository, ReviewRepository,
|
||||||
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||||
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
||||||
WrapUpStatsQuery,
|
WrapUpRepository, WrapUpStatsQuery,
|
||||||
},
|
},
|
||||||
testing::{
|
testing::{
|
||||||
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
|
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
|
||||||
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||||
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||||
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
|
||||||
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
|
||||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
|
InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
|
||||||
|
PanicPersonCommand,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ pub struct TestContextBuilder {
|
|||||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||||
pub review_logger: Arc<dyn ReviewLogger>,
|
pub review_logger: Arc<dyn ReviewLogger>,
|
||||||
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
|
||||||
|
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +119,12 @@ impl TestContextBuilder {
|
|||||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||||
review_logger: Arc::new(NoopReviewLogger),
|
review_logger: Arc::new(NoopReviewLogger),
|
||||||
social_query: Arc::new(NoopSocialQueryPort),
|
social_query: Arc::new(NoopSocialQueryPort),
|
||||||
|
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||||
config: AppConfig {
|
config: AppConfig {
|
||||||
allow_registration: true,
|
allow_registration: true,
|
||||||
base_url: "http://localhost:3000".into(),
|
base_url: "http://localhost:3000".into(),
|
||||||
rate_limit: 20,
|
rate_limit: 20,
|
||||||
|
refresh_ttl_seconds: 2_592_000,
|
||||||
wrapup: crate::config::WrapUpConfig {
|
wrapup: crate::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
@@ -286,6 +290,7 @@ impl TestContextBuilder {
|
|||||||
goal: self.goal_repo,
|
goal: self.goal_repo,
|
||||||
user_settings: self.user_settings_repo,
|
user_settings: self.user_settings_repo,
|
||||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||||
|
refresh_session: self.refresh_session_repo,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: self.auth_service,
|
auth: self.auth_service,
|
||||||
@@ -297,6 +302,7 @@ impl TestContextBuilder {
|
|||||||
diary_exporter: self.diary_exporter,
|
diary_exporter: self.diary_exporter,
|
||||||
document_parser: self.document_parser,
|
document_parser: self.document_parser,
|
||||||
review_logger: self.review_logger,
|
review_logger: self.review_logger,
|
||||||
|
person_enrichment: None,
|
||||||
},
|
},
|
||||||
config: self.config,
|
config: self.config,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ impl EventHandler for RecordingHandler {
|
|||||||
DomainEvent::GoalCreated { .. }
|
DomainEvent::GoalCreated { .. }
|
||||||
| DomainEvent::GoalUpdated { .. }
|
| DomainEvent::GoalUpdated { .. }
|
||||||
| DomainEvent::GoalDeleted { .. } => "goal",
|
| DomainEvent::GoalDeleted { .. } => "goal",
|
||||||
|
DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested",
|
||||||
};
|
};
|
||||||
self.calls.lock().unwrap().push(label);
|
self.calls.lock().unwrap().push(label);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use domain::{
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
const DEFAULT_CONCURRENCY: usize = 8;
|
const DEFAULT_CONCURRENCY: usize = 4;
|
||||||
|
|
||||||
pub struct WorkerService {
|
pub struct WorkerService {
|
||||||
consumer: Arc<dyn EventConsumer>,
|
consumer: Arc<dyn EventConsumer>,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
models::PersonId,
|
||||||
value_objects::{
|
value_objects::{
|
||||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
||||||
},
|
},
|
||||||
@@ -43,6 +44,10 @@ pub enum DomainEvent {
|
|||||||
movie_id: MovieId,
|
movie_id: MovieId,
|
||||||
external_metadata_id: String,
|
external_metadata_id: String,
|
||||||
},
|
},
|
||||||
|
PersonEnrichmentRequested {
|
||||||
|
person_id: PersonId,
|
||||||
|
external_person_id: String,
|
||||||
|
},
|
||||||
ImageStored {
|
ImageStored {
|
||||||
key: String,
|
key: String,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod enrichment;
|
mod enrichment;
|
||||||
mod feed;
|
mod feed;
|
||||||
mod movie;
|
mod movie;
|
||||||
|
mod refresh_session;
|
||||||
mod review;
|
mod review;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod user;
|
mod user;
|
||||||
@@ -43,7 +44,10 @@ pub use import::{
|
|||||||
};
|
};
|
||||||
pub use import_profile::ImportProfile;
|
pub use import_profile::ImportProfile;
|
||||||
pub use import_session::ImportSession;
|
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::{
|
pub use search::{
|
||||||
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
|
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
|
||||||
SearchResults,
|
SearchResults,
|
||||||
|
|||||||
@@ -46,15 +46,56 @@ pub struct Person {
|
|||||||
name: String,
|
name: String,
|
||||||
known_for_department: Option<String>,
|
known_for_department: Option<String>,
|
||||||
profile_path: 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 {
|
impl Person {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: PersonId,
|
id: PersonId,
|
||||||
external_id: ExternalPersonId,
|
external_id: ExternalPersonId,
|
||||||
name: String,
|
name: String,
|
||||||
known_for_department: Option<String>,
|
known_for_department: Option<String>,
|
||||||
profile_path: 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 {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -62,6 +103,14 @@ impl Person {
|
|||||||
name,
|
name,
|
||||||
known_for_department,
|
known_for_department,
|
||||||
profile_path,
|
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> {
|
pub fn profile_path(&self) -> Option<&str> {
|
||||||
self.profile_path.as_deref()
|
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)]
|
#[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() {
|
fn person_new() {
|
||||||
let ext = ExternalPersonId::new("tmdb:12345");
|
let ext = ExternalPersonId::new("tmdb:12345");
|
||||||
let pid = PersonId::from_external(&ext);
|
let pid = PersonId::from_external(&ext);
|
||||||
let p = Person::new(
|
let p = Person::basic(
|
||||||
pid,
|
pid,
|
||||||
ext,
|
ext,
|
||||||
"Keanu Reeves".into(),
|
"Keanu Reeves".into(),
|
||||||
@@ -38,7 +38,7 @@ fn person_id_deterministic() {
|
|||||||
fn person_credits_default_empty() {
|
fn person_credits_default_empty() {
|
||||||
let ext = ExternalPersonId::new("tmdb:1");
|
let ext = ExternalPersonId::new("tmdb:1");
|
||||||
let pid = PersonId::from_external(&ext);
|
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 {
|
let credits = PersonCredits {
|
||||||
person: p,
|
person: p,
|
||||||
cast: vec![],
|
cast: vec![],
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ use crate::{
|
|||||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||||
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
|
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
|
||||||
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
|
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
|
||||||
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteGoalEntry,
|
ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
|
||||||
RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, User,
|
RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults,
|
||||||
UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
|
User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
|
||||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||||
collections::{self, PageParams, Paginated},
|
collections::{self, PageParams, Paginated},
|
||||||
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
|
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
|
||||||
@@ -292,6 +292,11 @@ pub trait MovieEnrichmentClient: Send + Sync {
|
|||||||
) -> Result<MovieProfile, DomainError>;
|
) -> Result<MovieProfile, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PersonEnrichmentClient: Send + Sync {
|
||||||
|
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ImportSessionRepository: Send + Sync {
|
pub trait ImportSessionRepository: Send + Sync {
|
||||||
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
|
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 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]
|
#[async_trait]
|
||||||
pub trait ImportProfileRepository: Send + Sync {
|
pub trait ImportProfileRepository: Send + Sync {
|
||||||
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
|
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
|
||||||
@@ -339,6 +353,11 @@ pub trait PersonCommand: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
batch_size: u32,
|
batch_size: u32,
|
||||||
) -> Result<(u64, bool), DomainError>;
|
) -> Result<(u64, bool), DomainError>;
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
id: &PersonId,
|
||||||
|
data: &PersonEnrichmentData,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read port — queries persons and credits. No mutations.
|
/// 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> {
|
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||||
let dummy = Person::new(
|
let dummy = Person::basic(
|
||||||
id.clone(),
|
id.clone(),
|
||||||
ExternalPersonId::new("tmdb:0"),
|
ExternalPersonId::new("tmdb:0"),
|
||||||
"Unknown".into(),
|
"Unknown".into(),
|
||||||
|
|||||||
@@ -6,19 +6,22 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
|
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
|
||||||
ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus,
|
ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent,
|
||||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
|
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
|
||||||
MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository,
|
MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository,
|
||||||
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
|
||||||
|
WebhookTokenRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||||
@@ -777,3 +780,53 @@ impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo {
|
|||||||
Ok(())
|
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::{
|
models::{
|
||||||
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||||
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
||||||
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId,
|
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits,
|
||||||
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
|
PersonEnrichmentData, PersonId, RefreshSession, ReviewHistory, SearchQuery, SearchResults,
|
||||||
|
UserStats, UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
|
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
|
||||||
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
|
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
|
||||||
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
|
PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort,
|
||||||
UserProfileFieldsRepository,
|
StatsRepository, UserProfileFieldsRepository,
|
||||||
},
|
},
|
||||||
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
|
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;
|
pub struct PanicImportProfileRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -150,6 +172,13 @@ impl PersonCommand for PanicPersonCommand {
|
|||||||
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
|
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
|
||||||
panic!("PanicPersonCommand called")
|
panic!("PanicPersonCommand called")
|
||||||
}
|
}
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
_: &PersonId,
|
||||||
|
_: &PersonEnrichmentData,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
panic!("PanicPersonCommand called")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PanicPersonQuery;
|
pub struct PanicPersonQuery;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use std::sync::Arc;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
|
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
|
||||||
PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, WebhookTokenRepository,
|
PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository,
|
||||||
|
WatchEventRepository, WebhookTokenRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub enum DbPool {
|
pub enum DbPool {
|
||||||
@@ -36,6 +37,7 @@ pub struct DatabaseOutput {
|
|||||||
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
||||||
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
||||||
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||||
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
pub db_pool: DbPool,
|
pub db_pool: DbPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +79,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
|
|||||||
goal: w.goal,
|
goal: w.goal,
|
||||||
user_settings: w.user_settings,
|
user_settings: w.user_settings,
|
||||||
remote_goal: w.remote_goal,
|
remote_goal: w.remote_goal,
|
||||||
|
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
|
||||||
|
w.pool.clone(),
|
||||||
|
)) as _,
|
||||||
db_pool: DbPool::Postgres(w.pool),
|
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,
|
goal: w.goal,
|
||||||
user_settings: w.user_settings,
|
user_settings: w.user_settings,
|
||||||
remote_goal: w.remote_goal,
|
remote_goal: w.remote_goal,
|
||||||
|
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||||
|
as _,
|
||||||
db_pool: DbPool::Sqlite(w.pool),
|
db_pool: DbPool::Sqlite(w.pool),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ use crate::{
|
|||||||
render::render_page,
|
render::render_page,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
|
use api_types::{
|
||||||
|
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
|
||||||
|
};
|
||||||
use application::ports::HtmlPageContext;
|
use application::ports::HtmlPageContext;
|
||||||
use template_askama::{LoginTemplate, RegisterTemplate};
|
use template_askama::{LoginTemplate, RegisterTemplate};
|
||||||
|
|
||||||
@@ -68,6 +70,7 @@ pub async fn login(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(LoginResponse {
|
Ok(Json(LoginResponse {
|
||||||
token: result.token,
|
token: result.token,
|
||||||
|
refresh_token: result.refresh_token,
|
||||||
user_id: result.user_id,
|
user_id: result.user_id,
|
||||||
email: result.email,
|
email: result.email,
|
||||||
expires_at: result.expires_at.to_rfc3339(),
|
expires_at: result.expires_at.to_rfc3339(),
|
||||||
@@ -100,6 +103,41 @@ pub async fn register(
|
|||||||
Ok(StatusCode::CREATED)
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub async fn get_login_page(
|
pub async fn get_login_page(
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ use std::collections::HashMap;
|
|||||||
|
|
||||||
use crate::render::render_page;
|
use crate::render::render_page;
|
||||||
use application::import::{
|
use application::import::{
|
||||||
apply_mapping as apply_import_mapping,
|
apply_mapping as apply_import_mapping, apply_profile as apply_import_profile,
|
||||||
commands::{
|
commands::{
|
||||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
|
||||||
ExecuteImportCommand, SaveImportProfileCommand,
|
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
|
||||||
},
|
},
|
||||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||||
execute as execute_import, list_profiles as list_import_profiles,
|
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(),
|
name: person.name().to_string(),
|
||||||
known_for_department: person.known_for_department().map(str::to_string),
|
known_for_department: person.known_for_department().map(str::to_string),
|
||||||
profile_path: person.profile_path().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(),
|
.into_response(),
|
||||||
Ok(None) => StatusCode::NOT_FOUND.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(),
|
name: credits.person.name().to_string(),
|
||||||
known_for_department: credits.person.known_for_department().map(str::to_string),
|
known_for_department: credits.person.known_for_department().map(str::to_string),
|
||||||
profile_path: credits.person.profile_path().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: credits
|
||||||
.cast
|
.cast
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ pub async fn get_user_profile(
|
|||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::ports::FeedSortBy::Date,
|
||||||
search: None,
|
search: params.search,
|
||||||
is_own_profile: viewer_id.value() == user_id,
|
is_own_profile: viewer_id.value() == user_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
goal: db.goal,
|
goal: db.goal,
|
||||||
user_settings: db.user_settings,
|
user_settings: db.user_settings,
|
||||||
remote_goal: db.remote_goal,
|
remote_goal: db.remote_goal,
|
||||||
|
refresh_session: db.refresh_session,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: auth_service,
|
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>,
|
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||||
review_logger,
|
review_logger,
|
||||||
|
person_enrichment: None,
|
||||||
},
|
},
|
||||||
config: app_config,
|
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/login", routing::post(handlers::auth::login))
|
||||||
.route("/auth/register", routing::post(handlers::auth::register))
|
.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("/diary/export", routing::get(handlers::diary::export_diary))
|
||||||
.route(
|
.route(
|
||||||
"/activity-feed",
|
"/activity-feed",
|
||||||
@@ -365,6 +367,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
"/import/profiles/{id}",
|
"/import/profiles/{id}",
|
||||||
routing::delete(handlers::import::api_delete_profile),
|
routing::delete(handlers::import::api_delete_profile),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/import/sessions/{id}/profile/{profile_id}",
|
||||||
|
routing::put(handlers::import::api_apply_profile),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/profile",
|
"/profile",
|
||||||
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
|
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use domain::{
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
|
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
|
||||||
PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats,
|
PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
|
||||||
UserTrends,
|
SearchResults, UserStats, UserTrends,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
@@ -437,6 +437,13 @@ impl PersonCommand for Panic {
|
|||||||
) -> Result<(u64, bool), DomainError> {
|
) -> Result<(u64, bool), DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
_: &PersonId,
|
||||||
|
_: &PersonEnrichmentData,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl PersonQuery for Panic {
|
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]
|
#[async_trait::async_trait]
|
||||||
impl application::ports::ReviewLogger for Panic {
|
impl application::ports::ReviewLogger for Panic {
|
||||||
async fn log_review(
|
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 _,
|
goal: Arc::clone(&repo) as _,
|
||||||
user_settings: Arc::clone(&repo) as _,
|
user_settings: Arc::clone(&repo) as _,
|
||||||
remote_goal: Arc::clone(&repo) as _,
|
remote_goal: Arc::clone(&repo) as _,
|
||||||
|
refresh_session: Arc::clone(&repo) as _,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: auth_service,
|
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 _,
|
diary_exporter: Arc::clone(&repo) as _,
|
||||||
document_parser: Arc::clone(&repo) as _,
|
document_parser: Arc::clone(&repo) as _,
|
||||||
review_logger: Arc::clone(&repo) as _,
|
review_logger: Arc::clone(&repo) as _,
|
||||||
|
person_enrichment: None,
|
||||||
},
|
},
|
||||||
config: AppConfig {
|
config: AppConfig {
|
||||||
allow_registration: false,
|
allow_registration: false,
|
||||||
base_url: "http://localhost:3000".to_string(),
|
base_url: "http://localhost:3000".to_string(),
|
||||||
rate_limit: 20,
|
rate_limit: 20,
|
||||||
|
refresh_ttl_seconds: 2_592_000,
|
||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId,
|
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits,
|
||||||
SearchQuery, SearchResults, User,
|
PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User,
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||||
@@ -314,6 +314,13 @@ impl PersonCommand for PanicPersonCommand {
|
|||||||
) -> Result<(u64, bool), DomainError> {
|
) -> Result<(u64, bool), DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn update_enrichment(
|
||||||
|
&self,
|
||||||
|
_: &PersonId,
|
||||||
|
_: &PersonEnrichmentData,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicPersonQuery;
|
struct PanicPersonQuery;
|
||||||
@@ -451,6 +458,7 @@ async fn test_app() -> Router {
|
|||||||
goal: Arc::new(domain::testing::NoopGoalRepository),
|
goal: Arc::new(domain::testing::NoopGoalRepository),
|
||||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
||||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||||
|
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: Arc::new(PanicAuth),
|
auth: Arc::new(PanicAuth),
|
||||||
@@ -462,11 +470,13 @@ async fn test_app() -> Router {
|
|||||||
diary_exporter: Arc::new(PanicExporter),
|
diary_exporter: Arc::new(PanicExporter),
|
||||||
document_parser: Arc::new(PanicDocumentParser),
|
document_parser: Arc::new(PanicDocumentParser),
|
||||||
review_logger: Arc::new(PanicReviewLogger),
|
review_logger: Arc::new(PanicReviewLogger),
|
||||||
|
person_enrichment: None,
|
||||||
},
|
},
|
||||||
config: AppConfig {
|
config: AppConfig {
|
||||||
allow_registration: false,
|
allow_registration: false,
|
||||||
base_url: "http://localhost:3000".to_string(),
|
base_url: "http://localhost:3000".to_string(),
|
||||||
rate_limit: 20,
|
rate_limit: 20,
|
||||||
|
refresh_ttl_seconds: 2_592_000,
|
||||||
wrapup: application::config::WrapUpConfig {
|
wrapup: application::config::WrapUpConfig {
|
||||||
font_path: None,
|
font_path: None,
|
||||||
logo_path: None,
|
logo_path: None,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ pub struct WorkerDbOutput {
|
|||||||
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
pub goal: Arc<dyn domain::ports::GoalRepository>,
|
||||||
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
||||||
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||||
|
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
|
||||||
pub db_pool: DbPool,
|
pub db_pool: DbPool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +87,9 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
|
|||||||
goal: w.goal,
|
goal: w.goal,
|
||||||
user_settings: w.user_settings,
|
user_settings: w.user_settings,
|
||||||
remote_goal: w.remote_goal,
|
remote_goal: w.remote_goal,
|
||||||
|
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
|
||||||
|
w.pool.clone(),
|
||||||
|
)) as _,
|
||||||
db_pool: DbPool::Postgres(w.pool),
|
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,
|
goal: w.goal,
|
||||||
user_settings: w.user_settings,
|
user_settings: w.user_settings,
|
||||||
remote_goal: w.remote_goal,
|
remote_goal: w.remote_goal,
|
||||||
|
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||||
|
as _,
|
||||||
db_pool: DbPool::Sqlite(w.pool),
|
db_pool: DbPool::Sqlite(w.pool),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use export::ExportAdapter;
|
|||||||
use importer::ImporterDocumentParser;
|
use importer::ImporterDocumentParser;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
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")))]
|
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||||
compile_error!(
|
compile_error!(
|
||||||
@@ -74,7 +77,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Arc::clone(&event_publisher_arc),
|
Arc::clone(&event_publisher_arc),
|
||||||
));
|
));
|
||||||
|
|
||||||
let ctx = AppContext {
|
let mut ctx = AppContext {
|
||||||
repos: Repositories {
|
repos: Repositories {
|
||||||
movie: db.movie,
|
movie: db.movie,
|
||||||
review: db.review,
|
review: db.review,
|
||||||
@@ -105,6 +108,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
goal: db.goal,
|
goal: db.goal,
|
||||||
user_settings: db.user_settings,
|
user_settings: db.user_settings,
|
||||||
remote_goal: db.remote_goal,
|
remote_goal: db.remote_goal,
|
||||||
|
refresh_session: db.refresh_session,
|
||||||
},
|
},
|
||||||
services: Services {
|
services: Services {
|
||||||
auth: auth_service,
|
auth: auth_service,
|
||||||
@@ -116,6 +120,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||||
review_logger,
|
review_logger,
|
||||||
|
person_enrichment: None,
|
||||||
},
|
},
|
||||||
config: app_config,
|
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.
|
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
|
||||||
// Without a key, no MovieEnrichmentRequested events are produced or handled.
|
// Without a key, no MovieEnrichmentRequested events are produced or handled.
|
||||||
|
|
||||||
type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
|
type EnrichmentParts = (
|
||||||
let (enrichment_handler, enrichment_job): OptionalPair =
|
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() {
|
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
tracing::info!("TMDb enrichment enabled");
|
tracing::info!("TMDb enrichment enabled");
|
||||||
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler::new(
|
let client = Arc::new(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),
|
||||||
Arc::clone(&ctx.repos.movie_profile),
|
Arc::clone(&ctx.repos.movie_profile),
|
||||||
Arc::clone(&ctx.repos.person_command),
|
Arc::clone(&ctx.repos.person_command),
|
||||||
Arc::clone(&ctx.repos.search_command),
|
Arc::clone(&ctx.repos.search_command),
|
||||||
Arc::clone(&ctx.services.object_storage),
|
Arc::clone(&ctx.services.object_storage),
|
||||||
)) as Arc<dyn EventHandler>;
|
)) 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()))
|
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
||||||
as Arc<dyn PeriodicJob>;
|
as Arc<dyn PeriodicJob>;
|
||||||
(Some(handler), Some(job))
|
(Some(handler), Some(person_handler), Some(job))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("TMDb enrichment disabled: {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::WatchEventCleanupJob::new(ctx.clone())),
|
||||||
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())),
|
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())),
|
||||||
Arc::new(application::jobs::WrapUpCleanupJob::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 {
|
if let Some(job) = enrichment_job {
|
||||||
periodic_jobs.push(job);
|
periodic_jobs.push(job);
|
||||||
@@ -225,6 +243,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
if let Some(e) = enrichment_handler {
|
if let Some(e) = enrichment_handler {
|
||||||
h.push(e);
|
h.push(e);
|
||||||
}
|
}
|
||||||
|
if let Some(e) = person_enrichment_handler {
|
||||||
|
h.push(e);
|
||||||
|
}
|
||||||
if let Some((ref conv_handler, _)) = conversion {
|
if let Some((ref conv_handler, _)) = conversion {
|
||||||
h.push(Arc::clone(conv_handler));
|
h.push(Arc::clone(conv_handler));
|
||||||
}
|
}
|
||||||
@@ -281,6 +302,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
if let Some(e) = enrichment_handler {
|
if let Some(e) = enrichment_handler {
|
||||||
h.push(e);
|
h.push(e);
|
||||||
}
|
}
|
||||||
|
if let Some(e) = person_enrichment_handler {
|
||||||
|
h.push(e);
|
||||||
|
}
|
||||||
if let Some((ref conv_handler, _)) = conversion {
|
if let Some((ref conv_handler, _)) = conversion {
|
||||||
h.push(Arc::clone(conv_handler));
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
||||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||||
|
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</p>}
|
||||||
</div>
|
</div>
|
||||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -2,16 +2,18 @@ import { Link } from "@tanstack/react-router"
|
|||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Bar, BarChart, XAxis, YAxis } from "recharts"
|
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 { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
import { MovieCard } from "@/components/movie-card"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
import { SwipeTabs } from "@/components/swipe-tabs"
|
import { SwipeTabs } from "@/components/swipe-tabs"
|
||||||
import { VirtualList } from "@/components/virtual-list"
|
import { VirtualList } from "@/components/virtual-list"
|
||||||
import { useInfiniteDiary } from "@/hooks/use-diary"
|
import { useInfiniteDiary } from "@/hooks/use-diary"
|
||||||
|
import { timeAgo } from "@/lib/date"
|
||||||
import type { UserProfileResponse } from "@/lib/api/users"
|
import type { UserProfileResponse } from "@/lib/api/users"
|
||||||
|
|
||||||
type ProfileViewProps = {
|
type ProfileViewProps = {
|
||||||
@@ -19,6 +21,8 @@ type ProfileViewProps = {
|
|||||||
actions?: React.ReactNode
|
actions?: React.ReactNode
|
||||||
headerRight?: React.ReactNode
|
headerRight?: React.ReactNode
|
||||||
userId?: string
|
userId?: string
|
||||||
|
search?: string
|
||||||
|
onSearchChange?: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileView({
|
export function ProfileView({
|
||||||
@@ -26,6 +30,8 @@ export function ProfileView({
|
|||||||
actions,
|
actions,
|
||||||
headerRight,
|
headerRight,
|
||||||
userId,
|
userId,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
}: ProfileViewProps) {
|
}: ProfileViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
|
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
|
||||||
@@ -72,6 +78,18 @@ export function ProfileView({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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}
|
{actions}
|
||||||
|
|
||||||
<SwipeTabs
|
<SwipeTabs
|
||||||
@@ -82,13 +100,14 @@ export function ProfileView({
|
|||||||
{(tab) => (
|
{(tab) => (
|
||||||
<>
|
<>
|
||||||
{tab === "recent" && (
|
{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" && (
|
{tab === "top_rated" && (
|
||||||
<DiaryTab
|
<DiaryTab
|
||||||
key="rating_desc"
|
key="rating_desc"
|
||||||
sortBy="rating_desc"
|
sortBy="rating_desc"
|
||||||
userId={userId}
|
userId={userId}
|
||||||
|
search={search}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tab === "trends" && <TrendsView data={data} />}
|
{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 { t } = useTranslation()
|
||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: sortBy, movie_id: undefined })
|
useInfiniteDiary({ sort_by: sortBy, movie_id: undefined })
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
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])
|
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||||
|
|
||||||
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
|
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 (
|
return (
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={items}
|
items={filtered}
|
||||||
estimateSize={52}
|
estimateSize={52}
|
||||||
hasMore={!!hasNextPage}
|
hasMore={!!hasNextPage}
|
||||||
isFetching={isFetchingNextPage}
|
isFetching={isFetchingNextPage}
|
||||||
@@ -130,7 +154,7 @@ function DiaryTab({ sortBy }: { sortBy: string; userId?: string }) {
|
|||||||
movie={e.movie}
|
movie={e.movie}
|
||||||
rating={e.review.rating}
|
rating={e.review.rating}
|
||||||
comment={e.review.comment}
|
comment={e.review.comment}
|
||||||
subtitle={e.review.watched_at.slice(0, 10)}
|
subtitle={t("profile.watchedAgo", { when: timeAgo(e.review.watched_at) })}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
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 type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
|
||||||
|
import { getRefreshToken } from "@/lib/auth"
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const { login: setAuth } = useAuth()
|
const { login: setAuth } = useAuth()
|
||||||
@@ -11,6 +12,7 @@ export function useLogin() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setAuth({
|
setAuth({
|
||||||
token: res.token,
|
token: res.token,
|
||||||
|
refresh_token: res.refresh_token,
|
||||||
user_id: res.user_id,
|
user_id: res.user_id,
|
||||||
email: res.email,
|
email: res.email,
|
||||||
role: res.role,
|
role: res.role,
|
||||||
@@ -32,6 +34,12 @@ export function useLogout() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
const rt = getRefreshToken()
|
||||||
|
if (rt) {
|
||||||
|
try {
|
||||||
|
await apiLogout(rt)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
logout()
|
logout()
|
||||||
qc.clear()
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
|
applyImportProfile,
|
||||||
applyMapping,
|
applyMapping,
|
||||||
confirmImport,
|
confirmImport,
|
||||||
createImportSession,
|
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 { z } from "zod"
|
||||||
import { post } from "./client"
|
import { API_URL, post } from "./client"
|
||||||
|
|
||||||
export const loginRequestSchema = z.object({
|
export const loginRequestSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
@@ -9,6 +9,7 @@ export type LoginRequest = z.infer<typeof loginRequestSchema>
|
|||||||
|
|
||||||
export const loginResponseSchema = z.object({
|
export const loginResponseSchema = z.object({
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
|
refresh_token: z.string(),
|
||||||
user_id: z.string().uuid(),
|
user_id: z.string().uuid(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
role: z.string(),
|
role: z.string(),
|
||||||
@@ -30,3 +31,25 @@ export function login(data: LoginRequest) {
|
|||||||
export function register(data: RegisterRequest) {
|
export function register(data: RegisterRequest) {
|
||||||
return post("/auth/register", data)
|
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 ?? ""
|
export const API_URL = import.meta.env.VITE_API_URL ?? ""
|
||||||
|
|
||||||
@@ -42,6 +42,31 @@ function buildUrl(
|
|||||||
return qs ? `${base}?${qs}` : base
|
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>(
|
async function request<T = void>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
@@ -55,8 +80,36 @@ async function request<T = void>(
|
|||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
clearAuth()
|
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
|
||||||
window.location.href = "/app/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())
|
throw new ApiError(res.status, await res.text())
|
||||||
}
|
}
|
||||||
@@ -118,3 +171,17 @@ export async function upload<T>(
|
|||||||
body: form,
|
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 { z } from "zod"
|
||||||
import { del, get, post, put, upload } from "./client"
|
import { del, get, post, put, uploadWithFields } from "./client"
|
||||||
|
|
||||||
export const sessionCreatedResponseSchema = z.object({
|
export const sessionCreatedResponseSchema = z.object({
|
||||||
session_id: z.string(),
|
session_id: z.string(),
|
||||||
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
|
|||||||
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
|
||||||
|
|
||||||
export function createImportSession(file: File) {
|
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) {
|
export function getImportSession(id: string) {
|
||||||
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
|
|||||||
return post(`/import/sessions/${sessionId}/confirm`, data)
|
return post(`/import/sessions/${sessionId}/confirm`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImportProfile = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getImportProfiles() {
|
export function getImportProfiles() {
|
||||||
return get<unknown[]>("/import/profiles")
|
return get<ImportProfile[]>("/import/profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveImportProfile(data: SaveProfileRequest) {
|
export function saveImportProfile(data: SaveProfileRequest) {
|
||||||
return post("/import/profiles", data)
|
return post<{ id: string }>("/import/profiles", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteImportProfile(id: string) {
|
export function deleteImportProfile(id: string) {
|
||||||
return del(`/import/profiles/${id}`)
|
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(),
|
name: z.string(),
|
||||||
known_for_department: z.string().optional(),
|
known_for_department: z.string().optional(),
|
||||||
profile_path: 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>
|
export type PersonDto = z.infer<typeof personDtoSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const userProfileQueryParamsSchema = z.object({
|
|||||||
view: z.string().optional(),
|
view: z.string().optional(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
})
|
})
|
||||||
export type UserProfileQueryParams = z.infer<typeof userProfileQueryParamsSchema>
|
export type UserProfileQueryParams = z.infer<typeof userProfileQueryParamsSchema>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const AUTH_KEY = "auth_state"
|
|||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
token: string
|
token: string
|
||||||
|
refresh_token: string
|
||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
email: string
|
||||||
role: string
|
role: string
|
||||||
@@ -30,6 +31,10 @@ export function getToken(): string | null {
|
|||||||
return getAuth()?.token ?? null
|
return getAuth()?.token ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return getAuth()?.refresh_token ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function isAdmin(): boolean {
|
export function isAdmin(): boolean {
|
||||||
return getAuth()?.role === "admin"
|
return getAuth()?.role === "admin"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
"run": "Run",
|
"run": "Run",
|
||||||
"reviews": "{{count}} reviews",
|
"reviews": "{{count}} reviews",
|
||||||
"films": "{{count}} films",
|
"films": "{{count}} films",
|
||||||
"filmsAvg": "{{count}} films, avg {{avg}}"
|
"filmsAvg": "{{count}} films, avg {{avg}}",
|
||||||
|
"more": "Show more",
|
||||||
|
"less": "Show less"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
@@ -122,7 +124,19 @@
|
|||||||
"noEntries": "No entries",
|
"noEntries": "No entries",
|
||||||
"noTrends": "No trends yet",
|
"noTrends": "No trends yet",
|
||||||
"topDirectors": "Top Directors",
|
"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": {
|
"social": {
|
||||||
"title": "Social",
|
"title": "Social",
|
||||||
@@ -143,7 +157,7 @@
|
|||||||
"editProfile": "Edit Profile",
|
"editProfile": "Edit Profile",
|
||||||
"editProfileDesc": "Username, bio",
|
"editProfileDesc": "Username, bio",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"importDesc": "Import from CSV",
|
"importDesc": "Import from CSV or JSON",
|
||||||
"yearWrapUp": "Year Wrap-Up",
|
"yearWrapUp": "Year Wrap-Up",
|
||||||
"yearWrapUpDesc": "Annual summaries",
|
"yearWrapUpDesc": "Annual summaries",
|
||||||
"webhookTokens": "Webhook Tokens",
|
"webhookTokens": "Webhook Tokens",
|
||||||
@@ -163,7 +177,12 @@
|
|||||||
"rebuildSearchDone": "Reindex queued",
|
"rebuildSearchDone": "Reindex queued",
|
||||||
"privacy": "Privacy",
|
"privacy": "Privacy",
|
||||||
"federateGoals": "Share goals on Fediverse",
|
"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": {
|
"goals": {
|
||||||
"yearGoal": "{{year}} Goal",
|
"yearGoal": "{{year}} Goal",
|
||||||
@@ -331,7 +350,7 @@
|
|||||||
"scale1to10": "1–10 → 1–5 (×0.5)",
|
"scale1to10": "1–10 → 1–5 (×0.5)",
|
||||||
"scale1to100": "1–100 → 1–5 (×0.05)",
|
"scale1to100": "1–100 → 1–5 (×0.05)",
|
||||||
"scaleLetterboxd": "0–4 Letterboxd → 1–5 (×1.25)",
|
"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...",
|
"uploading": "Uploading...",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
"rowsCols": "{{rows}} rows · {{cols}} columns",
|
||||||
@@ -351,6 +370,14 @@
|
|||||||
"importing": "Importing...",
|
"importing": "Importing...",
|
||||||
"importRows": "Import {{count}} rows",
|
"importRows": "Import {{count}} rows",
|
||||||
"importComplete": "Import complete!",
|
"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 AppSettingsWrapupRouteImport } from './routes/_app/settings/wrapup'
|
||||||
import { Route as AppSettingsWebhooksRouteImport } from './routes/_app/settings/webhooks'
|
import { Route as AppSettingsWebhooksRouteImport } from './routes/_app/settings/webhooks'
|
||||||
import { Route as AppSettingsImportRouteImport } from './routes/_app/settings/import'
|
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 AppSettingsEditProfileRouteImport } from './routes/_app/settings/edit-profile'
|
||||||
import { Route as AppSettingsBlockedRouteImport } from './routes/_app/settings/blocked'
|
import { Route as AppSettingsBlockedRouteImport } from './routes/_app/settings/blocked'
|
||||||
import { Route as AppPeopleIdRouteImport } from './routes/_app/people.$id'
|
import { Route as AppPeopleIdRouteImport } from './routes/_app/people.$id'
|
||||||
@@ -97,6 +98,11 @@ const AppSettingsImportRoute = AppSettingsImportRouteImport.update({
|
|||||||
path: '/settings/import',
|
path: '/settings/import',
|
||||||
getParentRoute: () => AppRoute,
|
getParentRoute: () => AppRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppSettingsExportRoute = AppSettingsExportRouteImport.update({
|
||||||
|
id: '/settings/export',
|
||||||
|
path: '/settings/export',
|
||||||
|
getParentRoute: () => AppRoute,
|
||||||
|
} as any)
|
||||||
const AppSettingsEditProfileRoute = AppSettingsEditProfileRouteImport.update({
|
const AppSettingsEditProfileRoute = AppSettingsEditProfileRouteImport.update({
|
||||||
id: '/settings/edit-profile',
|
id: '/settings/edit-profile',
|
||||||
path: '/settings/edit-profile',
|
path: '/settings/edit-profile',
|
||||||
@@ -130,6 +136,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/people/$id': typeof AppPeopleIdRoute
|
'/people/$id': typeof AppPeopleIdRoute
|
||||||
'/settings/blocked': typeof AppSettingsBlockedRoute
|
'/settings/blocked': typeof AppSettingsBlockedRoute
|
||||||
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||||
|
'/settings/export': typeof AppSettingsExportRoute
|
||||||
'/settings/import': typeof AppSettingsImportRoute
|
'/settings/import': typeof AppSettingsImportRoute
|
||||||
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||||
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||||
@@ -149,6 +156,7 @@ export interface FileRoutesByTo {
|
|||||||
'/people/$id': typeof AppPeopleIdRoute
|
'/people/$id': typeof AppPeopleIdRoute
|
||||||
'/settings/blocked': typeof AppSettingsBlockedRoute
|
'/settings/blocked': typeof AppSettingsBlockedRoute
|
||||||
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||||
|
'/settings/export': typeof AppSettingsExportRoute
|
||||||
'/settings/import': typeof AppSettingsImportRoute
|
'/settings/import': typeof AppSettingsImportRoute
|
||||||
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
'/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||||
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
'/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||||
@@ -170,6 +178,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/people/$id': typeof AppPeopleIdRoute
|
'/_app/people/$id': typeof AppPeopleIdRoute
|
||||||
'/_app/settings/blocked': typeof AppSettingsBlockedRoute
|
'/_app/settings/blocked': typeof AppSettingsBlockedRoute
|
||||||
'/_app/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
'/_app/settings/edit-profile': typeof AppSettingsEditProfileRoute
|
||||||
|
'/_app/settings/export': typeof AppSettingsExportRoute
|
||||||
'/_app/settings/import': typeof AppSettingsImportRoute
|
'/_app/settings/import': typeof AppSettingsImportRoute
|
||||||
'/_app/settings/webhooks': typeof AppSettingsWebhooksRoute
|
'/_app/settings/webhooks': typeof AppSettingsWebhooksRoute
|
||||||
'/_app/settings/wrapup': typeof AppSettingsWrapupRoute
|
'/_app/settings/wrapup': typeof AppSettingsWrapupRoute
|
||||||
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
|
|||||||
| '/people/$id'
|
| '/people/$id'
|
||||||
| '/settings/blocked'
|
| '/settings/blocked'
|
||||||
| '/settings/edit-profile'
|
| '/settings/edit-profile'
|
||||||
|
| '/settings/export'
|
||||||
| '/settings/import'
|
| '/settings/import'
|
||||||
| '/settings/webhooks'
|
| '/settings/webhooks'
|
||||||
| '/settings/wrapup'
|
| '/settings/wrapup'
|
||||||
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
|
|||||||
| '/people/$id'
|
| '/people/$id'
|
||||||
| '/settings/blocked'
|
| '/settings/blocked'
|
||||||
| '/settings/edit-profile'
|
| '/settings/edit-profile'
|
||||||
|
| '/settings/export'
|
||||||
| '/settings/import'
|
| '/settings/import'
|
||||||
| '/settings/webhooks'
|
| '/settings/webhooks'
|
||||||
| '/settings/wrapup'
|
| '/settings/wrapup'
|
||||||
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/people/$id'
|
| '/_app/people/$id'
|
||||||
| '/_app/settings/blocked'
|
| '/_app/settings/blocked'
|
||||||
| '/_app/settings/edit-profile'
|
| '/_app/settings/edit-profile'
|
||||||
|
| '/_app/settings/export'
|
||||||
| '/_app/settings/import'
|
| '/_app/settings/import'
|
||||||
| '/_app/settings/webhooks'
|
| '/_app/settings/webhooks'
|
||||||
| '/_app/settings/wrapup'
|
| '/_app/settings/wrapup'
|
||||||
@@ -344,6 +356,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppSettingsImportRouteImport
|
preLoaderRoute: typeof AppSettingsImportRouteImport
|
||||||
parentRoute: typeof AppRoute
|
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': {
|
'/_app/settings/edit-profile': {
|
||||||
id: '/_app/settings/edit-profile'
|
id: '/_app/settings/edit-profile'
|
||||||
path: '/settings/edit-profile'
|
path: '/settings/edit-profile'
|
||||||
@@ -385,6 +404,7 @@ interface AppRouteChildren {
|
|||||||
AppPeopleIdRoute: typeof AppPeopleIdRoute
|
AppPeopleIdRoute: typeof AppPeopleIdRoute
|
||||||
AppSettingsBlockedRoute: typeof AppSettingsBlockedRoute
|
AppSettingsBlockedRoute: typeof AppSettingsBlockedRoute
|
||||||
AppSettingsEditProfileRoute: typeof AppSettingsEditProfileRoute
|
AppSettingsEditProfileRoute: typeof AppSettingsEditProfileRoute
|
||||||
|
AppSettingsExportRoute: typeof AppSettingsExportRoute
|
||||||
AppSettingsImportRoute: typeof AppSettingsImportRoute
|
AppSettingsImportRoute: typeof AppSettingsImportRoute
|
||||||
AppSettingsWebhooksRoute: typeof AppSettingsWebhooksRoute
|
AppSettingsWebhooksRoute: typeof AppSettingsWebhooksRoute
|
||||||
AppSettingsWrapupRoute: typeof AppSettingsWrapupRoute
|
AppSettingsWrapupRoute: typeof AppSettingsWrapupRoute
|
||||||
@@ -403,6 +423,7 @@ const AppRouteChildren: AppRouteChildren = {
|
|||||||
AppPeopleIdRoute: AppPeopleIdRoute,
|
AppPeopleIdRoute: AppPeopleIdRoute,
|
||||||
AppSettingsBlockedRoute: AppSettingsBlockedRoute,
|
AppSettingsBlockedRoute: AppSettingsBlockedRoute,
|
||||||
AppSettingsEditProfileRoute: AppSettingsEditProfileRoute,
|
AppSettingsEditProfileRoute: AppSettingsEditProfileRoute,
|
||||||
|
AppSettingsExportRoute: AppSettingsExportRoute,
|
||||||
AppSettingsImportRoute: AppSettingsImportRoute,
|
AppSettingsImportRoute: AppSettingsImportRoute,
|
||||||
AppSettingsWebhooksRoute: AppSettingsWebhooksRoute,
|
AppSettingsWebhooksRoute: AppSettingsWebhooksRoute,
|
||||||
AppSettingsWrapupRoute: AppSettingsWrapupRoute,
|
AppSettingsWrapupRoute: AppSettingsWrapupRoute,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { format, startOfMonth, subMonths } from "date-fns"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
import { MovieCard } from "@/components/movie-card"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
@@ -10,8 +10,7 @@ import { VirtualList } from "@/components/virtual-list"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
|
||||||
import { API_URL } from "@/lib/api/client"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import { getToken } from "@/lib/auth"
|
|
||||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/diary")({
|
export const Route = createFileRoute("/_app/diary")({
|
||||||
@@ -29,6 +28,7 @@ function groupByDate(items: DiaryEntryDto[]) {
|
|||||||
|
|
||||||
function DiaryPage() {
|
function DiaryPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("diary.title"))
|
||||||
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: "desc" })
|
useInfiniteDiary({ sort_by: "desc" })
|
||||||
@@ -75,28 +75,7 @@ function DiaryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-xl bg-card px-3 py-2">
|
<div className="flex items-center justify-between rounded-xl bg-card px-3 py-2">
|
||||||
<Button variant="ghost" size="icon" onClick={goBack} disabled={!canGoBack}>
|
<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 { StarDisplay } from "@/components/star-display"
|
||||||
import { RatingHistogram } from "@/components/rating-histogram"
|
import { RatingHistogram } from "@/components/rating-histogram"
|
||||||
import { EmptyState } from "@/components/empty-state"
|
import { EmptyState } from "@/components/empty-state"
|
||||||
|
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
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 { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||||
import { timeAgo, shortDate } from "@/lib/date"
|
import { timeAgo, shortDate } from "@/lib/date"
|
||||||
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
|
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import {
|
import {
|
||||||
useWatchlistStatus,
|
useWatchlistStatus,
|
||||||
useAddToWatchlist,
|
useAddToWatchlist,
|
||||||
@@ -29,6 +31,7 @@ function MovieDetailPage() {
|
|||||||
const { data, isPending } = useMovie(id)
|
const { data, isPending } = useMovie(id)
|
||||||
const { data: profile } = useMovieProfile(id)
|
const { data: profile } = useMovieProfile(id)
|
||||||
const { data: history } = useMovieHistory(id)
|
const { data: history } = useMovieHistory(id)
|
||||||
|
useDocumentTitle(data?.movie.title)
|
||||||
|
|
||||||
if (isPending) return <DetailSkeleton />
|
if (isPending) return <DetailSkeleton />
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
@@ -229,7 +232,7 @@ function HeroSection({
|
|||||||
|
|
||||||
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
|
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
|
||||||
return (
|
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) => {
|
{items.map((person, i) => {
|
||||||
const subtitle = type === "cast"
|
const subtitle = type === "cast"
|
||||||
? (person as CastMemberDto).character
|
? (person as CastMemberDto).character
|
||||||
@@ -251,7 +254,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</HorizontalStrip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { BackButton } from "@/components/back-button"
|
||||||
import { MovieCard } from "@/components/movie-card"
|
|
||||||
import { EmptyState } from "@/components/empty-state"
|
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 { 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 { 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")({
|
export const Route = createFileRoute("/_app/people/$id")({
|
||||||
component: PersonDetailPage,
|
component: PersonDetailPage,
|
||||||
@@ -16,100 +26,216 @@ function PersonDetailPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams()
|
||||||
const { data, isPending } = usePersonCredits(id)
|
const { data, isPending } = usePersonCredits(id)
|
||||||
|
useDocumentTitle(data?.person.name)
|
||||||
|
|
||||||
if (isPending) return <PersonSkeleton />
|
if (isPending) return <PersonSkeleton />
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const { person, cast, crew } = data
|
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 (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{/* Header */}
|
||||||
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
|
<div className="flex gap-4">
|
||||||
|
<div className="size-24 flex-shrink-0 overflow-hidden rounded-xl bg-muted">
|
||||||
{person.profile_path ? (
|
{person.profile_path ? (
|
||||||
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
|
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center">
|
<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>
|
||||||
<div>
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<h1 className="text-xl font-bold">{person.name}</h1>
|
<h1 className="text-xl font-bold">{person.name}</h1>
|
||||||
{person.known_for_department && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cast.length > 0 && (
|
{/* Details card */}
|
||||||
<section>
|
{(person.birthday || person.place_of_birth) && (
|
||||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<Card size="sm">
|
||||||
{t("movie.castCredits", { count: cast.length })}
|
<CardContent className="space-y-2">
|
||||||
</h2>
|
{person.birthday && (
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{cast.map((c) => (
|
<Calendar className="size-3.5 text-muted-foreground" />
|
||||||
<MovieCard
|
<span>{shortDate(person.birthday)}</span>
|
||||||
key={`${c.movie_id}-${c.character}`}
|
{age != null && (
|
||||||
movie={{
|
<span className="text-muted-foreground">({age})</span>
|
||||||
id: c.movie_id,
|
)}
|
||||||
title: c.title,
|
</div>
|
||||||
release_year: c.release_year ?? 0,
|
)}
|
||||||
poster_path: c.poster_path,
|
{person.deathday && (
|
||||||
genres: [],
|
<div className="flex items-center gap-2 text-sm">
|
||||||
}}
|
<Calendar className="size-3.5 text-muted-foreground" />
|
||||||
subtitle={c.character}
|
<span>{shortDate(person.deathday)}</span>
|
||||||
variant="compact"
|
<span className="text-muted-foreground">({t("person.deathday").toLowerCase()})</span>
|
||||||
/>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
{person.place_of_birth && (
|
||||||
</section>
|
<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 && (
|
{/* Biography */}
|
||||||
<section>
|
{person.biography && <BiographySection text={person.biography} />}
|
||||||
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
||||||
{t("movie.crewCredits", { count: crew.length })}
|
{/* Also known as */}
|
||||||
</h2>
|
{person.also_known_as?.length > 0 && (
|
||||||
<div className="space-y-1">
|
<Card size="sm">
|
||||||
{crew.map((c) => (
|
<CardHeader>
|
||||||
<MovieCard
|
<CardTitle className="text-xs">{t("person.alsoKnownAs")}</CardTitle>
|
||||||
key={`${c.movie_id}-${c.job}`}
|
</CardHeader>
|
||||||
movie={{
|
<CardContent>
|
||||||
id: c.movie_id,
|
<div className="flex flex-wrap gap-1">
|
||||||
title: c.title,
|
{person.also_known_as.map((name) => (
|
||||||
release_year: c.release_year ?? 0,
|
<Badge key={name} variant="outline" className="text-xs font-normal">
|
||||||
poster_path: c.poster_path,
|
{name}
|
||||||
genres: [],
|
</Badge>
|
||||||
}}
|
))}
|
||||||
subtitle={`${c.job} (${c.department})`}
|
</div>
|
||||||
variant="compact"
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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")} />
|
<EmptyState icon={Film} title={t("movie.noCredits")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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() {
|
function PersonSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
<Skeleton className="h-5 w-16" />
|
<Skeleton className="h-5 w-16" />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex gap-4">
|
||||||
<Skeleton className="size-16 rounded-full" />
|
<Skeleton className="size-24 rounded-xl" />
|
||||||
<div className="space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-6 w-32" />
|
<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>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Skeleton key={i} className="h-10 rounded-lg" />
|
<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 { GoalCard } from "@/components/goal-card"
|
||||||
import { GoalSheet } from "@/components/goal-sheet"
|
import { GoalSheet } from "@/components/goal-sheet"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { GoalDto } from "@/lib/api/users"
|
import type { GoalDto } from "@/lib/api/users"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/profile")({
|
export const Route = createFileRoute("/_app/profile")({
|
||||||
@@ -20,10 +21,13 @@ export const Route = createFileRoute("/_app/profile")({
|
|||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { auth } = useAuth()
|
const { auth } = useAuth()
|
||||||
|
useDocumentTitle(t("profile.title"))
|
||||||
const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
|
const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
|
||||||
view: "trends",
|
view: "trends",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
if (!auth) return null
|
if (!auth) return null
|
||||||
if (isPending) return <ProfileSkeleton />
|
if (isPending) return <ProfileSkeleton />
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
@@ -39,6 +43,8 @@ function ProfilePage() {
|
|||||||
|
|
||||||
<ProfileView
|
<ProfileView
|
||||||
data={data}
|
data={data}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<GoalSection goals={data.goals ?? []} />
|
<GoalSection goals={data.goals ?? []} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { InfiniteScroll } from "@/components/infinite-scroll"
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useInfiniteSearch } from "@/hooks/use-search"
|
import { useInfiniteSearch } from "@/hooks/use-search"
|
||||||
import { useDebounce } from "@/hooks/use-debounce"
|
import { useDebounce } from "@/hooks/use-debounce"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import { useAddToWatchlist } from "@/hooks/use-watchlist"
|
import { useAddToWatchlist } from "@/hooks/use-watchlist"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/search")({
|
|||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("search.placeholder"))
|
||||||
const addToWatchlist = useAddToWatchlist()
|
const addToWatchlist = useAddToWatchlist()
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const debouncedQuery = useDebounce(query, 300)
|
const debouncedQuery = useDebounce(query, 300)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
useAddBlockedDomain,
|
useAddBlockedDomain,
|
||||||
useRemoveBlockedDomain,
|
useRemoveBlockedDomain,
|
||||||
} from "@/hooks/use-social"
|
} from "@/hooks/use-social"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/blocked")({
|
export const Route = createFileRoute("/_app/settings/blocked")({
|
||||||
component: BlockedPage,
|
component: BlockedPage,
|
||||||
@@ -22,6 +23,7 @@ export const Route = createFileRoute("/_app/settings/blocked")({
|
|||||||
|
|
||||||
function BlockedPage() {
|
function BlockedPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("blocked.title"))
|
||||||
const isAdmin = useIsAdmin()
|
const isAdmin = useIsAdmin()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
|
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/edit-profile")({
|
export const Route = createFileRoute("/_app/settings/edit-profile")({
|
||||||
component: EditProfilePage,
|
component: EditProfilePage,
|
||||||
@@ -17,6 +18,7 @@ export const Route = createFileRoute("/_app/settings/edit-profile")({
|
|||||||
|
|
||||||
function EditProfilePage() {
|
function EditProfilePage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("editProfile.title"))
|
||||||
const { data, isPending } = useProfile()
|
const { data, isPending } = useProfile()
|
||||||
const update = useUpdateProfile()
|
const update = useUpdateProfile()
|
||||||
const updateFields = useUpdateProfileFields()
|
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 { createFileRoute, Link } from "@tanstack/react-router"
|
||||||
import { useRef, useState } from "react"
|
import { useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -25,9 +26,14 @@ import { Skeleton } from "@/components/ui/skeleton"
|
|||||||
import {
|
import {
|
||||||
useCreateImportSession,
|
useCreateImportSession,
|
||||||
useApplyMapping,
|
useApplyMapping,
|
||||||
|
useApplyImportProfile,
|
||||||
useConfirmImport,
|
useConfirmImport,
|
||||||
useImportPreview,
|
useImportPreview,
|
||||||
|
useImportProfiles,
|
||||||
|
useSaveImportProfile,
|
||||||
|
useDeleteImportProfile,
|
||||||
} from "@/hooks/use-imports"
|
} from "@/hooks/use-imports"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
import type { SessionCreatedResponse } from "@/lib/api/imports"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/import")({
|
export const Route = createFileRoute("/_app/settings/import")({
|
||||||
@@ -36,6 +42,7 @@ export const Route = createFileRoute("/_app/settings/import")({
|
|||||||
|
|
||||||
function ImportPage() {
|
function ImportPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("import.title"))
|
||||||
|
|
||||||
const DOMAIN_FIELDS = [
|
const DOMAIN_FIELDS = [
|
||||||
{ value: "title", label: t("import.fieldTitle") },
|
{ value: "title", label: t("import.fieldTitle") },
|
||||||
@@ -63,7 +70,10 @@ function ImportPage() {
|
|||||||
|
|
||||||
const createSession = useCreateImportSession()
|
const createSession = useCreateImportSession()
|
||||||
const applyMapping = useApplyMapping()
|
const applyMapping = useApplyMapping()
|
||||||
|
const applyProfile = useApplyImportProfile()
|
||||||
const confirmImport = useConfirmImport()
|
const confirmImport = useConfirmImport()
|
||||||
|
const { data: profiles } = useImportProfiles()
|
||||||
|
const deleteProfile = useDeleteImportProfile()
|
||||||
|
|
||||||
const handleFile = (file: File) => {
|
const handleFile = (file: File) => {
|
||||||
createSession.mutate(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")
|
const hasRatingMapping = Object.values(mappings).includes("rating")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -160,7 +178,7 @@ function ImportPage() {
|
|||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv,.json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -212,6 +230,40 @@ function ImportPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Column mapping */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -330,6 +382,8 @@ function ConfirmStep({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
const { data, isPending: previewLoading } = useImportPreview(sessionId)
|
||||||
|
const saveProfile = useSaveImportProfile()
|
||||||
|
const [presetName, setPresetName] = useState("")
|
||||||
|
|
||||||
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
|
||||||
@@ -385,6 +439,33 @@ function ConfirmStep({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
|
||||||
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ShieldBan,
|
ShieldBan,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Upload,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -18,6 +19,7 @@ import { Switch } from "@/components/ui/switch"
|
|||||||
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
import { useAuth, useIsAdmin } from "@/components/auth-provider"
|
||||||
import { reindexSearch } from "@/lib/api/users"
|
import { reindexSearch } from "@/lib/api/users"
|
||||||
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
|
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/")({
|
export const Route = createFileRoute("/_app/settings/")({
|
||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
@@ -32,6 +34,7 @@ type SettingsItem = {
|
|||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("settings.title"))
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const isAdmin = useIsAdmin()
|
const isAdmin = useIsAdmin()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -50,6 +53,12 @@ function SettingsPage() {
|
|||||||
label: t("settings.import"),
|
label: t("settings.import"),
|
||||||
description: t("settings.importDesc"),
|
description: t("settings.importDesc"),
|
||||||
to: "/settings/import",
|
to: "/settings/import",
|
||||||
|
icon: <Upload className="size-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("settings.export"),
|
||||||
|
description: t("settings.exportDesc"),
|
||||||
|
to: "/settings/export",
|
||||||
icon: <Download className="size-4" />,
|
icon: <Download className="size-4" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
useDeleteToken,
|
useDeleteToken,
|
||||||
} from "@/hooks/use-webhooks"
|
} from "@/hooks/use-webhooks"
|
||||||
import { API_URL } from "@/lib/api/client"
|
import { API_URL } from "@/lib/api/client"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/webhooks")({
|
export const Route = createFileRoute("/_app/settings/webhooks")({
|
||||||
component: WebhooksPage,
|
component: WebhooksPage,
|
||||||
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/_app/settings/webhooks")({
|
|||||||
|
|
||||||
function WebhooksPage() {
|
function WebhooksPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("webhooks.title"))
|
||||||
const { data: tokens, isPending } = useWebhookTokens()
|
const { data: tokens, isPending } = useWebhookTokens()
|
||||||
const generate = useGenerateToken()
|
const generate = useGenerateToken()
|
||||||
const remove = useDeleteToken()
|
const remove = useDeleteToken()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useDeleteWrapUp,
|
useDeleteWrapUp,
|
||||||
} from "@/hooks/use-wrapup"
|
} from "@/hooks/use-wrapup"
|
||||||
import { useUsers } from "@/hooks/use-users"
|
import { useUsers } from "@/hooks/use-users"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/settings/wrapup")({
|
export const Route = createFileRoute("/_app/settings/wrapup")({
|
||||||
component: WrapupPage,
|
component: WrapupPage,
|
||||||
@@ -30,6 +31,7 @@ export const Route = createFileRoute("/_app/settings/wrapup")({
|
|||||||
|
|
||||||
function WrapupPage() {
|
function WrapupPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("wrapup.title"))
|
||||||
const { auth } = useAuth()
|
const { auth } = useAuth()
|
||||||
const isAdmin = useIsAdmin()
|
const isAdmin = useIsAdmin()
|
||||||
const { data, isPending } = useWrapUps()
|
const { data, isPending } = useWrapUps()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useUserFollowing,
|
useUserFollowing,
|
||||||
useUserFollowers,
|
useUserFollowers,
|
||||||
} from "@/hooks/use-social"
|
} from "@/hooks/use-social"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { RemoteActorDto } from "@/lib/api/social"
|
import type { RemoteActorDto } from "@/lib/api/social"
|
||||||
|
|
||||||
type SearchParams = { user?: string }
|
type SearchParams = { user?: string }
|
||||||
@@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/social")({
|
|||||||
|
|
||||||
function SocialPage() {
|
function SocialPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
useDocumentTitle(t("social.title"))
|
||||||
const { user: viewUserId } = Route.useSearch()
|
const { user: viewUserId } = Route.useSearch()
|
||||||
const { auth } = useAuth()
|
const { auth } = useAuth()
|
||||||
const isSelf = !viewUserId || viewUserId === auth?.user_id
|
const isSelf = !viewUserId || viewUserId === auth?.user_id
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { UserCheck, UserPlus } from "lucide-react"
|
import { UserCheck, UserPlus } from "lucide-react"
|
||||||
import { BackButton } from "@/components/back-button"
|
import { BackButton } from "@/components/back-button"
|
||||||
@@ -8,6 +9,7 @@ import { GoalCard } from "@/components/goal-card"
|
|||||||
import { useAuth } from "@/components/auth-provider"
|
import { useAuth } from "@/components/auth-provider"
|
||||||
import { useUserProfile } from "@/hooks/use-users"
|
import { useUserProfile } from "@/hooks/use-users"
|
||||||
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
|
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app/users/$id")({
|
export const Route = createFileRoute("/_app/users/$id")({
|
||||||
component: UserProfilePage,
|
component: UserProfilePage,
|
||||||
@@ -22,9 +24,11 @@ function UserProfilePage() {
|
|||||||
const followMutation = useFollow()
|
const followMutation = useFollow()
|
||||||
const unfollowMutation = useUnfollow()
|
const unfollowMutation = useUnfollow()
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
useDocumentTitle(data?.username)
|
||||||
|
|
||||||
if (isPending) return <ProfileSkeleton />
|
if (isPending) return <ProfileSkeleton />
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
|
|
||||||
const isSelf = auth?.user_id === id
|
const isSelf = auth?.user_id === id
|
||||||
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
|
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
|
||||||
|
|
||||||
@@ -35,6 +39,8 @@ function UserProfilePage() {
|
|||||||
<ProfileView
|
<ProfileView
|
||||||
data={data}
|
data={data}
|
||||||
userId={id}
|
userId={id}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
actions={
|
actions={
|
||||||
data.goals?.length ? (
|
data.goals?.length ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RankCard } from "@/components/wrapup-rank-card"
|
|||||||
import { posterUrl } from "@/lib/api/client"
|
import { posterUrl } from "@/lib/api/client"
|
||||||
import { fmtUsd } from "@/lib/format"
|
import { fmtUsd } from "@/lib/format"
|
||||||
import { useWrapUpReport } from "@/hooks/use-wrapup"
|
import { useWrapUpReport } from "@/hooks/use-wrapup"
|
||||||
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import type { MovieRef } from "@/lib/api/wrapup"
|
import type { MovieRef } from "@/lib/api/wrapup"
|
||||||
|
|
||||||
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
|
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 { data: report, isPending } = useWrapUpReport(id)
|
||||||
const [showShare, setShowShare] = useState(false)
|
const [showShare, setShowShare] = useState(false)
|
||||||
|
|
||||||
|
useDocumentTitle(report ? `${report.date_range.start.slice(0, 4)} Wrap-Up` : undefined)
|
||||||
if (isPending) return <ReportSkeleton />
|
if (isPending) return <ReportSkeleton />
|
||||||
if (!report) return null
|
if (!report) return null
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user