Compare commits

...

40 Commits

Author SHA1 Message Date
20ac0d3adf fmt
All checks were successful
CI / Check / Test (push) Successful in 38m10s
2026-06-11 14:44:53 +02:00
8ac87a3735 refactor: split jobs.rs into per-context modules 2026-06-11 14:44:23 +02:00
4f0f44dec3 test: refresh token rotation, logout revocation, login refresh token 2026-06-11 14:42:39 +02:00
96c753c2c6 spa: silent refresh on 401, persistent login 2026-06-11 14:39:10 +02:00
822f3f9d9c api: refresh + logout endpoints, update login response 2026-06-11 14:37:09 +02:00
55feaa353f app: refresh/logout use cases, update login with refresh token 2026-06-11 14:35:53 +02:00
3a3f3b3889 db: refresh_sessions migration + SQLite/Postgres adapters 2026-06-11 14:31:46 +02:00
ef9ecbae06 domain: add RefreshSession model + repository port 2026-06-11 14:29:43 +02:00
db285b513b fix: swap import/export icons 2026-06-11 14:13:51 +02:00
2617c77b42 feat: add arrow navigation to horizontal scroll strips 2026-06-11 14:12:42 +02:00
e618e1aa84 feat: horizontal poster strip for person filmography 2026-06-11 14:10:20 +02:00
7dc372a7b6 feat: redesign person page with Cards, SwipeTabs, collapsible bio 2026-06-11 14:07:27 +02:00
e39fcf6802 feat: show person age calculated from birthday 2026-06-11 14:04:52 +02:00
b09ef4686a fmt 2026-06-11 14:03:05 +02:00
2074a2244e refactor: move person enrichment staleness check to application layer 2026-06-11 14:01:44 +02:00
338ecb71c8 perf: reduce worker concurrency to 4 to ease SQLite write contention 2026-06-11 13:55:48 +02:00
8cb90b256c fix: guard also_known_as access when undefined 2026-06-11 13:52:44 +02:00
c05087a6c7 fmt 2026-06-11 13:51:33 +02:00
0fdc79af23 fix: add enrichment columns to test schema 2026-06-11 13:51:14 +02:00
262ba5ca39 fix: collapse nested if per clippy 2026-06-11 13:48:08 +02:00
57c720b22f fix: collapse nested if per clippy 2026-06-11 13:47:32 +02:00
53b7f730cb refactor: drop EnrichmentHandler alias, use MovieEnrichmentHandler directly 2026-06-11 13:46:30 +02:00
e8fa24bf9b refactor: split tmdb-enrichment into client, movie_handler, person_handler 2026-06-11 13:46:00 +02:00
7437ed89ad fix: handle PersonEnrichmentRequested in test fakes 2026-06-11 13:43:37 +02:00
b2a41db290 feat: rich person detail page with bio, dates, links 2026-06-11 13:42:04 +02:00
9b932cde8e feat: wire person enrichment handler, update API DTOs 2026-06-11 13:40:16 +02:00
a68e19aad7 feat: TMDB person enrichment client + event handler 2026-06-11 13:38:07 +02:00
371a3cdc46 app: person enrichment use case + staleness checks 2026-06-11 13:36:43 +02:00
517a18da8a db: person enrichment migrations + adapter updates 2026-06-11 13:34:10 +02:00
7df24a19ee domain: add person enrichment fields, event, port 2026-06-11 13:30:19 +02:00
549923b92e feat: show relative watched date and comment preview in profile entries 2026-06-11 13:03:49 +02:00
3cbb406ea7 feat: add export route to settings in route tree 2026-06-11 13:01:12 +02:00
21cc6ed437 fix: move useDocumentTitle before early returns (Rules of Hooks) 2026-06-11 12:59:13 +02:00
5dc90724d3 feat: JSON import + mapping presets in SPA
- Accept .json files in import upload, send format to backend
- Backend endpoint PUT /import/sessions/{id}/profile/{profile_id}
- Load saved presets on mapping step, auto-apply and skip to preview
- Save current mapping as preset on confirm step
- Delete presets from mapping step
2026-06-11 12:58:08 +02:00
9a894c3a95 refactor: move export to its own settings page under Data group 2026-06-11 12:46:46 +02:00
acc20d2f43 feat: dynamic page titles across SPA
useDocumentTitle hook sets document.title per page.
Dynamic: movie name, person name, username, wrapup year.
Static: diary, profile, search, social, all settings pages.
2026-06-11 12:45:01 +02:00
a95be0b131 feat: add search input on user profile pages 2026-06-11 12:39:14 +02:00
f10b114e83 feat: expose search param on user profile API endpoint 2026-06-11 12:37:20 +02:00
c020135cd1 feat: move diary export to settings page with CSV/JSON options 2026-06-11 12:36:03 +02:00
ad55897871 i18n: add export and profile search translations 2026-06-11 12:34:29 +02:00
94 changed files with 2568 additions and 630 deletions

View File

@@ -30,7 +30,7 @@ impl AuthConfig {
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(86400u64);
.unwrap_or(900u64);
Ok(Self {
secret,
ttl_seconds,

View File

@@ -2,6 +2,7 @@ use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
events::DomainEvent,
models::PersonId,
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
@@ -109,6 +110,10 @@ pub enum EventPayload {
user_id: String,
year: u16,
},
PersonEnrichmentRequested {
person_id: String,
external_person_id: String,
},
}
impl EventPayload {
@@ -135,6 +140,7 @@ impl EventPayload {
EventPayload::GoalCreated { .. } => "GoalCreated",
EventPayload::GoalUpdated { .. } => "GoalUpdated",
EventPayload::GoalDeleted { .. } => "GoalDeleted",
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
}
}
}
@@ -311,6 +317,13 @@ impl From<&DomainEvent> for EventPayload {
user_id: user_id.value().to_string(),
year: *year,
},
DomainEvent::PersonEnrichmentRequested {
person_id,
external_person_id,
} => EventPayload::PersonEnrichmentRequested {
person_id: person_id.value().to_string(),
external_person_id: external_person_id.clone(),
},
}
}
}
@@ -496,6 +509,13 @@ impl TryFrom<EventPayload> for DomainEvent {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
}),
EventPayload::PersonEnrichmentRequested {
person_id,
external_person_id,
} => Ok(DomainEvent::PersonEnrichmentRequested {
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
external_person_id,
}),
}
}
}

View File

@@ -23,6 +23,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::GoalCreated { .. } => "goal.created",
DomainEvent::GoalUpdated { .. } => "goal.updated",
DomainEvent::GoalDeleted { .. } => "goal.deleted",
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
};
format!("{prefix}.{suffix}")
}

View File

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

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

View File

@@ -21,6 +21,7 @@ mod models;
mod persons;
mod profile;
mod profile_fields;
mod refresh_sessions;
mod remote_goals;
mod user_settings;
mod users;
@@ -40,6 +41,7 @@ pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use profile_fields::PostgresProfileFieldsRepository;
pub use refresh_sessions::PostgresRefreshSessionAdapter;
pub use users::PostgresUserRepository;
pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository};
pub use watchlist::PostgresWatchlistRepository;

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
models::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
PersonId,
},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
@@ -111,71 +114,61 @@ impl PersonCommand for PostgresPersonAdapter {
}
Ok((count, has_more))
}
async fn update_enrichment(
&self,
id: &PersonId,
data: &PersonEnrichmentData,
) -> Result<(), DomainError> {
let also_known_as_json =
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
let now = chrono::Utc::now();
sqlx::query(
"UPDATE persons SET biography = $1, birthday = $2, deathday = $3, place_of_birth = $4, also_known_as = $5, homepage = $6, imdb_id = $7, enriched_at = $8 WHERE id = $9",
)
.bind(&data.biography)
.bind(data.birthday.map(|d| d.to_string()))
.bind(data.deathday.map(|d| d.to_string()))
.bind(&data.place_of_birth)
.bind(&also_known_as_json)
.bind(&data.homepage)
.bind(&data.imdb_id)
.bind(now)
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
}
#[async_trait]
impl PersonQuery for PostgresPersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = $1",
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = $1",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
Ok(row.map(PersonRow::into_person))
}
async fn get_by_external_id(
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = $1",
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = $1",
)
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
Ok(row.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
}))
Ok(row.map(PersonRow::into_person))
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
@@ -261,17 +254,8 @@ impl PersonQuery for PostgresPersonAdapter {
}
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT $1 OFFSET $2",
let rows = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT $1 OFFSET $2",
)
.bind(limit as i64)
.bind(offset as i64)
@@ -279,19 +263,7 @@ impl PersonQuery for PostgresPersonAdapter {
.await
.map_err(map_err)?;
Ok(rows
.into_iter()
.map(|r| {
let ext = ExternalPersonId::new(r.external_id);
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
ext,
r.name,
r.known_for_department,
r.profile_path,
)
})
.collect())
Ok(rows.into_iter().map(PersonRow::into_person).collect())
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
@@ -315,3 +287,57 @@ impl PersonQuery for PostgresPersonAdapter {
.collect())
}
}
// ── Row types ────────────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct PersonRow {
id: String,
external_id: String,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<String>,
deathday: Option<String>,
place_of_birth: Option<String>,
also_known_as: Option<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<String>,
}
impl PersonRow {
fn into_person(self) -> Person {
let ext = ExternalPersonId::new(self.external_id);
let also_known_as = self
.also_known_as
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default();
let birthday = self
.birthday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let deathday = self
.deathday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let enriched_at = self
.enriched_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
ext,
self.name,
self.known_for_department,
self.profile_path,
self.biography,
birthday,
deathday,
self.place_of_birth,
also_known_as,
self.homepage,
self.imdb_id,
enriched_at,
)
}
}

View 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,
})
}
}

View File

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

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

View File

@@ -22,6 +22,7 @@ mod models;
mod persons;
mod profile;
mod profile_fields;
mod refresh_sessions;
mod remote_goals;
mod user_settings;
mod users;
@@ -41,6 +42,7 @@ pub use import_session::SqliteImportSessionRepository;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use refresh_sessions::SqliteRefreshSessionAdapter;
pub use users::SqliteUserRepository;
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
pub use watchlist::SqliteWatchlistRepository;

View File

@@ -1,7 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId},
models::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData,
PersonId,
},
ports::{PersonCommand, PersonQuery},
value_objects::MovieId,
};
@@ -111,13 +114,39 @@ impl PersonCommand for SqlitePersonAdapter {
}
Ok((count, has_more))
}
async fn update_enrichment(
&self,
id: &PersonId,
data: &PersonEnrichmentData,
) -> Result<(), DomainError> {
let also_known_as_json =
serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into());
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"UPDATE persons SET biography = ?, birthday = ?, deathday = ?, place_of_birth = ?, also_known_as = ?, homepage = ?, imdb_id = ?, enriched_at = ? WHERE id = ?",
)
.bind(&data.biography)
.bind(data.birthday.map(|d| d.to_string()))
.bind(data.deathday.map(|d| d.to_string()))
.bind(&data.place_of_birth)
.bind(&also_known_as_json)
.bind(&data.homepage)
.bind(&data.imdb_id)
.bind(&now)
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(())
}
}
#[async_trait]
impl PersonQuery for SqlitePersonAdapter {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError> {
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE id = ?",
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
@@ -132,7 +161,7 @@ impl PersonQuery for SqlitePersonAdapter {
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
let row = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons WHERE external_id = ?",
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = ?",
)
.bind(id.value())
.fetch_optional(&self.pool)
@@ -212,7 +241,7 @@ impl PersonQuery for SqlitePersonAdapter {
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError> {
let rows = sqlx::query_as::<_, PersonRow>(
"SELECT id, external_id, name, known_for_department, profile_path FROM persons ORDER BY id LIMIT ? OFFSET ?",
"SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT ? OFFSET ?",
)
.bind(limit)
.bind(offset)
@@ -254,17 +283,47 @@ struct PersonRow {
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<String>,
deathday: Option<String>,
place_of_birth: Option<String>,
also_known_as: Option<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<String>,
}
impl PersonRow {
fn into_person(self) -> Person {
let ext = ExternalPersonId::new(self.external_id);
let also_known_as = self
.also_known_as
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default();
let birthday = self
.birthday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let deathday = self
.deathday
.and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok());
let enriched_at = self
.enriched_at
.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok())
.map(|d| d.with_timezone(&chrono::Utc));
Person::new(
PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()),
ext,
self.name,
self.known_for_department,
self.profile_path,
self.biography,
birthday,
deathday,
self.place_of_birth,
also_known_as,
self.homepage,
self.imdb_id,
enriched_at,
)
}
}

View 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,
})
}
}

View File

@@ -11,7 +11,10 @@ async fn pool_with_schema() -> SqlitePool {
"CREATE TABLE persons (
id TEXT PRIMARY KEY, external_id TEXT NOT NULL UNIQUE,
tmdb_person_id INTEGER UNIQUE, name TEXT NOT NULL,
known_for_department TEXT, profile_path TEXT
known_for_department TEXT, profile_path TEXT,
biography TEXT, birthday TEXT, deathday TEXT,
place_of_birth TEXT, also_known_as TEXT,
homepage TEXT, imdb_id TEXT, enriched_at TEXT
)",
)
.execute(&pool)
@@ -46,7 +49,7 @@ async fn pool_with_schema() -> SqlitePool {
fn make_person(tmdb_id: i64, name: &str, dept: Option<&str>) -> Person {
let ext = ExternalPersonId::new(format!("tmdb:{tmdb_id}"));
Person::new(
Person::basic(
PersonId::from_external(&ext),
ext,
name.to_string(),

View 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()),
})
}
}

View File

@@ -1,312 +1,7 @@
use std::sync::Arc;
mod client;
mod movie_handler;
mod person_handler;
use application::movies::{commands::EnrichMovieCommand, enrich_movie, request_enrichment};
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{CastMember, CrewMember, Genre, Keyword, MovieProfile},
ports::{
EventHandler, MovieEnrichmentClient, MovieProfileRepository, MovieRepository,
ObjectStorage, PersonCommand, SearchCommand,
},
value_objects::MovieId,
};
use serde::Deserialize;
// ── TMDb enrichment client ───────────────────────────────────────────────────
pub struct TmdbEnrichmentClient {
api_key: String,
http: reqwest::Client,
}
impl TmdbEnrichmentClient {
pub fn from_env() -> Result<Self, DomainError> {
let api_key = std::env::var("TMDB_API_KEY")
.map_err(|_| DomainError::InfrastructureError("TMDB_API_KEY is not set".into()))?;
Ok(Self {
api_key,
http: reqwest::Client::new(),
})
}
fn base(&self, path: &str) -> String {
format!("https://api.themoviedb.org/3{}", path)
}
async fn get<T: for<'de> Deserialize<'de>>(
&self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T, DomainError> {
let mut req = self
.http
.get(url)
.query(&[("api_key", self.api_key.as_str())]);
for (k, v) in extra {
req = req.query(&[(k, v)]);
}
req.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.json::<T>()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn resolve_tmdb_id(&self, external_id: &str) -> Result<u64, DomainError> {
if let Some(numeric) = external_id.strip_prefix("tmdb:") {
return numeric.parse::<u64>().map_err(|_| {
DomainError::InfrastructureError(format!("Invalid tmdb id: {numeric}"))
});
}
// Assume IMDb ID (tt…) — use /find
#[derive(Deserialize)]
struct FindResult {
id: u64,
}
#[derive(Deserialize)]
struct FindResponse {
movie_results: Vec<FindResult>,
}
let url = self.base(&format!("/find/{}", external_id));
let resp: FindResponse = self.get(&url, &[("external_source", "imdb_id")]).await?;
resp.movie_results
.into_iter()
.next()
.map(|r| r.id)
.ok_or_else(|| DomainError::NotFound(format!("TMDb: no movie for {external_id}")))
}
}
#[async_trait]
impl MovieEnrichmentClient for TmdbEnrichmentClient {
async fn fetch_profile(
&self,
movie_id: MovieId,
external_metadata_id: &str,
) -> Result<MovieProfile, DomainError> {
let tmdb_id = self.resolve_tmdb_id(external_metadata_id).await?;
#[derive(Deserialize)]
struct GenreDto {
id: u32,
name: String,
}
#[derive(Deserialize)]
struct CollectionDto {
name: String,
}
#[derive(Deserialize)]
struct CastDto {
id: u64,
name: String,
character: String,
order: u32,
profile_path: Option<String>,
}
#[derive(Deserialize)]
struct CrewDto {
id: u64,
name: String,
job: String,
department: String,
profile_path: Option<String>,
}
#[derive(Deserialize)]
struct Credits {
cast: Vec<CastDto>,
crew: Vec<CrewDto>,
}
#[derive(Deserialize)]
struct KeywordDto {
id: u32,
name: String,
}
#[derive(Deserialize)]
struct Keywords {
keywords: Vec<KeywordDto>,
}
#[derive(Deserialize)]
struct Details {
imdb_id: Option<String>,
overview: Option<String>,
tagline: Option<String>,
runtime: Option<u32>,
budget: Option<i64>,
revenue: Option<i64>,
vote_average: Option<f64>,
vote_count: Option<u32>,
original_language: Option<String>,
genres: Vec<GenreDto>,
belongs_to_collection: Option<CollectionDto>,
credits: Credits,
keywords: Keywords,
}
let url = self.base(&format!("/movie/{}", tmdb_id));
let d: Details = self
.get(&url, &[("append_to_response", "credits,keywords")])
.await?;
Ok(MovieProfile {
movie_id,
tmdb_id,
imdb_id: d.imdb_id.filter(|s| !s.is_empty()),
overview: d.overview.filter(|s| !s.is_empty()),
tagline: d.tagline.filter(|s| !s.is_empty()),
runtime_minutes: d.runtime,
budget_usd: d.budget.filter(|&v| v > 0),
revenue_usd: d.revenue.filter(|&v| v > 0),
vote_average: d.vote_average,
vote_count: d.vote_count,
original_language: d.original_language,
collection_name: d.belongs_to_collection.map(|c| c.name),
genres: d
.genres
.into_iter()
.map(|g| Genre {
tmdb_id: g.id,
name: g.name,
})
.collect(),
keywords: d
.keywords
.keywords
.into_iter()
.map(|k| Keyword {
tmdb_id: k.id,
name: k.name,
})
.collect(),
cast: d
.credits
.cast
.into_iter()
.map(|c| CastMember {
tmdb_person_id: c.id,
name: c.name,
character: c.character,
billing_order: c.order,
profile_path: c.profile_path,
})
.collect(),
crew: d
.credits
.crew
.into_iter()
.map(|c| CrewMember {
tmdb_person_id: c.id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(),
enriched_at: Utc::now(),
})
}
}
// ── Enrichment event handler ─────────────────────────────────────────────────
pub struct EnrichmentHandler {
pub enrichment_client: Arc<dyn MovieEnrichmentClient>,
pub movie_repository: Arc<dyn MovieRepository>,
pub profile_repo: Arc<dyn MovieProfileRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub search_command: Arc<dyn SearchCommand>,
pub object_storage: Arc<dyn ObjectStorage>,
http: reqwest::Client,
}
impl EnrichmentHandler {
pub fn new(
enrichment_client: Arc<dyn MovieEnrichmentClient>,
movie_repository: Arc<dyn MovieRepository>,
profile_repo: Arc<dyn MovieProfileRepository>,
person_command: Arc<dyn PersonCommand>,
search_command: Arc<dyn SearchCommand>,
object_storage: Arc<dyn ObjectStorage>,
) -> Self {
Self {
enrichment_client,
movie_repository,
profile_repo,
person_command,
search_command,
object_storage,
http: reqwest::Client::new(),
}
}
async fn download_cast_photos(&self, profile: &MovieProfile) {
for member in profile.cast.iter().take(5) {
let Some(ref path) = member.profile_path else {
continue;
};
let key = format!("cast{path}");
if self.object_storage.get(&key).await.is_ok() {
continue;
}
let url = format!("https://image.tmdb.org/t/p/w185{path}");
match self.http.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await
&& let Err(e) = self.object_storage.store(&key, &bytes).await
{
tracing::debug!("cast photo store failed for {path}: {e}");
}
}
_ => tracing::debug!("cast photo download failed for {path}"),
}
}
}
}
#[async_trait]
impl EventHandler for EnrichmentHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let (movie_id, external_metadata_id) = match event {
DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
} => (movie_id.clone(), external_metadata_id.clone()),
_ => return Ok(()),
};
let Some(profile) = request_enrichment::fetch_if_stale(
self.enrichment_client.as_ref(),
&self.profile_repo,
movie_id.clone(),
&external_metadata_id,
)
.await?
else {
return Ok(());
};
self.download_cast_photos(&profile).await;
enrich_movie::execute(
&self.movie_repository,
&self.profile_repo,
&self.person_command,
&self.search_command,
EnrichMovieCommand { movie_id, profile },
)
.await
}
}
pub use client::TmdbEnrichmentClient;
pub use movie_handler::MovieEnrichmentHandler;
pub use person_handler::PersonEnrichmentHandler;

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

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

View File

@@ -10,6 +10,7 @@ pub struct LoginRequest {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginResponse {
pub token: String,
pub refresh_token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: String,
@@ -22,3 +23,20 @@ pub struct RegisterRequest {
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RefreshResponse {
pub token: String,
pub refresh_token: String,
pub expires_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LogoutRequest {
pub refresh_token: String,
}

View File

@@ -70,6 +70,21 @@ pub struct PersonDto {
pub name: String,
pub known_for_department: Option<String>,
pub profile_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub biography: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub birthday: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deathday: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub place_of_birth: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub also_known_as: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub imdb_url: Option<String>,
pub enriched: bool,
}
#[derive(Debug, Serialize, ToSchema)]

View File

@@ -26,6 +26,7 @@ pub struct UserProfileQueryParams {
pub view: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub search: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,12 +1,13 @@
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email};
use domain::{errors::DomainError, models::RefreshSession, value_objects::Email};
use crate::{auth::queries::LoginQuery, context::AppContext};
pub struct LoginResult {
pub token: String,
pub refresh_token: String,
pub user_id: Uuid,
pub email: String,
pub expires_at: DateTime<Utc>,
@@ -33,8 +34,20 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
let generated = ctx.services.auth.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64);
let session = RefreshSession {
id: Uuid::new_v4(),
user_id: user.id().clone(),
token: refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
ctx.repos.refresh_session.create(&session).await?;
Ok(LoginResult {
token: generated.token,
refresh_token,
user_id: user.id().value(),
email: user.email().value().to_string(),
expires_at: generated.expires_at,

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

View File

@@ -1,5 +1,7 @@
pub mod commands;
pub mod login;
pub mod logout;
pub mod queries;
pub mod refresh;
pub mod register;
pub mod register_and_login;

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

View File

@@ -44,6 +44,7 @@ async fn test_login_valid_credentials_returns_token() {
.unwrap();
assert!(!result.token.is_empty());
assert!(!result.refresh_token.is_empty());
assert_eq!(result.email, "carol@example.com");
}

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

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

View File

@@ -3,6 +3,7 @@ pub struct AppConfig {
pub allow_registration: bool,
pub base_url: String,
pub rate_limit: u64,
pub refresh_ttl_seconds: u64,
pub wrapup: WrapUpConfig,
}
@@ -24,10 +25,15 @@ impl AppConfig {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(60);
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_592_000u64);
Self {
allow_registration,
base_url,
rate_limit,
refresh_ttl_seconds,
wrapup: WrapUpConfig::from_env(),
}
}

View File

@@ -3,11 +3,12 @@ use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient,
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository,
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery,
};
use crate::config::AppConfig;
@@ -38,6 +39,7 @@ pub struct Repositories {
pub goal: Arc<dyn GoalRepository>,
pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
}
#[derive(Clone)]
@@ -51,6 +53,7 @@ pub struct Services {
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub review_logger: Arc<dyn ReviewLogger>,
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
}
#[derive(Clone)]

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

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

View 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};

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

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

View File

@@ -2,91 +2,10 @@ use std::time::Duration;
use async_trait::async_trait;
use chrono::Datelike;
use domain::{errors::DomainError, events::DomainEvent, ports::PeriodicJob};
use domain::{errors::DomainError, ports::PeriodicJob};
use crate::context::AppContext;
pub struct ImportSessionCleanupJob {
ctx: AppContext,
}
impl ImportSessionCleanupJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for ImportSessionCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let n = crate::import::cleanup::execute(&self.ctx).await?;
tracing::info!("import session cleanup: removed {} expired sessions", n);
Ok(())
}
}
pub struct WatchEventCleanupJob {
ctx: AppContext,
}
impl WatchEventCleanupJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for WatchEventCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(86400)
}
async fn run(&self) -> Result<(), DomainError> {
let n = crate::integrations::cleanup::execute(&self.ctx).await?;
if n > 0 {
tracing::info!("watch event cleanup: removed {n} old entries");
}
Ok(())
}
}
pub struct EnrichmentStalenessJob {
ctx: AppContext,
}
impl EnrichmentStalenessJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for EnrichmentStalenessJob {
fn interval(&self) -> Duration {
Duration::from_secs(3600)
}
async fn run(&self) -> Result<(), DomainError> {
let stale = self.ctx.repos.movie_profile.list_stale().await?;
if stale.is_empty() {
return Ok(());
}
tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale {
let event = DomainEvent::MovieEnrichmentRequested {
movie_id,
external_metadata_id,
};
self.ctx.services.event_publisher.publish(&event).await?;
}
Ok(())
}
}
pub struct WrapUpAutoGenerateJob {
ctx: AppContext,
}
@@ -105,7 +24,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
async fn run(&self) -> Result<(), DomainError> {
let now = chrono::Utc::now().naive_utc();
// Only run in January
if now.month() != 1 {
return Ok(());
}
@@ -140,7 +58,6 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
}
}
// Global wrap-up
let existing = self
.ctx
.repos

View File

@@ -67,7 +67,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
for member in cast {
seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new(
Person::basic(
PersonId::from_external(&ext),
ext,
member.name.clone(),
@@ -80,7 +80,7 @@ fn extract_persons(cast: &[CastMember], crew: &[CrewMember]) -> Vec<Person> {
for member in crew {
seen.entry(member.tmdb_person_id).or_insert_with(|| {
let ext = ExternalPersonId::new(format!("tmdb:{}", member.tmdb_person_id));
Person::new(
Person::basic(
PersonId::from_external(&ext),
ext,
member.name.clone(),

View File

@@ -83,6 +83,13 @@ impl domain::ports::PersonCommand for NoopPersonCommand {
) -> Result<(u64, bool), domain::errors::DomainError> {
Ok((0, false))
}
async fn update_enrichment(
&self,
_: &domain::models::PersonId,
_: &domain::models::PersonEnrichmentData,
) -> Result<(), domain::errors::DomainError> {
Ok(())
}
}
#[tokio::test]

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

View File

@@ -1,11 +1,35 @@
use crate::context::AppContext;
use chrono::Utc;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Person, PersonId},
};
const ENRICHMENT_TTL_DAYS: i64 = 90;
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<Option<Person>, DomainError> {
ctx.repos.person_query.get_by_id(&id).await
let person = ctx.repos.person_query.get_by_id(&id).await?;
if let Some(ref p) = person
&& should_enrich(p)
{
let _ = ctx
.services
.event_publisher
.publish(&DomainEvent::PersonEnrichmentRequested {
person_id: id,
external_person_id: p.external_id().value().to_string(),
})
.await;
}
Ok(person)
}
fn should_enrich(p: &Person) -> bool {
match p.enriched_at() {
None => true,
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
}
}
#[cfg(test)]

View File

@@ -1,11 +1,33 @@
use crate::context::AppContext;
use chrono::Utc;
use domain::{
errors::DomainError,
models::{PersonCredits, PersonId},
events::DomainEvent,
models::{Person, PersonCredits, PersonId},
};
const ENRICHMENT_TTL_DAYS: i64 = 90;
pub async fn execute(ctx: &AppContext, id: PersonId) -> Result<PersonCredits, DomainError> {
ctx.repos.person_query.get_credits(&id).await
let credits = ctx.repos.person_query.get_credits(&id).await?;
if should_enrich(&credits.person) {
let _ = ctx
.services
.event_publisher
.publish(&DomainEvent::PersonEnrichmentRequested {
person_id: id,
external_person_id: credits.person.external_id().value().to_string(),
})
.await;
}
Ok(credits)
}
fn should_enrich(p: &Person) -> bool {
match p.enriched_at() {
None => true,
Some(at) => (Utc::now() - at).num_days() >= ENRICHMENT_TTL_DAYS,
}
}
#[cfg(test)]

View File

@@ -1,2 +1,3 @@
pub mod enrich;
pub mod get;
pub mod get_credits;

View File

@@ -9,19 +9,20 @@ use domain::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
GoalRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient,
MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, PersonCommand,
PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery,
PersonQuery, PosterFetcherClient, RefreshSessionRepository, ReviewRepository,
SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
WrapUpRepository, WrapUpStatsQuery,
},
testing::{
FakeAuthService, FakeDiaryRepository, FakeDocumentParser, FakeMetadataClient,
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
PanicPersonCommand,
},
};
@@ -75,6 +76,7 @@ pub struct TestContextBuilder {
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
pub review_logger: Arc<dyn ReviewLogger>,
pub social_query: Arc<dyn domain::ports::SocialQueryPort>,
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
pub config: AppConfig,
}
@@ -117,10 +119,12 @@ impl TestContextBuilder {
user_settings_repo: InMemoryUserSettingsRepository::new(),
review_logger: Arc::new(NoopReviewLogger),
social_query: Arc::new(NoopSocialQueryPort),
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
config: AppConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: crate::config::WrapUpConfig {
font_path: None,
logo_path: None,
@@ -286,6 +290,7 @@ impl TestContextBuilder {
goal: self.goal_repo,
user_settings: self.user_settings_repo,
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
refresh_session: self.refresh_session_repo,
},
services: Services {
auth: self.auth_service,
@@ -297,6 +302,7 @@ impl TestContextBuilder {
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
review_logger: self.review_logger,
person_enrichment: None,
},
config: self.config,
}

View File

@@ -66,6 +66,7 @@ impl EventHandler for RecordingHandler {
DomainEvent::GoalCreated { .. }
| DomainEvent::GoalUpdated { .. }
| DomainEvent::GoalDeleted { .. } => "goal",
DomainEvent::PersonEnrichmentRequested { .. } => "person_enrichment_requested",
};
self.calls.lock().unwrap().push(label);
Ok(())

View File

@@ -7,7 +7,7 @@ use domain::{
use futures::StreamExt;
use tokio::sync::Semaphore;
const DEFAULT_CONCURRENCY: usize = 8;
const DEFAULT_CONCURRENCY: usize = 4;
pub struct WorkerService {
consumer: Arc<dyn EventConsumer>,

View File

@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
use crate::{
errors::DomainError,
models::PersonId,
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
@@ -43,6 +44,10 @@ pub enum DomainEvent {
movie_id: MovieId,
external_metadata_id: String,
},
PersonEnrichmentRequested {
person_id: PersonId,
external_person_id: String,
},
ImageStored {
key: String,
},

View File

@@ -1,6 +1,7 @@
mod enrichment;
mod feed;
mod movie;
mod refresh_session;
mod review;
mod stats;
mod user;
@@ -43,7 +44,10 @@ pub use import::{
};
pub use import_profile::ImportProfile;
pub use import_session::ImportSession;
pub use person::{CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonId};
pub use person::{
CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, PersonId,
};
pub use refresh_session::RefreshSession;
pub use search::{
EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, SearchFilters, SearchQuery,
SearchResults,

View File

@@ -46,15 +46,56 @@ pub struct Person {
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<chrono::NaiveDate>,
deathday: Option<chrono::NaiveDate>,
place_of_birth: Option<String>,
also_known_as: Vec<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl Person {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: PersonId,
external_id: ExternalPersonId,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<chrono::NaiveDate>,
deathday: Option<chrono::NaiveDate>,
place_of_birth: Option<String>,
also_known_as: Vec<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Self {
Self {
id,
external_id,
name,
known_for_department,
profile_path,
biography,
birthday,
deathday,
place_of_birth,
also_known_as,
homepage,
imdb_id,
enriched_at,
}
}
pub fn basic(
id: PersonId,
external_id: ExternalPersonId,
name: String,
known_for_department: Option<String>,
profile_path: Option<String>,
) -> Self {
Self {
id,
@@ -62,6 +103,14 @@ impl Person {
name,
known_for_department,
profile_path,
biography: None,
birthday: None,
deathday: None,
place_of_birth: None,
also_known_as: vec![],
homepage: None,
imdb_id: None,
enriched_at: None,
}
}
@@ -84,6 +133,49 @@ impl Person {
pub fn profile_path(&self) -> Option<&str> {
self.profile_path.as_deref()
}
pub fn biography(&self) -> Option<&str> {
self.biography.as_deref()
}
pub fn birthday(&self) -> Option<chrono::NaiveDate> {
self.birthday
}
pub fn deathday(&self) -> Option<chrono::NaiveDate> {
self.deathday
}
pub fn place_of_birth(&self) -> Option<&str> {
self.place_of_birth.as_deref()
}
pub fn also_known_as(&self) -> &[String] {
&self.also_known_as
}
pub fn homepage(&self) -> Option<&str> {
self.homepage.as_deref()
}
pub fn imdb_id(&self) -> Option<&str> {
self.imdb_id.as_deref()
}
pub fn enriched_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.enriched_at
}
}
#[derive(Clone, Debug)]
pub struct PersonEnrichmentData {
pub biography: Option<String>,
pub birthday: Option<chrono::NaiveDate>,
pub deathday: Option<chrono::NaiveDate>,
pub place_of_birth: Option<String>,
pub also_known_as: Vec<String>,
pub homepage: Option<String>,
pub imdb_id: Option<String>,
}
#[derive(Clone, Debug)]

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

View File

@@ -4,7 +4,7 @@ use super::*;
fn person_new() {
let ext = ExternalPersonId::new("tmdb:12345");
let pid = PersonId::from_external(&ext);
let p = Person::new(
let p = Person::basic(
pid,
ext,
"Keanu Reeves".into(),
@@ -38,7 +38,7 @@ fn person_id_deterministic() {
fn person_credits_default_empty() {
let ext = ExternalPersonId::new("tmdb:1");
let pid = PersonId::from_external(&ext);
let p = Person::new(pid, ext, "Test".into(), None, None);
let p = Person::basic(pid, ext, "Test".into(), None, None);
let credits = PersonCredits {
person: p,
cast: vec![],

View File

@@ -10,9 +10,9 @@ use crate::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteGoalEntry,
RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults, User,
UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
ParsedPlaybackEvent, Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession,
RemoteGoalEntry, RemoteWatchlistEntry, Review, ReviewHistory, SearchQuery, SearchResults,
User, UserSettings, UserStats, UserSummary, UserTrends, WatchEvent, WatchEventStatus,
WatchlistEntry, WatchlistWithMovie, WebhookToken,
collections::{self, PageParams, Paginated},
wrapup::{DateRange, WrapUpRecord, WrapUpScope, WrapUpStatus},
@@ -292,6 +292,11 @@ pub trait MovieEnrichmentClient: Send + Sync {
) -> Result<MovieProfile, DomainError>;
}
#[async_trait]
pub trait PersonEnrichmentClient: Send + Sync {
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
}
#[async_trait]
pub trait ImportSessionRepository: Send + Sync {
async fn create(&self, session: &ImportSession) -> Result<(), DomainError>;
@@ -306,6 +311,15 @@ pub trait ImportSessionRepository: Send + Sync {
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RefreshSessionRepository: Send + Sync {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError>;
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError>;
async fn revoke(&self, token: &str) -> Result<(), DomainError>;
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError>;
async fn delete_expired(&self) -> Result<u64, DomainError>;
}
#[async_trait]
pub trait ImportProfileRepository: Send + Sync {
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError>;
@@ -339,6 +353,11 @@ pub trait PersonCommand: Send + Sync {
&self,
batch_size: u32,
) -> Result<(u64, bool), DomainError>;
async fn update_enrichment(
&self,
id: &PersonId,
data: &PersonEnrichmentData,
) -> Result<(), DomainError>;
}
/// Read port — queries persons and credits. No mutations.

View File

@@ -223,7 +223,7 @@ impl PersonQuery for FakePersonQuery {
}
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
let dummy = Person::new(
let dummy = Person::basic(
id.clone(),
ExternalPersonId::new("tmdb:0"),
"Unknown".into(),

View File

@@ -6,19 +6,22 @@ use uuid::Uuid;
use chrono::NaiveDateTime;
use chrono::Utc;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus,
WatchlistEntry, WatchlistWithMovie, WebhookToken,
ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent,
WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
collections::{PageParams, Paginated},
},
ports::{
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository,
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository,
},
value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -777,3 +780,53 @@ impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo {
Ok(())
}
}
// ── InMemoryRefreshSessionRepository ────────────────────────────────────────
pub struct InMemoryRefreshSessionRepository {
pub store: Mutex<Vec<RefreshSession>>,
}
impl InMemoryRefreshSessionRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(Vec::new()),
})
}
}
#[async_trait]
impl RefreshSessionRepository for InMemoryRefreshSessionRepository {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
self.store.lock().unwrap().push(session.clone());
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|s| s.token == token)
.cloned())
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
self.store.lock().unwrap().retain(|s| s.token != token);
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.store.lock().unwrap().retain(|s| s.user_id != *user_id);
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let mut store = self.store.lock().unwrap();
let before = store.len();
let now = Utc::now();
store.retain(|s| s.expires_at >= now);
Ok((before - store.len()) as u64)
}
}

View File

@@ -5,15 +5,16 @@ use crate::{
models::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId,
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits,
PersonEnrichmentData, PersonId, RefreshSession, ReviewHistory, SearchQuery, SearchResults,
UserStats, UserTrends,
collections::{PageParams, Paginated},
},
ports::{
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
UserProfileFieldsRepository,
PersonQuery, PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort,
StatsRepository, UserProfileFieldsRepository,
},
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
};
@@ -103,6 +104,27 @@ impl ImportSessionRepository for PanicImportSessionRepository {
}
}
pub struct PanicRefreshSessionRepository;
#[async_trait]
impl RefreshSessionRepository for PanicRefreshSessionRepository {
async fn create(&self, _: &RefreshSession) -> Result<(), DomainError> {
panic!("PanicRefreshSessionRepository called")
}
async fn get_by_token(&self, _: &str) -> Result<Option<RefreshSession>, DomainError> {
panic!("PanicRefreshSessionRepository called")
}
async fn revoke(&self, _: &str) -> Result<(), DomainError> {
panic!("PanicRefreshSessionRepository called")
}
async fn revoke_all_for_user(&self, _: &UserId) -> Result<(), DomainError> {
panic!("PanicRefreshSessionRepository called")
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!("PanicRefreshSessionRepository called")
}
}
pub struct PanicImportProfileRepository;
#[async_trait]
@@ -150,6 +172,13 @@ impl PersonCommand for PanicPersonCommand {
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
panic!("PanicPersonCommand called")
}
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!("PanicPersonCommand called")
}
}
pub struct PanicPersonQuery;

View File

@@ -3,7 +3,8 @@ use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
PosterFetcherClient, UserProfileFieldsRepository, WatchEventRepository, WebhookTokenRepository,
PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository,
WatchEventRepository, WebhookTokenRepository,
};
pub enum DbPool {
@@ -36,6 +37,7 @@ pub struct DatabaseOutput {
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub db_pool: DbPool,
}
@@ -77,6 +79,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(),
)) as _,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -115,6 +120,8 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -17,7 +17,9 @@ use crate::{
render::render_page,
state::AppState,
};
use api_types::{LoginRequest, LoginResponse, RegisterRequest};
use api_types::{
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
};
use application::ports::HtmlPageContext;
use template_askama::{LoginTemplate, RegisterTemplate};
@@ -68,6 +70,7 @@ pub async fn login(
.await?;
Ok(Json(LoginResponse {
token: result.token,
refresh_token: result.refresh_token,
user_id: result.user_id,
email: result.email,
expires_at: result.expires_at.to_rfc3339(),
@@ -100,6 +103,41 @@ pub async fn register(
Ok(StatusCode::CREATED)
}
#[utoipa::path(
post, path = "/api/v1/auth/refresh",
request_body = RefreshRequest,
responses(
(status = 200, body = RefreshResponse),
(status = 401, description = "Invalid or expired refresh token"),
)
)]
pub async fn refresh(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, ApiError> {
let result = application::auth::refresh::execute(&state.app_ctx, &req.refresh_token).await?;
Ok(Json(RefreshResponse {
token: result.token,
refresh_token: result.refresh_token,
expires_at: result.expires_at.to_rfc3339(),
}))
}
#[utoipa::path(
post, path = "/api/v1/auth/logout",
request_body = LogoutRequest,
responses(
(status = 204, description = "Logged out"),
)
)]
pub async fn api_logout(
State(state): State<AppState>,
Json(req): Json<LogoutRequest>,
) -> StatusCode {
let _ = application::auth::logout::execute(&state.app_ctx, &req.refresh_token).await;
StatusCode::NO_CONTENT
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn get_login_page(

View File

@@ -13,10 +13,10 @@ use std::collections::HashMap;
use crate::render::render_page;
use application::import::{
apply_mapping as apply_import_mapping,
apply_mapping as apply_import_mapping, apply_profile as apply_import_profile,
commands::{
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
ExecuteImportCommand, SaveImportProfileCommand,
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
},
create_session as create_import_session, delete_profile as delete_import_profile,
execute as execute_import, list_profiles as list_import_profiles,
@@ -859,3 +859,97 @@ pub async fn api_delete_profile(
}
}
}
#[utoipa::path(
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
params(
("id" = String, Path, description = "Import session UUID"),
("profile_id" = String, Path, description = "Import profile UUID"),
),
responses(
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
(status = 400, description = "Invalid ID"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Session or profile not found"),
(status = 422, description = "Mapping error"),
),
security(("bearer_auth" = []))
)]
pub async fn api_apply_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
Path((session_id_str, profile_id_str)): Path<(String, String)>,
) -> impl IntoResponse {
let Ok(session_id) = session_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid session id"})),
)
.into_response();
};
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid profile id"})),
)
.into_response();
};
if let Err(e) = apply_import_profile::execute(
&state.app_ctx,
ApplyImportProfileCommand {
user_id: user_id.value(),
session_id,
profile_id,
},
)
.await
{
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
StatusCode::NOT_FOUND
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
return (
status,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response();
}
let session = match state
.app_ctx
.repos
.import_session
.get(&ImportSessionId::from_uuid(session_id), &user_id)
.await
{
Ok(Some(s)) => s,
_ => {
return (
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({"error": "session not found after profile apply"})),
)
.into_response();
}
};
let mappings = session.field_mappings.unwrap_or_default();
match apply_import_mapping::execute(
&state.app_ctx,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id,
mappings,
},
)
.await
{
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
Err(e) => (
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}

View File

@@ -108,6 +108,16 @@ pub async fn get_person_handler(
name: person.name().to_string(),
known_for_department: person.known_for_department().map(str::to_string),
profile_path: person.profile_path().map(str::to_string),
biography: person.biography().map(str::to_string),
birthday: person.birthday().map(|d| d.to_string()),
deathday: person.deathday().map(|d| d.to_string()),
place_of_birth: person.place_of_birth().map(str::to_string),
also_known_as: person.also_known_as().to_vec(),
homepage: person.homepage().map(str::to_string),
imdb_url: person
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: person.enriched_at().is_some(),
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
@@ -136,6 +146,17 @@ pub async fn get_person_credits_handler(
name: credits.person.name().to_string(),
known_for_department: credits.person.known_for_department().map(str::to_string),
profile_path: credits.person.profile_path().map(str::to_string),
biography: credits.person.biography().map(str::to_string),
birthday: credits.person.birthday().map(|d| d.to_string()),
deathday: credits.person.deathday().map(|d| d.to_string()),
place_of_birth: credits.person.place_of_birth().map(str::to_string),
also_known_as: credits.person.also_known_as().to_vec(),
homepage: credits.person.homepage().map(str::to_string),
imdb_url: credits
.person
.imdb_id()
.map(|id| format!("https://www.imdb.com/name/{id}")),
enriched: credits.person.enriched_at().is_some(),
},
cast: credits
.cast

View File

@@ -270,7 +270,7 @@ pub async fn get_user_profile(
limit: params.limit,
offset: params.offset,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
search: params.search,
is_own_profile: viewer_id.value() == user_id,
},
)

View File

@@ -207,6 +207,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
goal: db.goal,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
refresh_session: db.refresh_session,
},
services: Services {
auth: auth_service,
@@ -218,6 +219,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
review_logger,
person_enrichment: None,
},
config: app_config,
};

View File

@@ -326,6 +326,8 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
)
.route("/auth/login", routing::post(handlers::auth::login))
.route("/auth/register", routing::post(handlers::auth::register))
.route("/auth/refresh", routing::post(handlers::auth::refresh))
.route("/auth/logout", routing::post(handlers::auth::api_logout))
.route("/diary/export", routing::get(handlers::diary::export_diary))
.route(
"/activity-feed",
@@ -365,6 +367,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
"/import/profiles/{id}",
routing::delete(handlers::import::api_delete_profile),
)
.route(
"/import/sessions/{id}/profile/{profile_id}",
routing::put(handlers::import::api_apply_profile),
)
.route(
"/profile",
routing::get(handlers::users::get_profile).put(handlers::users::update_profile_handler),

View File

@@ -14,8 +14,8 @@ use domain::{
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats,
UserTrends,
PersonCredits, PersonEnrichmentData, PersonId, Review, ReviewHistory, SearchQuery,
SearchResults, UserStats, UserTrends,
collections::{PageParams, Paginated},
},
ports::{
@@ -437,6 +437,13 @@ impl PersonCommand for Panic {
) -> Result<(u64, bool), DomainError> {
panic!()
}
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl PersonQuery for Panic {
@@ -722,6 +729,31 @@ impl domain::ports::RemoteGoalRepository for Panic {
}
}
#[async_trait::async_trait]
impl domain::ports::RefreshSessionRepository for Panic {
async fn create(&self, _: &domain::models::RefreshSession) -> Result<(), DomainError> {
panic!()
}
async fn get_by_token(
&self,
_: &str,
) -> Result<Option<domain::models::RefreshSession>, DomainError> {
panic!()
}
async fn revoke(&self, _: &str) -> Result<(), DomainError> {
panic!()
}
async fn revoke_all_for_user(
&self,
_: &domain::value_objects::UserId,
) -> Result<(), DomainError> {
panic!()
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl application::ports::ReviewLogger for Panic {
async fn log_review(
@@ -762,6 +794,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
goal: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
refresh_session: Arc::clone(&repo) as _,
},
services: Services {
auth: auth_service,
@@ -773,11 +806,13 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
review_logger: Arc::clone(&repo) as _,
person_enrichment: None,
},
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig {
font_path: None,
logo_path: None,

View File

@@ -14,8 +14,8 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId,
SearchQuery, SearchResults, User,
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits,
PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User,
},
ports::{
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
@@ -314,6 +314,13 @@ impl PersonCommand for PanicPersonCommand {
) -> Result<(u64, bool), DomainError> {
panic!()
}
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> Result<(), DomainError> {
panic!()
}
}
struct PanicPersonQuery;
@@ -451,6 +458,7 @@ async fn test_app() -> Router {
goal: Arc::new(domain::testing::NoopGoalRepository),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
},
services: Services {
auth: Arc::new(PanicAuth),
@@ -462,11 +470,13 @@ async fn test_app() -> Router {
diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser),
review_logger: Arc::new(PanicReviewLogger),
person_enrichment: None,
},
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig {
font_path: None,
logo_path: None,

View File

@@ -41,6 +41,7 @@ pub struct WorkerDbOutput {
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
pub db_pool: DbPool,
}
@@ -86,6 +87,9 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(),
)) as _,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -128,6 +132,8 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -15,7 +15,10 @@ use export::ExportAdapter;
use importer::ImporterDocumentParser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use domain::ports::{DiaryExporter, DocumentParser, EventHandler, PeriodicJob};
use domain::ports::{
DiaryExporter, DocumentParser, EventHandler, MovieEnrichmentClient, PeriodicJob,
PersonEnrichmentClient,
};
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!(
@@ -74,7 +77,7 @@ async fn main() -> anyhow::Result<()> {
Arc::clone(&event_publisher_arc),
));
let ctx = AppContext {
let mut ctx = AppContext {
repos: Repositories {
movie: db.movie,
review: db.review,
@@ -105,6 +108,7 @@ async fn main() -> anyhow::Result<()> {
goal: db.goal,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
refresh_session: db.refresh_session,
},
services: Services {
auth: auth_service,
@@ -116,6 +120,7 @@ async fn main() -> anyhow::Result<()> {
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
review_logger,
person_enrichment: None,
},
config: app_config,
};
@@ -124,26 +129,36 @@ async fn main() -> anyhow::Result<()> {
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
// Without a key, no MovieEnrichmentRequested events are produced or handled.
type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
let (enrichment_handler, enrichment_job): OptionalPair =
type EnrichmentParts = (
Option<Arc<dyn EventHandler>>,
Option<Arc<dyn EventHandler>>,
Option<Arc<dyn PeriodicJob>>,
);
let (enrichment_handler, person_enrichment_handler, enrichment_job): EnrichmentParts =
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
Ok(client) => {
tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler::new(
Arc::new(client),
let client = Arc::new(client);
let handler = Arc::new(tmdb_enrichment::MovieEnrichmentHandler::new(
Arc::clone(&client) as Arc<dyn MovieEnrichmentClient>,
Arc::clone(&ctx.repos.movie),
Arc::clone(&ctx.repos.movie_profile),
Arc::clone(&ctx.repos.person_command),
Arc::clone(&ctx.repos.search_command),
Arc::clone(&ctx.services.object_storage),
)) as Arc<dyn EventHandler>;
ctx.services.person_enrichment =
Some(Arc::clone(&client) as Arc<dyn PersonEnrichmentClient>);
let person_handler =
Arc::new(tmdb_enrichment::PersonEnrichmentHandler::new(ctx.clone()))
as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>;
(Some(handler), Some(job))
(Some(handler), Some(person_handler), Some(job))
}
Err(e) => {
tracing::warn!("TMDb enrichment disabled: {e}");
(None, None)
(None, None, None)
}
};
@@ -163,6 +178,9 @@ async fn main() -> anyhow::Result<()> {
Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())),
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())),
Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())),
Arc::new(application::jobs::RefreshSessionCleanupJob::new(
ctx.clone(),
)),
];
if let Some(job) = enrichment_job {
periodic_jobs.push(job);
@@ -225,6 +243,9 @@ async fn main() -> anyhow::Result<()> {
if let Some(e) = enrichment_handler {
h.push(e);
}
if let Some(e) = person_enrichment_handler {
h.push(e);
}
if let Some((ref conv_handler, _)) = conversion {
h.push(Arc::clone(conv_handler));
}
@@ -281,6 +302,9 @@ async fn main() -> anyhow::Result<()> {
if let Some(e) = enrichment_handler {
h.push(e);
}
if let Some(e) = person_enrichment_handler {
h.push(e);
}
if let Some((ref conv_handler, _)) = conversion {
h.push(Arc::clone(conv_handler));
}

View 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>
)
}

View File

@@ -23,6 +23,7 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{movie.title}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</p>}
</div>
{rating != null && <StarDisplay rating={rating} size="xs" />}
</Link>

View File

@@ -2,16 +2,18 @@ import { Link } from "@tanstack/react-router"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { User } from "lucide-react"
import { Search, User } from "lucide-react"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Input } from "@/components/ui/input"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs"
import { VirtualList } from "@/components/virtual-list"
import { useInfiniteDiary } from "@/hooks/use-diary"
import { timeAgo } from "@/lib/date"
import type { UserProfileResponse } from "@/lib/api/users"
type ProfileViewProps = {
@@ -19,6 +21,8 @@ type ProfileViewProps = {
actions?: React.ReactNode
headerRight?: React.ReactNode
userId?: string
search?: string
onSearchChange?: (value: string) => void
}
export function ProfileView({
@@ -26,6 +30,8 @@ export function ProfileView({
actions,
headerRight,
userId,
search,
onSearchChange,
}: ProfileViewProps) {
const { t } = useTranslation()
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
@@ -72,6 +78,18 @@ export function ProfileView({
</Link>
</div>
{onSearchChange && (
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("profile.searchPlaceholder")}
value={search ?? ""}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
)}
{actions}
<SwipeTabs
@@ -82,13 +100,14 @@ export function ProfileView({
{(tab) => (
<>
{tab === "recent" && (
<DiaryTab key="date_desc" sortBy="date_desc" userId={userId} />
<DiaryTab key="date_desc" sortBy="date_desc" userId={userId} search={search} />
)}
{tab === "top_rated" && (
<DiaryTab
key="rating_desc"
sortBy="rating_desc"
userId={userId}
search={search}
/>
)}
{tab === "trends" && <TrendsView data={data} />}
@@ -108,19 +127,24 @@ function StatCell({ label, value }: { label: string; value: string | number }) {
)
}
function DiaryTab({ sortBy }: { sortBy: string; userId?: string }) {
function DiaryTab({ sortBy, search }: { sortBy: string; userId?: string; search?: string }) {
const { t } = useTranslation()
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: sortBy, movie_id: undefined })
const items = data?.pages.flatMap((p) => p.items) ?? []
const filtered = search
? items.filter((e) =>
e.movie.title.toLowerCase().includes(search.toLowerCase())
)
: items
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
if (!items.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
return (
<VirtualList
items={items}
items={filtered}
estimateSize={52}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
@@ -130,7 +154,7 @@ function DiaryTab({ sortBy }: { sortBy: string; userId?: string }) {
movie={e.movie}
rating={e.review.rating}
comment={e.review.comment}
subtitle={e.review.watched_at.slice(0, 10)}
subtitle={t("profile.watchedAgo", { when: timeAgo(e.review.watched_at) })}
variant="compact"
/>
)}

View File

@@ -1,7 +1,8 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuth } from "@/components/auth-provider"
import { login, register } from "@/lib/api/auth"
import { apiLogout, login, register } from "@/lib/api/auth"
import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
import { getRefreshToken } from "@/lib/auth"
export function useLogin() {
const { login: setAuth } = useAuth()
@@ -11,6 +12,7 @@ export function useLogin() {
onSuccess: (res) => {
setAuth({
token: res.token,
refresh_token: res.refresh_token,
user_id: res.user_id,
email: res.email,
role: res.role,
@@ -32,6 +34,12 @@ export function useLogout() {
const qc = useQueryClient()
return useMutation({
mutationFn: async () => {
const rt = getRefreshToken()
if (rt) {
try {
await apiLogout(rt)
} catch {}
}
logout()
qc.clear()
},

View 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])
}

View File

@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
applyImportProfile,
applyMapping,
confirmImport,
createImportSession,
@@ -93,3 +94,10 @@ export function useDeleteImportProfile() {
},
})
}
export function useApplyImportProfile() {
return useMutation({
mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
applyImportProfile(sessionId, profileId),
})
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { post } from "./client"
import { API_URL, post } from "./client"
export const loginRequestSchema = z.object({
email: z.string(),
@@ -9,6 +9,7 @@ export type LoginRequest = z.infer<typeof loginRequestSchema>
export const loginResponseSchema = z.object({
token: z.string(),
refresh_token: z.string(),
user_id: z.string().uuid(),
email: z.string(),
role: z.string(),
@@ -30,3 +31,25 @@ export function login(data: LoginRequest) {
export function register(data: RegisterRequest) {
return post("/auth/register", data)
}
export type RefreshResponse = {
token: string
refresh_token: string
expires_at: string
}
export async function refreshToken(
refresh_token: string,
): Promise<RefreshResponse> {
const res = await fetch(`${API_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token }),
})
if (!res.ok) throw new Error("refresh failed")
return res.json()
}
export function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token })
}

View File

@@ -1,4 +1,4 @@
import { clearAuth, getToken } from "@/lib/auth"
import { clearAuth, getAuth, getToken, setAuth } from "@/lib/auth"
export const API_URL = import.meta.env.VITE_API_URL ?? ""
@@ -42,6 +42,31 @@ function buildUrl(
return qs ? `${base}?${qs}` : base
}
let refreshPromise: Promise<boolean> | null = null
async function tryRefresh(): Promise<boolean> {
const auth = getAuth()
if (!auth?.refresh_token) return false
try {
const res = await fetch(buildUrl("/auth/refresh"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: auth.refresh_token }),
})
if (!res.ok) return false
const data = await res.json()
setAuth({
...auth,
token: data.token,
refresh_token: data.refresh_token,
expires_at: data.expires_at,
})
return true
} catch {
return false
}
}
async function request<T = void>(
url: string,
options?: RequestInit,
@@ -55,8 +80,36 @@ async function request<T = void>(
})
if (!res.ok) {
if (res.status === 401) {
clearAuth()
window.location.href = "/app/login"
if (url.includes("/auth/refresh") || url.includes("/auth/login")) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
if (!refreshPromise) {
refreshPromise = tryRefresh().finally(() => {
refreshPromise = null
})
}
const ok = await refreshPromise
if (!ok) {
clearAuth()
window.location.href = "/app/login"
throw new ApiError(res.status, await res.text())
}
const retryRes = await fetch(url, {
...options,
headers: {
...authHeaders(),
...options?.headers,
},
})
if (!retryRes.ok) {
throw new ApiError(retryRes.status, await retryRes.text())
}
const retryText = await retryRes.text()
return retryText ? JSON.parse(retryText) : (undefined as T)
}
throw new ApiError(res.status, await res.text())
}
@@ -118,3 +171,17 @@ export async function upload<T>(
body: form,
})
}
export async function uploadWithFields<T>(
path: string,
file: File,
fields: Record<string, string>,
): Promise<T> {
const form = new FormData()
form.append("file", file)
for (const [k, v] of Object.entries(fields)) form.append(k, v)
return request<T>(buildUrl(path), {
method: "POST",
body: form,
})
}

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { del, get, post, put, upload } from "./client"
import { del, get, post, put, uploadWithFields } from "./client"
export const sessionCreatedResponseSchema = z.object({
session_id: z.string(),
@@ -41,7 +41,9 @@ export const saveProfileRequestSchema = z.object({
export type SaveProfileRequest = z.infer<typeof saveProfileRequestSchema>
export function createImportSession(file: File) {
return upload<SessionCreatedResponse>("/import/sessions", file)
const ext = file.name.split(".").pop()?.toLowerCase()
const format = ext === "json" ? "json" : "csv"
return uploadWithFields<SessionCreatedResponse>("/import/sessions", file, { format })
}
export function getImportSession(id: string) {
@@ -76,14 +78,24 @@ export function confirmImport(sessionId: string, data: ConfirmRequest) {
return post(`/import/sessions/${sessionId}/confirm`, data)
}
export type ImportProfile = {
id: string
name: string
created_at: string
}
export function getImportProfiles() {
return get<unknown[]>("/import/profiles")
return get<ImportProfile[]>("/import/profiles")
}
export function saveImportProfile(data: SaveProfileRequest) {
return post("/import/profiles", data)
return post<{ id: string }>("/import/profiles", data)
}
export function deleteImportProfile(id: string) {
return del(`/import/profiles/${id}`)
}
export function applyImportProfile(sessionId: string, profileId: string) {
return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
}

View File

@@ -45,6 +45,14 @@ export const personDtoSchema = z.object({
name: z.string(),
known_for_department: z.string().optional(),
profile_path: z.string().optional(),
biography: z.string().optional(),
birthday: z.string().optional(),
deathday: z.string().optional(),
place_of_birth: z.string().optional(),
also_known_as: z.array(z.string()).default([]),
homepage: z.string().optional(),
imdb_url: z.string().optional(),
enriched: z.boolean().default(false),
})
export type PersonDto = z.infer<typeof personDtoSchema>

View File

@@ -21,6 +21,7 @@ export const userProfileQueryParamsSchema = z.object({
view: z.string().optional(),
limit: z.number().optional(),
offset: z.number().optional(),
search: z.string().optional(),
})
export type UserProfileQueryParams = z.infer<typeof userProfileQueryParamsSchema>

View File

@@ -2,6 +2,7 @@ const AUTH_KEY = "auth_state"
export type AuthState = {
token: string
refresh_token: string
user_id: string
email: string
role: string
@@ -30,6 +31,10 @@ export function getToken(): string | null {
return getAuth()?.token ?? null
}
export function getRefreshToken(): string | null {
return getAuth()?.refresh_token ?? null
}
export function isAdmin(): boolean {
return getAuth()?.role === "admin"
}

View File

@@ -23,7 +23,9 @@
"run": "Run",
"reviews": "{{count}} reviews",
"films": "{{count}} films",
"filmsAvg": "{{count}} films, avg {{avg}}"
"filmsAvg": "{{count}} films, avg {{avg}}",
"more": "Show more",
"less": "Show less"
},
"nav": {
"home": "Home",
@@ -122,7 +124,19 @@
"noEntries": "No entries",
"noTrends": "No trends yet",
"topDirectors": "Top Directors",
"monthlyActivity": "Monthly Activity"
"monthlyActivity": "Monthly Activity",
"searchPlaceholder": "Search entries...",
"watchedAgo": "Watched {{when}}"
},
"person": {
"biography": "Biography",
"birthday": "Born",
"deathday": "Died",
"placeOfBirth": "Birthplace",
"alsoKnownAs": "Also Known As",
"links": "Links",
"homepage": "Homepage",
"imdb": "IMDb"
},
"social": {
"title": "Social",
@@ -143,7 +157,7 @@
"editProfile": "Edit Profile",
"editProfileDesc": "Username, bio",
"import": "Import",
"importDesc": "Import from CSV",
"importDesc": "Import from CSV or JSON",
"yearWrapUp": "Year Wrap-Up",
"yearWrapUpDesc": "Annual summaries",
"webhookTokens": "Webhook Tokens",
@@ -163,7 +177,12 @@
"rebuildSearchDone": "Reindex queued",
"privacy": "Privacy",
"federateGoals": "Share goals on Fediverse",
"federateGoalsDesc": "Broadcast goal progress to followers"
"federateGoalsDesc": "Broadcast goal progress to followers",
"export": "Export",
"exportDesc": "Download your diary",
"exportCsv": "CSV",
"exportJson": "JSON",
"exporting": "Exporting..."
},
"goals": {
"yearGoal": "{{year}} Goal",
@@ -331,7 +350,7 @@
"scale1to10": "110 → 15 (×0.5)",
"scale1to100": "1100 → 15 (×0.05)",
"scaleLetterboxd": "04 Letterboxd → 15 (×1.25)",
"dropCsv": "Drop a CSV file or tap to browse",
"dropCsv": "Drop a CSV or JSON file, or tap to browse",
"uploading": "Uploading...",
"preview": "Preview",
"rowsCols": "{{rows}} rows · {{cols}} columns",
@@ -351,6 +370,14 @@
"importing": "Importing...",
"importRows": "Import {{count}} rows",
"importComplete": "Import complete!",
"viewDiary": "View your diary"
"viewDiary": "View your diary",
"presets": "Presets",
"loadPreset": "Load preset",
"savePreset": "Save as preset",
"presetName": "Preset name",
"presetNamePlaceholder": "e.g. Letterboxd",
"presetSaved": "Preset saved",
"presetDeleted": "Preset deleted",
"noPresets": "No saved presets"
}
}

View File

@@ -23,6 +23,7 @@ import { Route as AppUsersIdRouteImport } from './routes/_app/users.$id'
import { Route as AppSettingsWrapupRouteImport } from './routes/_app/settings/wrapup'
import { Route as AppSettingsWebhooksRouteImport } from './routes/_app/settings/webhooks'
import { Route as AppSettingsImportRouteImport } from './routes/_app/settings/import'
import { Route as AppSettingsExportRouteImport } from './routes/_app/settings/export'
import { Route as AppSettingsEditProfileRouteImport } from './routes/_app/settings/edit-profile'
import { Route as AppSettingsBlockedRouteImport } from './routes/_app/settings/blocked'
import { Route as AppPeopleIdRouteImport } from './routes/_app/people.$id'
@@ -97,6 +98,11 @@ const AppSettingsImportRoute = AppSettingsImportRouteImport.update({
path: '/settings/import',
getParentRoute: () => AppRoute,
} as any)
const AppSettingsExportRoute = AppSettingsExportRouteImport.update({
id: '/settings/export',
path: '/settings/export',
getParentRoute: () => AppRoute,
} as any)
const AppSettingsEditProfileRoute = AppSettingsEditProfileRouteImport.update({
id: '/settings/edit-profile',
path: '/settings/edit-profile',
@@ -130,6 +136,7 @@ export interface FileRoutesByFullPath {
'/people/$id': typeof AppPeopleIdRoute
'/settings/blocked': typeof AppSettingsBlockedRoute
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
'/settings/export': typeof AppSettingsExportRoute
'/settings/import': typeof AppSettingsImportRoute
'/settings/webhooks': typeof AppSettingsWebhooksRoute
'/settings/wrapup': typeof AppSettingsWrapupRoute
@@ -149,6 +156,7 @@ export interface FileRoutesByTo {
'/people/$id': typeof AppPeopleIdRoute
'/settings/blocked': typeof AppSettingsBlockedRoute
'/settings/edit-profile': typeof AppSettingsEditProfileRoute
'/settings/export': typeof AppSettingsExportRoute
'/settings/import': typeof AppSettingsImportRoute
'/settings/webhooks': typeof AppSettingsWebhooksRoute
'/settings/wrapup': typeof AppSettingsWrapupRoute
@@ -170,6 +178,7 @@ export interface FileRoutesById {
'/_app/people/$id': typeof AppPeopleIdRoute
'/_app/settings/blocked': typeof AppSettingsBlockedRoute
'/_app/settings/edit-profile': typeof AppSettingsEditProfileRoute
'/_app/settings/export': typeof AppSettingsExportRoute
'/_app/settings/import': typeof AppSettingsImportRoute
'/_app/settings/webhooks': typeof AppSettingsWebhooksRoute
'/_app/settings/wrapup': typeof AppSettingsWrapupRoute
@@ -191,6 +200,7 @@ export interface FileRouteTypes {
| '/people/$id'
| '/settings/blocked'
| '/settings/edit-profile'
| '/settings/export'
| '/settings/import'
| '/settings/webhooks'
| '/settings/wrapup'
@@ -210,6 +220,7 @@ export interface FileRouteTypes {
| '/people/$id'
| '/settings/blocked'
| '/settings/edit-profile'
| '/settings/export'
| '/settings/import'
| '/settings/webhooks'
| '/settings/wrapup'
@@ -230,6 +241,7 @@ export interface FileRouteTypes {
| '/_app/people/$id'
| '/_app/settings/blocked'
| '/_app/settings/edit-profile'
| '/_app/settings/export'
| '/_app/settings/import'
| '/_app/settings/webhooks'
| '/_app/settings/wrapup'
@@ -344,6 +356,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsImportRouteImport
parentRoute: typeof AppRoute
}
'/_app/settings/export': {
id: '/_app/settings/export'
path: '/settings/export'
fullPath: '/settings/export'
preLoaderRoute: typeof AppSettingsExportRouteImport
parentRoute: typeof AppRoute
}
'/_app/settings/edit-profile': {
id: '/_app/settings/edit-profile'
path: '/settings/edit-profile'
@@ -385,6 +404,7 @@ interface AppRouteChildren {
AppPeopleIdRoute: typeof AppPeopleIdRoute
AppSettingsBlockedRoute: typeof AppSettingsBlockedRoute
AppSettingsEditProfileRoute: typeof AppSettingsEditProfileRoute
AppSettingsExportRoute: typeof AppSettingsExportRoute
AppSettingsImportRoute: typeof AppSettingsImportRoute
AppSettingsWebhooksRoute: typeof AppSettingsWebhooksRoute
AppSettingsWrapupRoute: typeof AppSettingsWrapupRoute
@@ -403,6 +423,7 @@ const AppRouteChildren: AppRouteChildren = {
AppPeopleIdRoute: AppPeopleIdRoute,
AppSettingsBlockedRoute: AppSettingsBlockedRoute,
AppSettingsEditProfileRoute: AppSettingsEditProfileRoute,
AppSettingsExportRoute: AppSettingsExportRoute,
AppSettingsImportRoute: AppSettingsImportRoute,
AppSettingsWebhooksRoute: AppSettingsWebhooksRoute,
AppSettingsWrapupRoute: AppSettingsWrapupRoute,

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { BookOpen, ChevronLeft, ChevronRight, Download } from "lucide-react"
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
import { format, startOfMonth, subMonths } from "date-fns"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
@@ -10,8 +10,7 @@ import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
import { API_URL } from "@/lib/api/client"
import { getToken } from "@/lib/auth"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { DiaryEntryDto } from "@/lib/api/common"
export const Route = createFileRoute("/_app/diary")({
@@ -29,6 +28,7 @@ function groupByDate(items: DiaryEntryDto[]) {
function DiaryPage() {
const { t } = useTranslation()
useDocumentTitle(t("diary.title"))
const [month, setMonth] = useState(() => startOfMonth(new Date()))
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "desc" })
@@ -75,28 +75,7 @@ function DiaryPage() {
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={async () => {
const res = await fetch(`${API_URL}/api/v1/diary/export?format=csv`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "diary.csv"
a.click()
URL.revokeObjectURL(url)
}}
>
<Download className="size-4" />
</Button>
</div>
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
<div className="flex items-center justify-between rounded-xl bg-card px-3 py-2">
<Button variant="ghost" size="icon" onClick={goBack} disabled={!canGoBack}>

View File

@@ -5,6 +5,7 @@ import { BackButton } from "@/components/back-button"
import { StarDisplay } from "@/components/star-display"
import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state"
import { HorizontalStrip } from "@/components/horizontal-strip"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
@@ -12,6 +13,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { timeAgo, shortDate } from "@/lib/date"
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
import { useDocumentTitle } from "@/hooks/use-document-title"
import {
useWatchlistStatus,
useAddToWatchlist,
@@ -29,6 +31,7 @@ function MovieDetailPage() {
const { data, isPending } = useMovie(id)
const { data: profile } = useMovieProfile(id)
const { data: history } = useMovieHistory(id)
useDocumentTitle(data?.movie.title)
if (isPending) return <DetailSkeleton />
if (!data) return null
@@ -229,7 +232,7 @@ function HeroSection({
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
return (
<div className="-mx-4 flex gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
<HorizontalStrip gap="gap-2.5">
{items.map((person, i) => {
const subtitle = type === "cast"
? (person as CastMemberDto).character
@@ -251,7 +254,7 @@ function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]
</Link>
)
})}
</div>
</HorizontalStrip>
)
}

View File

@@ -1,12 +1,22 @@
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Film, User } from "lucide-react"
import { Calendar, ChevronDown, ExternalLink, Film, Globe, MapPin, User } from "lucide-react"
import { Link } from "@tanstack/react-router"
import { BackButton } from "@/components/back-button"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { HorizontalStrip } from "@/components/horizontal-strip"
import { SwipeTabs } from "@/components/swipe-tabs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { tmdbProfileUrl } from "@/lib/api/client"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { usePersonCredits } from "@/hooks/use-search"
import { useDocumentTitle } from "@/hooks/use-document-title"
import { shortDate } from "@/lib/date"
import { differenceInYears, parseISO } from "date-fns"
export const Route = createFileRoute("/_app/people/$id")({
component: PersonDetailPage,
@@ -16,100 +26,216 @@ function PersonDetailPage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { data, isPending } = usePersonCredits(id)
useDocumentTitle(data?.person.name)
if (isPending) return <PersonSkeleton />
if (!data) return null
const { person, cast, crew } = data
const age = person.birthday
? differenceInYears(
person.deathday ? parseISO(person.deathday) : new Date(),
parseISO(person.birthday),
)
: null
const creditTabs = [
...(cast.length > 0 ? [{ value: "cast", label: t("movie.cast") + ` (${cast.length})` }] : []),
...(crew.length > 0 ? [{ value: "crew", label: t("movie.crew") + ` (${crew.length})` }] : []),
] as const
return (
<div className="space-y-4 p-4">
<BackButton />
<div className="flex items-center gap-4">
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
{/* Header */}
<div className="flex gap-4">
<div className="size-24 flex-shrink-0 overflow-hidden rounded-xl bg-muted">
{person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center">
<User className="size-6 text-muted-foreground/40" />
<User className="size-10 text-muted-foreground/40" />
</div>
)}
</div>
<div>
<div className="min-w-0 flex-1 space-y-1">
<h1 className="text-xl font-bold">{person.name}</h1>
{person.known_for_department && (
<p className="text-sm text-muted-foreground">{person.known_for_department}</p>
<Badge variant="secondary">{person.known_for_department}</Badge>
)}
{(person.homepage || person.imdb_url) && (
<div className="flex gap-1.5 pt-1">
{person.imdb_url && (
<a href={person.imdb_url} target="_blank" rel="noopener noreferrer">
<Badge variant="outline" className="gap-1 text-[10px]">
<ExternalLink className="size-2.5" />
IMDb
</Badge>
</a>
)}
{person.homepage && (
<a href={person.homepage} target="_blank" rel="noopener noreferrer">
<Badge variant="outline" className="gap-1 text-[10px]">
<Globe className="size-2.5" />
{t("person.homepage")}
</Badge>
</a>
)}
</div>
)}
</div>
</div>
{cast.length > 0 && (
<section>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("movie.castCredits", { count: cast.length })}
</h2>
<div className="space-y-1">
{cast.map((c) => (
<MovieCard
key={`${c.movie_id}-${c.character}`}
movie={{
id: c.movie_id,
title: c.title,
release_year: c.release_year ?? 0,
poster_path: c.poster_path,
genres: [],
}}
subtitle={c.character}
variant="compact"
/>
))}
</div>
</section>
{/* Details card */}
{(person.birthday || person.place_of_birth) && (
<Card size="sm">
<CardContent className="space-y-2">
{person.birthday && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="size-3.5 text-muted-foreground" />
<span>{shortDate(person.birthday)}</span>
{age != null && (
<span className="text-muted-foreground">({age})</span>
)}
</div>
)}
{person.deathday && (
<div className="flex items-center gap-2 text-sm">
<Calendar className="size-3.5 text-muted-foreground" />
<span>{shortDate(person.deathday)}</span>
<span className="text-muted-foreground">({t("person.deathday").toLowerCase()})</span>
</div>
)}
{person.place_of_birth && (
<div className="flex items-center gap-2 text-sm">
<MapPin className="size-3.5 text-muted-foreground" />
<span>{person.place_of_birth}</span>
</div>
)}
</CardContent>
</Card>
)}
{crew.length > 0 && (
<section>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("movie.crewCredits", { count: crew.length })}
</h2>
<div className="space-y-1">
{crew.map((c) => (
<MovieCard
key={`${c.movie_id}-${c.job}`}
movie={{
id: c.movie_id,
title: c.title,
release_year: c.release_year ?? 0,
poster_path: c.poster_path,
genres: [],
}}
subtitle={`${c.job} (${c.department})`}
variant="compact"
/>
))}
</div>
</section>
{/* Biography */}
{person.biography && <BiographySection text={person.biography} />}
{/* Also known as */}
{person.also_known_as?.length > 0 && (
<Card size="sm">
<CardHeader>
<CardTitle className="text-xs">{t("person.alsoKnownAs")}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1">
{person.also_known_as.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{cast.length === 0 && crew.length === 0 && (
<Separator />
{/* Filmography */}
{creditTabs.length > 0 ? (
<SwipeTabs tabs={creditTabs} defaultValue={creditTabs[0].value} tabsListClassName="w-full">
{(tab) => (
<>
{tab === "cast" && <FilmStrip items={cast.map((c) => ({ movieId: c.movie_id, title: c.title, year: c.release_year, posterPath: c.poster_path, subtitle: c.character }))} />}
{tab === "crew" && <FilmStrip items={crew.map((c) => ({ movieId: c.movie_id, title: c.title, year: c.release_year, posterPath: c.poster_path, subtitle: `${c.job}` }))} />}
</>
)}
</SwipeTabs>
) : (
<EmptyState icon={Film} title={t("movie.noCredits")} />
)}
</div>
)
}
type FilmStripItem = {
movieId: string
title: string
year?: number
posterPath?: string
subtitle: string
}
function FilmStrip({ items }: { items: FilmStripItem[] }) {
return (
<HorizontalStrip>
{items.map((item, i) => (
<Link key={`${item.movieId}-${i}`} to="/movies/$id" params={{ id: item.movieId }} className="w-28 flex-shrink-0">
<div className="aspect-[2/3] overflow-hidden rounded-xl bg-muted">
{item.posterPath ? (
<img src={posterUrl(item.posterPath)} alt="" className="size-full object-cover" loading="lazy" />
) : (
<div className="flex size-full items-center justify-center">
<Film className="size-6 text-muted-foreground/40" />
</div>
)}
</div>
<p className="mt-1.5 truncate text-xs font-semibold">{item.title}</p>
{item.year && <p className="text-[10px] text-muted-foreground">{item.year}</p>}
<p className="truncate text-[10px] italic text-muted-foreground">{item.subtitle}</p>
</Link>
))}
</HorizontalStrip>
)
}
const BIO_COLLAPSE_THRESHOLD = 300
function BiographySection({ text }: { text: string }) {
const { t } = useTranslation()
const isLong = text.length > BIO_COLLAPSE_THRESHOLD
const [expanded, setExpanded] = useState(!isLong)
return (
<Card size="sm">
<CardHeader>
<CardTitle className="text-xs">{t("person.biography")}</CardTitle>
</CardHeader>
<CardContent>
<p className={`text-sm leading-relaxed text-muted-foreground ${!expanded ? "line-clamp-4" : ""}`}>
{text}
</p>
{isLong && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-auto p-0 text-xs text-primary"
onClick={() => setExpanded((v) => !v)}
>
<ChevronDown className={`mr-1 size-3 transition-transform ${expanded ? "rotate-180" : ""}`} />
{expanded ? t("common.less") : t("common.more")}
</Button>
)}
</CardContent>
</Card>
)
}
function PersonSkeleton() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-5 w-16" />
<div className="flex items-center gap-4">
<Skeleton className="size-16 rounded-full" />
<div className="space-y-2">
<div className="flex gap-4">
<Skeleton className="size-24 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<Skeleton className="h-20 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-1 w-full" />
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 rounded-lg" />

View File

@@ -11,6 +11,7 @@ import { useDeleteGoal } from "@/hooks/use-goals"
import { GoalCard } from "@/components/goal-card"
import { GoalSheet } from "@/components/goal-sheet"
import { toast } from "sonner"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { GoalDto } from "@/lib/api/users"
export const Route = createFileRoute("/_app/profile")({
@@ -20,10 +21,13 @@ export const Route = createFileRoute("/_app/profile")({
function ProfilePage() {
const { t } = useTranslation()
const { auth } = useAuth()
useDocumentTitle(t("profile.title"))
const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
view: "trends",
})
const [search, setSearch] = useState("")
if (!auth) return null
if (isPending) return <ProfileSkeleton />
if (!data) return null
@@ -39,6 +43,8 @@ function ProfilePage() {
<ProfileView
data={data}
search={search}
onSearchChange={setSearch}
actions={
<>
<GoalSection goals={data.goals ?? []} />

View File

@@ -11,6 +11,7 @@ import { InfiniteScroll } from "@/components/infinite-scroll"
import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteSearch } from "@/hooks/use-search"
import { useDebounce } from "@/hooks/use-debounce"
import { useDocumentTitle } from "@/hooks/use-document-title"
import { useAddToWatchlist } from "@/hooks/use-watchlist"
import { toast } from "sonner"
@@ -20,6 +21,7 @@ export const Route = createFileRoute("/_app/search")({
function SearchPage() {
const { t } = useTranslation()
useDocumentTitle(t("search.placeholder"))
const addToWatchlist = useAddToWatchlist()
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300)

View File

@@ -15,6 +15,7 @@ import {
useAddBlockedDomain,
useRemoveBlockedDomain,
} from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/blocked")({
component: BlockedPage,
@@ -22,6 +23,7 @@ export const Route = createFileRoute("/_app/settings/blocked")({
function BlockedPage() {
const { t } = useTranslation()
useDocumentTitle(t("blocked.title"))
const isAdmin = useIsAdmin()
return (

View File

@@ -10,6 +10,7 @@ import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/edit-profile")({
component: EditProfilePage,
@@ -17,6 +18,7 @@ export const Route = createFileRoute("/_app/settings/edit-profile")({
function EditProfilePage() {
const { t } = useTranslation()
useDocumentTitle(t("editProfile.title"))
const { data, isPending } = useProfile()
const update = useUpdateProfile()
const updateFields = useUpdateProfileFields()

View 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>
)
}

View File

@@ -1,7 +1,8 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, CheckCircle, Upload } from "lucide-react"
import { ArrowLeft, CheckCircle, Trash2, Upload } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
@@ -25,9 +26,14 @@ import { Skeleton } from "@/components/ui/skeleton"
import {
useCreateImportSession,
useApplyMapping,
useApplyImportProfile,
useConfirmImport,
useImportPreview,
useImportProfiles,
useSaveImportProfile,
useDeleteImportProfile,
} from "@/hooks/use-imports"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { SessionCreatedResponse } from "@/lib/api/imports"
export const Route = createFileRoute("/_app/settings/import")({
@@ -36,6 +42,7 @@ export const Route = createFileRoute("/_app/settings/import")({
function ImportPage() {
const { t } = useTranslation()
useDocumentTitle(t("import.title"))
const DOMAIN_FIELDS = [
{ value: "title", label: t("import.fieldTitle") },
@@ -63,7 +70,10 @@ function ImportPage() {
const createSession = useCreateImportSession()
const applyMapping = useApplyMapping()
const applyProfile = useApplyImportProfile()
const confirmImport = useConfirmImport()
const { data: profiles } = useImportProfiles()
const deleteProfile = useDeleteImportProfile()
const handleFile = (file: File) => {
createSession.mutate(file, {
@@ -113,6 +123,14 @@ function ImportPage() {
)
}
const handleApplyProfile = (profileId: string) => {
if (!session) return
applyProfile.mutate(
{ sessionId: session.session_id, profileId },
{ onSuccess: () => setStep(2) },
)
}
const hasRatingMapping = Object.values(mappings).includes("rating")
return (
@@ -160,7 +178,7 @@ function ImportPage() {
<input
ref={fileRef}
type="file"
accept=".csv"
accept=".csv,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
@@ -212,6 +230,40 @@ function ImportPage() {
</CardContent>
</Card>
{/* Presets */}
{profiles && profiles.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.presets")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{profiles.map((p) => (
<div key={p.id} className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start"
onClick={() => handleApplyProfile(p.id)}
disabled={applyProfile.isPending}
>
{p.name}
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => deleteProfile.mutate(p.id, {
onSuccess: () => toast.success(t("import.presetDeleted")),
})}
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</CardContent>
</Card>
)}
{/* Column mapping */}
<Card>
<CardHeader>
@@ -330,6 +382,8 @@ function ConfirmStep({
}) {
const { t } = useTranslation()
const { data, isPending: previewLoading } = useImportPreview(sessionId)
const saveProfile = useSaveImportProfile()
const [presetName, setPresetName] = useState("")
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
@@ -385,6 +439,33 @@ function ConfirmStep({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.savePreset")}</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder={t("import.presetNamePlaceholder")}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
disabled={!presetName.trim() || saveProfile.isPending || saveProfile.isSuccess}
onClick={() =>
saveProfile.mutate(
{ session_id: sessionId, name: presetName.trim() },
{ onSuccess: () => toast.success(t("import.presetSaved")) },
)
}
>
{saveProfile.isSuccess ? t("import.presetSaved") : t("common.save")}
</Button>
</CardContent>
</Card>
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
</Button>

View File

@@ -11,6 +11,7 @@ import {
ShieldBan,
Sparkles,
Target,
Upload,
User,
} from "lucide-react"
import { Button } from "@/components/ui/button"
@@ -18,6 +19,7 @@ import { Switch } from "@/components/ui/switch"
import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { reindexSearch } from "@/lib/api/users"
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({
component: SettingsPage,
@@ -32,6 +34,7 @@ type SettingsItem = {
function SettingsPage() {
const { t } = useTranslation()
useDocumentTitle(t("settings.title"))
const { logout } = useAuth()
const isAdmin = useIsAdmin()
const navigate = useNavigate()
@@ -50,6 +53,12 @@ function SettingsPage() {
label: t("settings.import"),
description: t("settings.importDesc"),
to: "/settings/import",
icon: <Upload className="size-4" />,
},
{
label: t("settings.export"),
description: t("settings.exportDesc"),
to: "/settings/export",
icon: <Download className="size-4" />,
},
{

View File

@@ -28,6 +28,7 @@ import {
useDeleteToken,
} from "@/hooks/use-webhooks"
import { API_URL } from "@/lib/api/client"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/webhooks")({
component: WebhooksPage,
@@ -35,6 +36,7 @@ export const Route = createFileRoute("/_app/settings/webhooks")({
function WebhooksPage() {
const { t } = useTranslation()
useDocumentTitle(t("webhooks.title"))
const { data: tokens, isPending } = useWebhookTokens()
const generate = useGenerateToken()
const remove = useDeleteToken()

View File

@@ -23,6 +23,7 @@ import {
useDeleteWrapUp,
} from "@/hooks/use-wrapup"
import { useUsers } from "@/hooks/use-users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/wrapup")({
component: WrapupPage,
@@ -30,6 +31,7 @@ export const Route = createFileRoute("/_app/settings/wrapup")({
function WrapupPage() {
const { t } = useTranslation()
useDocumentTitle(t("wrapup.title"))
const { auth } = useAuth()
const isAdmin = useIsAdmin()
const { data, isPending } = useWrapUps()

View File

@@ -23,6 +23,7 @@ import {
useUserFollowing,
useUserFollowers,
} from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { RemoteActorDto } from "@/lib/api/social"
type SearchParams = { user?: string }
@@ -36,6 +37,7 @@ export const Route = createFileRoute("/_app/social")({
function SocialPage() {
const { t } = useTranslation()
useDocumentTitle(t("social.title"))
const { user: viewUserId } = Route.useSearch()
const { auth } = useAuth()
const isSelf = !viewUserId || viewUserId === auth?.user_id

View File

@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { UserCheck, UserPlus } from "lucide-react"
import { BackButton } from "@/components/back-button"
@@ -8,6 +9,7 @@ import { GoalCard } from "@/components/goal-card"
import { useAuth } from "@/components/auth-provider"
import { useUserProfile } from "@/hooks/use-users"
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/users/$id")({
component: UserProfilePage,
@@ -22,9 +24,11 @@ function UserProfilePage() {
const followMutation = useFollow()
const unfollowMutation = useUnfollow()
const [search, setSearch] = useState("")
useDocumentTitle(data?.username)
if (isPending) return <ProfileSkeleton />
if (!data) return null
const isSelf = auth?.user_id === id
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
@@ -35,6 +39,8 @@ function UserProfilePage() {
<ProfileView
data={data}
userId={id}
search={search}
onSearchChange={setSearch}
actions={
data.goals?.length ? (
<div className="space-y-2">

View File

@@ -17,6 +17,7 @@ import { RankCard } from "@/components/wrapup-rank-card"
import { posterUrl } from "@/lib/api/client"
import { fmtUsd } from "@/lib/format"
import { useWrapUpReport } from "@/hooks/use-wrapup"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { MovieRef } from "@/lib/api/wrapup"
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
@@ -39,6 +40,7 @@ function WrapUpReportPage() {
const { data: report, isPending } = useWrapUpReport(id)
const [showShare, setShowShare] = useState(false)
useDocumentTitle(report ? `${report.date_range.start.slice(0, 4)} Wrap-Up` : undefined)
if (isPending) return <ReportSkeleton />
if (!report) return null