feat: goals — "watch N movies in YEAR" with progress bar

Domain: Goal entity, UserSettings (federation toggle), RemoteGoalEntry.
Ports: GoalRepository, UserSettingsRepository, RemoteGoalRepository.
Adapters: sqlite + postgres repos, migrations, AP content query extensions.
Application: CRUD use cases (create/update/delete/get/list), settings use cases.
API: 7 endpoints (/goals CRUD, /users/{id}/goals, /settings) with utoipa docs.
Federation: GoalObject (Note + goal discriminator), outbound broadcast with
per-user toggle, inbound GoalObjectHandler in CompositeObjectHandler.
SPA: API client + hooks, GoalCard (shadcn Card+Progress+DropdownMenu),
GoalSheet (Drawer), profile integration (editable own, read-only others),
federation toggle in settings (Switch).
Classic HTML: glassmorphic goal card on profile, Frutiger Aero styling.
Progress computed from existing reviews — backwards compatible.
This commit is contained in:
2026-06-08 22:37:52 +02:00
parent 213f9a2433
commit fff5f4af2f
67 changed files with 2747 additions and 28 deletions

View File

@@ -5,11 +5,15 @@ use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url;
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
use crate::{
goal_handler::GoalObjectHandler, review_handler::ReviewObjectHandler,
watchlist_handler::WatchlistObjectHandler,
};
pub struct CompositeObjectHandler {
pub review: Arc<ReviewObjectHandler>,
pub watchlist: Arc<WatchlistObjectHandler>,
pub goal: Arc<GoalObjectHandler>,
}
#[async_trait]
@@ -40,8 +44,11 @@ impl ApObjectHandler for CompositeObjectHandler {
) -> anyhow::Result<()> {
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|| (object.get("movieTitle").is_some() && object.get("rating").is_none());
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
self.review.on_create(ap_id, actor_url, object).await
} else if is_goal {
self.goal.on_create(ap_id, actor_url, object).await
} else if is_watchlist {
self.watchlist.on_create(ap_id, actor_url, object).await
} else {
@@ -56,8 +63,11 @@ impl ApObjectHandler for CompositeObjectHandler {
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
if object.get("rating").is_some() {
self.review.on_update(ap_id, actor_url, object).await
} else if is_goal {
self.goal.on_update(ap_id, actor_url, object).await
} else {
Ok(())
}
@@ -66,12 +76,14 @@ impl ApObjectHandler for CompositeObjectHandler {
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_delete(ap_id, actor_url).await?;
self.watchlist.on_delete(ap_id, actor_url).await?;
self.goal.on_delete(ap_id, actor_url).await?;
Ok(())
}
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
self.review.on_actor_removed(actor_url).await?;
self.watchlist.on_actor_removed(actor_url).await?;
self.goal.on_actor_removed(actor_url).await?;
Ok(())
}

View File

@@ -10,8 +10,8 @@ use std::sync::Arc;
use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::review_to_ap_object;
use crate::urls::{actor_url, review_url};
use crate::objects::{goal_to_ap_object, review_to_ap_object};
use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
@@ -101,6 +101,28 @@ impl EventHandler for ActivityPubEventHandler {
.on_poster_synced(movie_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalCreated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, true)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalUpdated {
user_id,
year,
target_count,
..
} => self
.broadcast_goal(user_id, *year, *target_count, false)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::GoalDeleted { user_id, year, .. } => self
.on_goal_deleted(user_id, *year)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
_ => Ok(()),
}
}
@@ -311,4 +333,60 @@ impl ActivityPubEventHandler {
Ok(())
}
async fn broadcast_goal(
&self,
user_id: &UserId,
year: u16,
target_count: u32,
is_create: bool,
) -> anyhow::Result<()> {
if !self
.content_query
.get_user_federate_goals(user_id)
.await
.unwrap_or(false)
{
return Ok(());
}
let current = self
.content_query
.get_goal_with_progress(user_id, year)
.await
.ok()
.flatten()
.map(|(_, c)| c)
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
}
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
if !self
.content_query
.get_user_federate_goals(user_id)
.await
.unwrap_or(false)
{
return Ok(());
}
let ap_id = goal_url(&self.base_url, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,89 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository};
use k_ap::ApObjectHandler;
use url::Url;
use crate::objects::GoalObject;
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
}
#[async_trait]
impl ApObjectHandler for GoalObjectHandler {
async fn on_create(
&self,
ap_id: &Url,
actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: GoalObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Create: {}", e);
return Ok(());
}
};
let entry = RemoteGoalEntry {
ap_id: ap_id.as_str().to_string(),
actor_url: actor_url.as_str().to_string(),
year: obj.goal_year,
target_count: obj.goal_target,
current_count: obj.goal_current,
received_at: chrono::Utc::now(),
};
self.remote_goal_repo.save(entry).await?;
tracing::info!(ap_id = %ap_id, year = obj.goal_year, "saved remote goal");
Ok(())
}
async fn on_update(
&self,
ap_id: &Url,
_actor_url: &Url,
object: serde_json::Value,
) -> anyhow::Result<()> {
let obj: GoalObject = match serde_json::from_value(object) {
Ok(o) => o,
Err(e) => {
tracing::warn!(ap_id = %ap_id, "ignoring malformed goal Update: {}", e);
return Ok(());
}
};
self.remote_goal_repo
.update_by_ap_id(ap_id.as_str(), obj.goal_target, obj.goal_current)
.await?;
tracing::info!(ap_id = %ap_id, "updated remote goal progress");
Ok(())
}
async fn on_delete(&self, ap_id: &Url, actor_url: &Url) -> anyhow::Result<()> {
self.remote_goal_repo
.remove_by_ap_id(ap_id.as_str(), actor_url.as_str())
.await?;
tracing::info!(ap_id = %ap_id, "removed remote goal");
Ok(())
}
async fn on_actor_removed(&self, _actor_url: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
Ok(())
}
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -1,6 +1,7 @@
pub mod composite_handler;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod goal_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
@@ -48,6 +49,7 @@ pub struct ActivityPubDeps {
pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub base_url: String,
@@ -63,6 +65,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
blocklist_repo,
review_store,
remote_watchlist_repo,
remote_goal_repo,
local_ap_content,
user_repo,
base_url,
@@ -79,9 +82,11 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
content_query: std::sync::Arc::clone(&local_ap_content),
base_url: base_url.clone(),
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo });
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
watchlist: watchlist_handler,
goal: goal_handler,
});
let federation_debug = std::env::var("FEDERATION_DEBUG")

View File

@@ -191,6 +191,64 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
}
}
// ── Goal object ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GoalObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
pub(crate) published: chrono::DateTime<chrono::Utc>,
pub(crate) goal_year: u16,
pub(crate) goal_target: u32,
pub(crate) goal_current: u32,
#[serde(default)]
pub(crate) goal: bool,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) to: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub(crate) cc: Vec<String>,
}
pub fn goal_to_ap_object(
ap_id: Url,
actor_url: Url,
year: u16,
target: u32,
current: u32,
base_url: &str,
) -> GoalObject {
let content = format!(
"🎯 Goal: Watch {} movies in {} ({}/{})",
target, year, current, target
);
let tag = vec![ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
name: "#MoviesDiary".to_string(),
}];
GoalObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
published: chrono::Utc::now(),
goal_year: year,
goal_target: target,
goal_current: current,
goal: true,
tag,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
}
}
#[cfg(test)]
#[path = "tests/objects.rs"]
mod tests;

View File

@@ -13,6 +13,11 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
.expect("base_url is always a valid URL prefix")
}
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
Url::parse(&format!(

View File

@@ -2,7 +2,9 @@ use chrono::NaiveDateTime;
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId},
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -90,6 +92,23 @@ pub enum EventPayload {
PosterSynced {
movie_id: String,
},
GoalCreated {
goal_id: String,
user_id: String,
year: u16,
target_count: u32,
},
GoalUpdated {
goal_id: String,
user_id: String,
year: u16,
target_count: u32,
},
GoalDeleted {
goal_id: String,
user_id: String,
year: u16,
},
}
impl EventPayload {
@@ -113,6 +132,9 @@ impl EventPayload {
EventPayload::WrapUpCompleted { .. } => "WrapUpCompleted",
EventPayload::SearchReindexRequested => "SearchReindexRequested",
EventPayload::PosterSynced { .. } => "PosterSynced",
EventPayload::GoalCreated { .. } => "GoalCreated",
EventPayload::GoalUpdated { .. } => "GoalUpdated",
EventPayload::GoalDeleted { .. } => "GoalDeleted",
}
}
}
@@ -258,6 +280,37 @@ impl From<&DomainEvent> for EventPayload {
DomainEvent::PosterSynced { movie_id } => EventPayload::PosterSynced {
movie_id: movie_id.value().to_string(),
},
DomainEvent::GoalCreated {
goal_id,
user_id,
year,
target_count,
} => EventPayload::GoalCreated {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
target_count: *target_count,
},
DomainEvent::GoalUpdated {
goal_id,
user_id,
year,
target_count,
} => EventPayload::GoalUpdated {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
target_count: *target_count,
},
DomainEvent::GoalDeleted {
goal_id,
user_id,
year,
} => EventPayload::GoalDeleted {
goal_id: goal_id.value().to_string(),
user_id: user_id.value().to_string(),
year: *year,
},
}
}
}
@@ -412,6 +465,37 @@ impl TryFrom<EventPayload> for DomainEvent {
EventPayload::PosterSynced { movie_id } => Ok(DomainEvent::PosterSynced {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
}),
EventPayload::GoalCreated {
goal_id,
user_id,
year,
target_count,
} => Ok(DomainEvent::GoalCreated {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
target_count,
}),
EventPayload::GoalUpdated {
goal_id,
user_id,
year,
target_count,
} => Ok(DomainEvent::GoalUpdated {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
target_count,
}),
EventPayload::GoalDeleted {
goal_id,
user_id,
year,
} => Ok(DomainEvent::GoalDeleted {
goal_id: GoalId::from_uuid(parse_uuid(&goal_id, "goal_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
year,
}),
}
}
}

View File

@@ -20,6 +20,9 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::WrapUpCompleted { .. } => "wrapup.completed",
DomainEvent::SearchReindexRequested => "search.reindex.requested",
DomainEvent::PosterSynced { .. } => "poster.synced",
DomainEvent::GoalCreated { .. } => "goal.created",
DomainEvent::GoalUpdated { .. } => "goal.updated",
DomainEvent::GoalDeleted { .. } => "goal.deleted",
};
format!("{prefix}.{suffix}")
}

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS goals (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year BIGINT NOT NULL,
target_count BIGINT NOT NULL,
goal_type TEXT NOT NULL DEFAULT 'movies',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, year)
);
CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
federate_goals BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS remote_goals (
ap_id TEXT PRIMARY KEY NOT NULL,
actor_url TEXT NOT NULL,
year BIGINT NOT NULL,
target_count BIGINT NOT NULL,
current_count BIGINT NOT NULL DEFAULT 0,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_remote_goals_actor ON remote_goals(actor_url);

View File

@@ -228,4 +228,48 @@ impl LocalApContentQuery for PostgresApContentQuery {
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(domain::models::Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = crate::goals::row_to_goal(&r)?;
let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
}

View File

@@ -0,0 +1,195 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{Goal, GoalType},
ports::GoalRepository,
value_objects::{GoalId, UserId},
};
use sqlx::{PgPool, Row};
use crate::models::{datetime_to_str, parse_datetime, parse_uuid};
pub struct PostgresGoalRepository {
pool: PgPool,
}
impl PostgresGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl GoalRepository for PostgresGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
let year = goal.year() as i64;
let target = goal.target_count() as i64;
let goal_type = goal.goal_type().as_str();
let created_at = datetime_to_str(goal.created_at());
sqlx::query(
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
VALUES ($1, $2, $3, $4, $5, $6::timestamptz)",
)
.bind(&id)
.bind(&user_id)
.bind(year)
.bind(target)
.bind(goal_type)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let target = goal.target_count() as i64;
let result = sqlx::query("UPDATE goals SET target_count = $1 WHERE id = $2")
.bind(target)
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
let gid = id.value().to_string();
let uid = user_id.value().to_string();
let result = sqlx::query("DELETE FROM goals WHERE id = $1 AND user_id = $2")
.bind(&gid)
.bind(&uid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn find_by_user_and_year(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<Goal>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
row.map(|r| row_to_goal(&r)).transpose()
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY year DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(
pool: &PgPool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.unwrap_or(0);
Ok(count as u32)
}
pub(crate) fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}

View File

@@ -13,6 +13,7 @@ use domain::{
use sqlx::PgPool;
mod ap_content;
mod goals;
mod image_ref;
mod import_profile;
mod import_session;
@@ -20,6 +21,8 @@ mod models;
mod persons;
mod profile;
mod profile_fields;
mod remote_goals;
mod user_settings;
mod users;
mod watch_event;
mod watchlist;
@@ -1008,6 +1011,9 @@ pub struct PostgresWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
}
pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
@@ -1038,6 +1044,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _,
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool)) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::PostgresUserSettingsRepository::new(
pool.clone(),
)) as _,
remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool))
as _,
})
}

View File

@@ -0,0 +1,109 @@
use async_trait::async_trait;
use chrono::TimeZone;
use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository};
use sqlx::{PgPool, Row};
pub struct PostgresRemoteGoalRepository {
pool: PgPool,
}
impl PostgresRemoteGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl RemoteGoalRepository for PostgresRemoteGoalRepository {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> {
let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO remote_goals \
(ap_id, actor_url, year, target_count, current_count, received_at) \
VALUES ($1, $2, $3, $4, $5, $6::timestamptz) \
ON CONFLICT (ap_id) DO UPDATE SET \
target_count = $4, current_count = $5",
)
.bind(&entry.ap_id)
.bind(&entry.actor_url)
.bind(entry.year as i64)
.bind(entry.target_count as i64)
.bind(entry.current_count as i64)
.bind(&received)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update_by_ap_id(
&self,
ap_id: &str,
target: u32,
current: u32,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE remote_goals SET target_count = $1, current_count = $2 WHERE ap_id = $3",
)
.bind(target as i64)
.bind(current as i64)
.bind(ap_id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM remote_goals WHERE ap_id = $1 AND actor_url = $2")
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError> {
let rows = sqlx::query(
"SELECT ap_id, actor_url, year, target_count, current_count, \
to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS received_at \
FROM remote_goals WHERE actor_url = $1 ORDER BY year DESC",
)
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter()
.map(|r| {
let year: i64 = r.try_get("year").unwrap_or(0);
let target: i64 = r.try_get("target_count").unwrap_or(0);
let current: i64 = r.try_get("current_count").unwrap_or(0);
let received_str: String = r.try_get("received_at").unwrap_or_default();
let received_at =
chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S")
.map(|ndt| chrono::Utc.from_utc_datetime(&ndt))
.unwrap_or_else(|_| chrono::Utc::now());
Ok(RemoteGoalEntry {
ap_id: r.try_get("ap_id").unwrap_or_default(),
actor_url: r.try_get("actor_url").unwrap_or_default(),
year: year as u16,
target_count: target as u32,
current_count: current as u32,
received_at,
})
})
.collect()
}
}

View File

@@ -0,0 +1,62 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
};
use sqlx::{PgPool, Row};
pub struct PostgresUserSettingsRepository {
pool: PgPool,
}
impl PostgresUserSettingsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl UserSettingsRepository for PostgresUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string();
let row =
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(UserSettings::from_persistence(
user_id.clone(),
federate != 0,
))
}
None => Ok(UserSettings::new(user_id.clone())),
}
}
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 };
sqlx::query(
"INSERT INTO user_settings (user_id, federate_goals) VALUES ($1, $2) \
ON CONFLICT (user_id) DO UPDATE SET federate_goals = $2",
)
.bind(&uid)
.bind(federate)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
}

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS goals (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
target_count INTEGER NOT NULL,
goal_type TEXT NOT NULL DEFAULT 'movies',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now')),
UNIQUE(user_id, year)
);
CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE,
federate_goals INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS remote_goals (
ap_id TEXT PRIMARY KEY NOT NULL,
actor_url TEXT NOT NULL,
year INTEGER NOT NULL,
target_count INTEGER NOT NULL,
current_count INTEGER NOT NULL DEFAULT 0,
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_remote_goals_actor ON remote_goals(actor_url);

View File

@@ -1,11 +1,11 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DiaryEntry, Movie, Review, WatchlistWithMovie},
models::{DiaryEntry, Goal, Movie, Review, WatchlistWithMovie},
ports::LocalApContentQuery,
value_objects::{MovieId, ReviewId, UserId},
};
use sqlx::SqlitePool;
use sqlx::{Row, SqlitePool};
use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow};
@@ -168,4 +168,47 @@ impl LocalApContentQuery for SqliteApContentQuery {
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? AND year = ?",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = crate::goals::row_to_goal(&r)?;
let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
}

View File

@@ -0,0 +1,196 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{Goal, GoalType},
ports::GoalRepository,
value_objects::{GoalId, UserId},
};
use sqlx::{Row, SqlitePool};
pub struct SqliteGoalRepository {
pool: SqlitePool,
}
impl SqliteGoalRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl GoalRepository for SqliteGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
let year = goal.year() as i64;
let target = goal.target_count() as i64;
let goal_type = goal.goal_type().as_str();
let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&user_id)
.bind(year)
.bind(target)
.bind(goal_type)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let target = goal.target_count() as i64;
let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?")
.bind(target)
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
let gid = id.value().to_string();
let uid = user_id.value().to_string();
let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?")
.bind(&gid)
.bind(&uid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
async fn find_by_user_and_year(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<Goal>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? AND year = ?",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
row.map(|r| row_to_goal(&r)).transpose()
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? ORDER BY year DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(
pool: &SqlitePool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.unwrap_or(0);
Ok(count as u32)
}
pub(crate) fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(
uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(&user_id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = chrono::NaiveDateTime::parse_from_str(&created_at_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime: {e}")))?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}

View File

@@ -13,6 +13,7 @@ use domain::{
use sqlx::SqlitePool;
mod ap_content;
mod goals;
mod image_ref;
mod import_profile;
mod import_session;
@@ -21,6 +22,8 @@ mod models;
mod persons;
mod profile;
mod profile_fields;
mod remote_goals;
mod user_settings;
mod users;
mod watch_event;
mod watchlist;
@@ -978,6 +981,9 @@ pub struct SqliteWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
}
pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
@@ -1015,7 +1021,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
watchlist: std::sync::Arc::new(SqliteWatchlistRepository::new(pool.clone())) as _,
ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool)) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
pool.clone(),
)) as _,
remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _,
})
}

View File

@@ -0,0 +1,104 @@
use async_trait::async_trait;
use chrono::TimeZone;
use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository};
use sqlx::{Row, SqlitePool};
pub struct SqliteRemoteGoalRepository {
pool: SqlitePool,
}
impl SqliteRemoteGoalRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl RemoteGoalRepository for SqliteRemoteGoalRepository {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> {
let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT OR REPLACE INTO remote_goals \
(ap_id, actor_url, year, target_count, current_count, received_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&entry.ap_id)
.bind(&entry.actor_url)
.bind(entry.year as i64)
.bind(entry.target_count as i64)
.bind(entry.current_count as i64)
.bind(&received)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn update_by_ap_id(
&self,
ap_id: &str,
target: u32,
current: u32,
) -> Result<(), DomainError> {
sqlx::query("UPDATE remote_goals SET target_count = ?, current_count = ? WHERE ap_id = ?")
.bind(target as i64)
.bind(current as i64)
.bind(ap_id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM remote_goals WHERE ap_id = ? AND actor_url = ?")
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError> {
let rows = sqlx::query(
"SELECT ap_id, actor_url, year, target_count, current_count, received_at \
FROM remote_goals WHERE actor_url = ? ORDER BY year DESC",
)
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter()
.map(|r| {
let year: i64 = r.try_get("year").unwrap_or(0);
let target: i64 = r.try_get("target_count").unwrap_or(0);
let current: i64 = r.try_get("current_count").unwrap_or(0);
let received_str: String = r.try_get("received_at").unwrap_or_default();
let received_at =
chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S")
.map(|ndt| chrono::Utc.from_utc_datetime(&ndt))
.unwrap_or_else(|_| chrono::Utc::now());
Ok(RemoteGoalEntry {
ap_id: r.try_get("ap_id").unwrap_or_default(),
actor_url: r.try_get("actor_url").unwrap_or_default(),
year: year as u16,
target_count: target as u32,
current_count: current as u32,
received_at,
})
})
.collect()
}
}

View File

@@ -0,0 +1,59 @@
use async_trait::async_trait;
use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
};
use sqlx::{Row, SqlitePool};
pub struct SqliteUserSettingsRepository {
pool: SqlitePool,
}
impl SqliteUserSettingsRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl UserSettingsRepository for SqliteUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string();
let row =
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(UserSettings::from_persistence(
user_id.clone(),
federate != 0,
))
}
None => Ok(UserSettings::new(user_id.clone())),
}
}
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 };
sqlx::query("INSERT OR REPLACE INTO user_settings (user_id, federate_goals) VALUES (?, ?)")
.bind(&uid)
.bind(federate)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
}

View File

@@ -209,6 +209,15 @@ pub struct ProfileTemplate<'a> {
pub pending_followers: Vec<RemoteActorData>,
pub sort_by: String,
pub search: String,
pub goals: Vec<GoalViewData>,
}
pub struct GoalViewData {
pub year: u16,
pub target_count: u32,
pub current_count: u32,
pub percentage: f64,
pub is_complete: bool,
}
impl<'a> ProfileTemplate<'a> {

View File

@@ -24,6 +24,25 @@
</div>
</div>
{% if !goals.is_empty() %}
<div class="goals-section">
{% for g in goals %}
<div class="goal-card">
<div class="goal-header">
<span class="goal-label">{{ g.year }} Goal</span>
<span class="goal-count">{{ g.current_count }}&thinsp;/&thinsp;{{ g.target_count }} movies</span>
</div>
<div class="progress-track">
<div class="progress-fill" style="width: {{ g.percentage }}%"></div>
</div>
{% if g.is_complete %}
<span class="goal-complete">✦ Goal reached!</span>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if is_own_profile %}
<section class="follow-section">
<h3>Follow remote user</h3>

View File

@@ -0,0 +1,37 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GoalDto {
pub year: u16,
pub target_count: u32,
pub current_count: u32,
pub percentage: f64,
pub is_complete: bool,
pub goal_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct GoalsResponse {
pub goals: Vec<GoalDto>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct CreateGoalRequest {
pub year: u16,
pub target_count: u32,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateGoalRequest {
pub target_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto {
pub federate_goals: bool,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest {
pub federate_goals: bool,
}

View File

@@ -1,6 +1,7 @@
pub mod auth;
pub mod common;
pub mod diary;
pub mod goals;
pub mod import;
pub mod movies;
pub mod search;
@@ -13,6 +14,7 @@ pub mod wrapup;
pub use auth::*;
pub use common::*;
pub use diary::*;
pub use goals::*;
pub use import::*;
pub use movies::*;
pub use social::*;

View File

@@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::diary::{DiaryEntryDto, DiaryResponse};
use crate::goals::GoalDto;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSummaryDto {
@@ -81,6 +82,8 @@ pub struct UserProfileResponse {
pub history: Option<Vec<MonthActivityDto>>,
/// Populated for view=trends
pub trends: Option<UserTrendsDto>,
#[serde(skip_serializing_if = "Option::is_none")]
pub goals: Option<Vec<GoalDto>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -1,13 +1,13 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery,
PosterFetcherClient, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort,
SocialQueryPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery, WrapUpVideoRenderer,
PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, WrapUpVideoRenderer,
};
use crate::config::AppConfig;
@@ -34,6 +34,9 @@ pub struct Repositories {
pub social_query: Arc<dyn SocialQueryPort>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub goal: Arc<dyn GoalRepository>,
pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>,
}
#[derive(Clone)]

View File

@@ -0,0 +1,18 @@
use uuid::Uuid;
pub struct CreateGoalCommand {
pub user_id: Uuid,
pub year: u16,
pub target_count: u32,
}
pub struct UpdateGoalCommand {
pub user_id: Uuid,
pub year: u16,
pub target_count: u32,
}
pub struct DeleteGoalCommand {
pub user_id: Uuid,
pub year: u16,
}

View File

@@ -0,0 +1,56 @@
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Goal, GoalType, GoalWithProgress},
value_objects::UserId,
};
use super::commands::CreateGoalCommand;
use crate::context::AppContext;
pub async fn execute(
ctx: &AppContext,
cmd: CreateGoalCommand,
) -> Result<GoalWithProgress, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let existing = ctx
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year)
.await?;
if existing.is_some() {
return Err(DomainError::ValidationError(
"Goal already exists for this year".into(),
));
}
let goal = Goal::new(
user_id.clone(),
cmd.year,
cmd.target_count,
GoalType::Movies,
)?;
ctx.repos.goal.save(&goal).await?;
let current_count = ctx
.repos
.goal
.count_reviews_in_year(&user_id, cmd.year)
.await?;
ctx.services
.event_publisher
.publish(&DomainEvent::GoalCreated {
goal_id: goal.id().clone(),
user_id,
year: cmd.year,
target_count: cmd.target_count,
})
.await?;
Ok(GoalWithProgress {
goal,
current_count,
})
}

View File

@@ -0,0 +1,28 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
use super::commands::DeleteGoalCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteGoalCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let goal = ctx
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
ctx.repos.goal.delete(goal.id(), &user_id).await?;
ctx.services
.event_publisher
.publish(&DomainEvent::GoalDeleted {
goal_id: goal.id().clone(),
user_id,
year: cmd.year,
})
.await?;
Ok(())
}

View File

@@ -0,0 +1,30 @@
use domain::{errors::DomainError, models::GoalWithProgress, value_objects::UserId};
use super::queries::GetGoalQuery;
use crate::context::AppContext;
pub async fn execute(
ctx: &AppContext,
query: GetGoalQuery,
) -> Result<Option<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let goal = ctx
.repos
.goal
.find_by_user_and_year(&user_id, query.year)
.await?;
let Some(goal) = goal else { return Ok(None) };
let current_count = ctx
.repos
.goal
.count_reviews_in_year(&user_id, query.year)
.await?;
Ok(Some(GoalWithProgress {
goal,
current_count,
}))
}

View File

@@ -0,0 +1,27 @@
use domain::{errors::DomainError, models::GoalWithProgress, value_objects::UserId};
use super::queries::ListGoalsQuery;
use crate::context::AppContext;
pub async fn execute(
ctx: &AppContext,
query: ListGoalsQuery,
) -> Result<Vec<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let goals = ctx.repos.goal.list_for_user(&user_id).await?;
let mut result = Vec::with_capacity(goals.len());
for goal in goals {
let current_count = ctx
.repos
.goal
.count_reviews_in_year(&user_id, goal.year())
.await?;
result.push(GoalWithProgress {
goal,
current_count,
});
}
Ok(result)
}

View File

@@ -0,0 +1,7 @@
pub mod commands;
pub mod create;
pub mod delete;
pub mod get;
pub mod list;
pub mod queries;
pub mod update;

View File

@@ -0,0 +1,10 @@
use uuid::Uuid;
pub struct GetGoalQuery {
pub user_id: Uuid,
pub year: u16,
}
pub struct ListGoalsQuery {
pub user_id: Uuid,
}

View File

@@ -0,0 +1,44 @@
use domain::{
errors::DomainError, events::DomainEvent, models::GoalWithProgress, value_objects::UserId,
};
use super::commands::UpdateGoalCommand;
use crate::context::AppContext;
pub async fn execute(
ctx: &AppContext,
cmd: UpdateGoalCommand,
) -> Result<GoalWithProgress, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let mut goal = ctx
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
goal.update_target(cmd.target_count)?;
ctx.repos.goal.update(&goal).await?;
let current_count = ctx
.repos
.goal
.count_reviews_in_year(&user_id, cmd.year)
.await?;
ctx.services
.event_publisher
.publish(&DomainEvent::GoalUpdated {
goal_id: goal.id().clone(),
user_id,
year: cmd.year,
target_count: cmd.target_count,
})
.await?;
Ok(GoalWithProgress {
goal,
current_count,
})
}

View File

@@ -6,6 +6,7 @@ pub mod worker;
pub mod auth;
pub mod diary;
pub mod goals;
pub mod import;
pub mod integrations;
pub mod movies;

View File

@@ -172,6 +172,9 @@ impl TestContextBuilder {
social_query: Arc::new(NoopSocialQueryPort),
wrapup_stats: self.wrapup_stats,
wrapup_repo: self.wrapup_repo,
goal: Arc::new(domain::testing::NoopGoalRepository),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
},
services: Services {
auth: self.auth_service,

View File

@@ -63,6 +63,9 @@ impl EventHandler for RecordingHandler {
DomainEvent::WrapUpCompleted { .. } => "wrapup_completed",
DomainEvent::SearchReindexRequested => "search_reindex",
DomainEvent::PosterSynced { .. } => "poster_synced",
DomainEvent::GoalCreated { .. }
| DomainEvent::GoalUpdated { .. }
| DomainEvent::GoalDeleted { .. } => "goal",
};
self.calls.lock().unwrap().push(label);
Ok(())

View File

@@ -0,0 +1,8 @@
use domain::{errors::DomainError, models::UserSettings, value_objects::UserId};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> Result<UserSettings, DomainError> {
let uid = UserId::from_uuid(user_id);
ctx.repos.user_settings.get(&uid).await
}

View File

@@ -1,7 +1,9 @@
pub mod commands;
pub mod get_current_profile;
pub mod get_profile;
pub mod get_settings;
pub mod get_users;
pub mod queries;
pub mod update_profile;
pub mod update_profile_fields;
pub mod update_settings;

View File

@@ -0,0 +1,15 @@
use domain::{errors::DomainError, value_objects::UserId};
use crate::context::AppContext;
pub struct UpdateUserSettingsCommand {
pub user_id: uuid::Uuid,
pub federate_goals: bool,
}
pub async fn execute(ctx: &AppContext, cmd: UpdateUserSettingsCommand) -> Result<(), DomainError> {
let uid = UserId::from_uuid(cmd.user_id);
let mut settings = ctx.repos.user_settings.get(&uid).await?;
settings.set_federate_goals(cmd.federate_goals);
ctx.repos.user_settings.save(&settings).await
}

View File

@@ -3,7 +3,9 @@ use chrono::NaiveDateTime;
use crate::{
errors::DomainError,
value_objects::{ExternalMetadataId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId},
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
},
};
#[derive(Clone, Debug)]
@@ -88,6 +90,23 @@ pub enum DomainEvent {
PosterSynced {
movie_id: MovieId,
},
GoalCreated {
goal_id: GoalId,
user_id: UserId,
year: u16,
target_count: u32,
},
GoalUpdated {
goal_id: GoalId,
user_id: UserId,
year: u16,
target_count: u32,
},
GoalDeleted {
goal_id: GoalId,
user_id: UserId,
year: u16,
},
}
#[async_trait]

View File

@@ -0,0 +1,111 @@
use chrono::NaiveDateTime;
use crate::{
errors::DomainError,
value_objects::{GoalId, UserId},
};
use super::GoalType;
#[derive(Clone, Debug)]
pub struct Goal {
id: GoalId,
user_id: UserId,
year: u16,
target_count: u32,
goal_type: GoalType,
created_at: NaiveDateTime,
}
impl Goal {
pub fn new(
user_id: UserId,
year: u16,
target_count: u32,
goal_type: GoalType,
) -> Result<Self, DomainError> {
if year < 2020 {
return Err(DomainError::ValidationError(
"Goal year must be 2020 or later".into(),
));
}
if target_count < 1 {
return Err(DomainError::ValidationError(
"Target count must be at least 1".into(),
));
}
Ok(Self {
id: GoalId::generate(),
user_id,
year,
target_count,
goal_type,
created_at: chrono::Utc::now().naive_utc(),
})
}
pub fn from_persistence(
id: GoalId,
user_id: UserId,
year: u16,
target_count: u32,
goal_type: GoalType,
created_at: NaiveDateTime,
) -> Self {
Self {
id,
user_id,
year,
target_count,
goal_type,
created_at,
}
}
pub fn update_target(&mut self, target_count: u32) -> Result<(), DomainError> {
if target_count < 1 {
return Err(DomainError::ValidationError(
"Target count must be at least 1".into(),
));
}
self.target_count = target_count;
Ok(())
}
pub fn id(&self) -> &GoalId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn year(&self) -> u16 {
self.year
}
pub fn target_count(&self) -> u32 {
self.target_count
}
pub fn goal_type(&self) -> &GoalType {
&self.goal_type
}
pub fn created_at(&self) -> &NaiveDateTime {
&self.created_at
}
}
pub struct GoalWithProgress {
pub goal: Goal,
pub current_count: u32,
}
impl GoalWithProgress {
pub fn percentage(&self) -> f64 {
if self.goal.target_count == 0 {
return 100.0;
}
((self.current_count as f64 / self.goal.target_count as f64) * 100.0).min(100.0)
}
pub fn is_complete(&self) -> bool {
self.current_count >= self.goal.target_count
}
}

View File

@@ -18,6 +18,12 @@ pub mod watchlist;
pub use watchlist::{WatchlistEntry, WatchlistWithMovie};
pub mod remote_watchlist;
pub use remote_watchlist::RemoteWatchlistEntry;
pub mod goal;
pub use goal::{Goal, GoalWithProgress};
pub mod user_settings;
pub use user_settings::UserSettings;
pub mod remote_goal;
pub use remote_goal::RemoteGoalEntry;
pub mod watch_event;
pub mod wrapup;
pub use watch_event::{
@@ -38,6 +44,32 @@ pub use search::{
SearchResults,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GoalType {
Movies,
}
impl GoalType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Movies => "movies",
}
}
}
impl std::str::FromStr for GoalType {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"movies" => Ok(Self::Movies),
other => Err(DomainError::ValidationError(format!(
"Unknown goal type: {other}"
))),
}
}
}
#[derive(Clone, Debug, Default)]
pub enum SortDirection {
#[default]

View File

@@ -0,0 +1,11 @@
use chrono::{DateTime, Utc};
#[derive(Clone, Debug)]
pub struct RemoteGoalEntry {
pub ap_id: String,
pub actor_url: String,
pub year: u16,
pub target_count: u32,
pub current_count: u32,
pub received_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,35 @@
use crate::value_objects::UserId;
#[derive(Clone, Debug)]
pub struct UserSettings {
user_id: UserId,
federate_goals: bool,
}
impl UserSettings {
pub fn new(user_id: UserId) -> Self {
Self {
user_id,
federate_goals: false,
}
}
pub fn from_persistence(user_id: UserId, federate_goals: bool) -> Self {
Self {
user_id,
federate_goals,
}
}
pub fn set_federate_goals(&mut self, value: bool) {
self.federate_goals = value;
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn federate_goals(&self) -> bool {
self.federate_goals
}
}

View File

@@ -8,16 +8,17 @@ use crate::{
models::wrapup::WrapUpReport,
models::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
FeedEntry, FieldMapping, FileFormat, Goal, ImportError, ImportProfile, ImportSession,
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
ParsedPlaybackEvent, Person, PersonCredits, PersonId, RemoteWatchlistEntry, Review,
ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary, UserTrends,
WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
ParsedPlaybackEvent, Person, PersonCredits, PersonId, 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},
},
value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, WatchEventId,
WebhookTokenId, WrapUpId,
},
@@ -411,6 +412,41 @@ pub trait RemoteWatchlistRepository: Send + Sync {
) -> Result<Vec<RemoteWatchlistEntry>, DomainError>;
}
// ── Goals ────────────────────────────────────────────────────────────────────
#[async_trait]
pub trait GoalRepository: Send + Sync {
async fn save(&self, goal: &Goal) -> Result<(), DomainError>;
async fn update(&self, goal: &Goal) -> Result<(), DomainError>;
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>;
async fn find_by_user_and_year(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<Goal>, DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError>;
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError>;
}
#[async_trait]
pub trait UserSettingsRepository: Send + Sync {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError>;
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RemoteGoalRepository: Send + Sync {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>;
async fn update_by_ap_id(
&self,
ap_id: &str,
target: u32,
current: u32,
) -> Result<(), DomainError>;
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError>;
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError>;
}
/// Read-only query port used exclusively by the ActivityPub adapter.
/// Consolidates all reads the AP adapter needs so it never touches write repositories.
#[async_trait]
@@ -442,6 +478,14 @@ pub trait LocalApContentQuery: Send + Sync {
before: Option<chrono::NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError>;
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError>;
}
// ── Media server integration ──────────────────────────────────────────────────

View File

@@ -1240,3 +1240,70 @@ impl WrapUpRepository for PanicWrapUpRepository {
panic!("PanicWrapUpRepository called")
}
}
// ── Noop Goal/Settings repos ────────────────────────────────────────────────
pub struct NoopGoalRepository;
#[async_trait]
impl crate::ports::GoalRepository for NoopGoalRepository {
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
Ok(())
}
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
Ok(())
}
async fn delete(
&self,
_: &crate::value_objects::GoalId,
_: &UserId,
) -> Result<(), DomainError> {
Ok(())
}
async fn find_by_user_and_year(
&self,
_: &UserId,
_: u16,
) -> Result<Option<crate::models::Goal>, DomainError> {
Ok(None)
}
async fn list_for_user(&self, _: &UserId) -> Result<Vec<crate::models::Goal>, DomainError> {
Ok(vec![])
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
Ok(0)
}
}
pub struct NoopUserSettingsRepository;
#[async_trait]
impl crate::ports::UserSettingsRepository for NoopUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<crate::models::UserSettings, DomainError> {
Ok(crate::models::UserSettings::new(user_id.clone()))
}
async fn save(&self, _: &crate::models::UserSettings) -> Result<(), DomainError> {
Ok(())
}
}
pub struct NoopRemoteGoalRepository;
#[async_trait]
impl crate::ports::RemoteGoalRepository for NoopRemoteGoalRepository {
async fn save(&self, _: crate::models::RemoteGoalEntry) -> Result<(), DomainError> {
Ok(())
}
async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> {
Ok(())
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<crate::models::RemoteGoalEntry>, DomainError> {
Ok(vec![])
}
}

View File

@@ -29,6 +29,7 @@ uuid_id!(WatchlistEntryId);
uuid_id!(WatchEventId);
uuid_id!(WebhookTokenId);
uuid_id!(WrapUpId);
uuid_id!(GoalId);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalMetadataId(String);

View File

@@ -33,6 +33,9 @@ pub struct DatabaseOutput {
pub ap_content: Arc<dyn LocalApContentQuery>,
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub db_pool: DbPool,
}
@@ -71,6 +74,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -106,6 +112,9 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -54,13 +54,14 @@ use api_types::search::{
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto,
CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, GenreDto,
KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse,
PaginationQueryParams, ProfileResponse, RegisterRequest, ReviewHistoryResponse,
SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto,
UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse,
WatchlistStatusResponse,
CreateGoalRequest, CrewMemberDto, DiaryQueryParams, DiaryResponse, DirectorStatDto,
ExportQueryParams, GenreDto, GoalDto, GoalsResponse, KeywordDto, LogReviewRequest,
LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse,
MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams,
ProfileResponse, RegisterRequest, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UpdateGoalRequest, UpdateUserSettingsRequest, UserProfileQueryParams, UserProfileResponse,
UserSettingsDto, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, WatchlistEntryDto,
WatchlistResponse, WatchlistStatusResponse,
};
#[cfg(feature = "federation")]
use api_types::{
@@ -1177,6 +1178,19 @@ pub async fn get_user_profile(
entries,
history,
trends,
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
.unwrap_or_default();
if goals_list.is_empty() {
None
} else {
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
}
},
})
.into_response()
}
@@ -1505,3 +1519,188 @@ pub async fn get_watchlist_status(
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}
// ── Goals ────────────────────────────────────────────────────────────────────
fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
GoalDto {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage(),
is_complete: g.is_complete(),
goal_type: g.goal.goal_type().as_str().to_string(),
}
}
#[utoipa::path(
get, path = "/api/v1/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_goals(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
#[utoipa::path(
post, path = "/api/v1/goals",
request_body = CreateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn create_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::create::execute(
&state.app_ctx,
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
year: req.year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
put, path = "/api/v1/goals/{year}",
request_body = UpdateGoalRequest,
responses(
(status = 200, body = GoalDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let g = application::goals::update::execute(
&state.app_ctx,
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
year,
target_count: req.target_count,
},
)
.await?;
Ok(Json(goal_with_progress_to_dto(&g)))
}
#[utoipa::path(
delete, path = "/api/v1/goals/{year}",
responses(
(status = 204, description = "Goal deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Goal not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_goal(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(year): Path<u16>,
) -> Result<StatusCode, ApiError> {
application::goals::delete::execute(
&state.app_ctx,
application::goals::commands::DeleteGoalCommand {
user_id: user.0.value(),
year,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/users/{id}/goals",
responses(
(status = 200, body = GoalsResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_goals(
State(state): State<AppState>,
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let goals = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;
Ok(Json(GoalsResponse {
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
}))
}
// ── User Settings ────────────────────────────────────────────────────────────
#[utoipa::path(
get, path = "/api/v1/settings",
responses(
(status = 200, body = UserSettingsDto),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, ApiError> {
let settings =
application::users::get_settings::execute(&state.app_ctx, user.0.value()).await?;
Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(),
}))
}
#[utoipa::path(
put, path = "/api/v1/settings",
request_body = UpdateUserSettingsRequest,
responses(
(status = 204, description = "Settings updated"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn update_settings(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
&state.app_ctx,
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -668,6 +668,26 @@ pub async fn get_user_profile(
pending_followers,
sort_by: sort_by_str.to_string(),
search: params.search.clone(),
goals: {
let goals_list = application::goals::list::execute(
&state.app_ctx,
application::goals::queries::ListGoalsQuery {
user_id: profile_user_uuid,
},
)
.await
.unwrap_or_default();
goals_list
.iter()
.map(|g| template_askama::GoalViewData {
year: g.goal.year(),
target_count: g.goal.target_count(),
current_count: g.current_count,
percentage: g.percentage().round(),
is_complete: g.is_complete(),
})
.collect()
},
})
.into_response()
}

View File

@@ -118,6 +118,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
blocklist_repo,
review_store,
remote_watchlist_repo: remote_watchlist_repo.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&db.user),
base_url: app_config.base_url.clone(),
@@ -195,6 +196,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
social_query: Arc::new(domain::testing::NoopSocialQueryPort),
wrapup_stats: db.wrapup_stats,
wrapup_repo: db.wrapup_repo,
goal: db.goal,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
},
services: Services {
auth: auth_service,

View File

@@ -436,6 +436,22 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route(
"/admin/reindex-search",
routing::post(handlers::api::post_reindex_search),
)
.route(
"/goals",
routing::get(handlers::api::list_goals).post(handlers::api::create_goal),
)
.route(
"/goals/{year}",
routing::put(handlers::api::update_goal).delete(handlers::api::delete_goal),
)
.route(
"/users/{id}/goals",
routing::get(handlers::api::get_user_goals),
)
.route(
"/settings",
routing::get(handlers::api::get_settings).put(handlers::api::update_settings),
);
#[cfg(feature = "federation")]

View File

@@ -650,6 +650,75 @@ impl domain::ports::WrapUpRepository for Panic {
}
}
#[async_trait::async_trait]
impl domain::ports::GoalRepository for Panic {
async fn save(&self, _: &domain::models::Goal) -> Result<(), DomainError> {
panic!()
}
async fn update(&self, _: &domain::models::Goal) -> Result<(), DomainError> {
panic!()
}
async fn delete(
&self,
_: &domain::value_objects::GoalId,
_: &domain::value_objects::UserId,
) -> Result<(), DomainError> {
panic!()
}
async fn find_by_user_and_year(
&self,
_: &domain::value_objects::UserId,
_: u16,
) -> Result<Option<domain::models::Goal>, DomainError> {
panic!()
}
async fn list_for_user(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::models::Goal>, DomainError> {
panic!()
}
async fn count_reviews_in_year(
&self,
_: &domain::value_objects::UserId,
_: u16,
) -> Result<u32, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::UserSettingsRepository for Panic {
async fn get(
&self,
_: &domain::value_objects::UserId,
) -> Result<domain::models::UserSettings, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::UserSettings) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::RemoteGoalRepository for Panic {
async fn save(&self, _: domain::models::RemoteGoalEntry) -> Result<(), DomainError> {
panic!()
}
async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> {
panic!()
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
panic!()
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<domain::models::RemoteGoalEntry>, DomainError> {
panic!()
}
}
// --- Single state factory — only auth_service varies ---
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
@@ -677,6 +746,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
social_query: Arc::clone(&repo) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,
goal: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
},
services: Services {
auth: auth_service,

View File

@@ -437,6 +437,9 @@ async fn test_app() -> Router {
social_query: Arc::new(PanicSocialQuery),
wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _,
wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _,
goal: Arc::new(domain::testing::NoopGoalRepository),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
},
services: Services {
auth: Arc::new(PanicAuth),

View File

@@ -38,6 +38,9 @@ pub struct WorkerDbOutput {
pub image_ref_query: Arc<dyn ImageRefQuery>,
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub db_pool: DbPool,
}
@@ -80,6 +83,9 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
image_ref_query,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Postgres(w.pool),
})
}
@@ -119,6 +125,9 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
image_ref_query,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal: w.goal,
user_settings: w.user_settings,
remote_goal: w.remote_goal,
db_pool: DbPool::Sqlite(w.pool),
})
}

View File

@@ -94,6 +94,9 @@ async fn main() -> anyhow::Result<()> {
social_query: Arc::new(domain::testing::NoopSocialQueryPort),
wrapup_stats: db.wrapup_stats,
wrapup_repo: db.wrapup_repo,
goal: db.goal,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
},
services: Services {
auth: auth_service,
@@ -260,6 +263,7 @@ async fn main() -> anyhow::Result<()> {
blocklist_repo: fed_blocklist_repo,
review_store: fed_review_store,
remote_watchlist_repo: fed_remote_watchlist_repo,
remote_goal_repo: Arc::clone(&ctx.repos.remote_goal),
local_ap_content: fed_ap_content,
user_repo: fed_user_repo,
base_url,

View File

@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next"
import { Check, MoreHorizontal, Pencil, Target, Trash2 } from "lucide-react"
import { Card, CardContent } from "@/components/ui/card"
import { Progress } from "@/components/ui/progress"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import type { GoalDto } from "@/lib/api/users"
type GoalCardProps = {
goal: GoalDto
editable?: boolean
onEdit?: () => void
onDelete?: () => void
}
export function GoalCard({ goal, editable, onEdit, onDelete }: GoalCardProps) {
const { t } = useTranslation()
return (
<Card>
<CardContent className="space-y-2 py-3 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("goals.yearGoal", { year: goal.year })}
</span>
</div>
{editable && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="size-7 p-0">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-3.5" />
{t("common.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDelete} className="text-destructive">
<Trash2 className="mr-2 size-3.5" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<Progress value={goal.percentage} className="h-2" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{goal.current_count} / {goal.target_count} {t("goals.movies")}
</span>
<span>{Math.round(goal.percentage)}%</span>
</div>
{goal.is_complete && (
<p className="text-xs text-green-500 flex items-center gap-1">
<Check className="size-3" />
{t("goals.reached")}
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useCreateGoal, useUpdateGoal } from "@/hooks/use-goals"
import { toast } from "sonner"
type GoalSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
editYear?: number
editTarget?: number
}
export function GoalSheet({
open,
onOpenChange,
editYear,
editTarget,
}: GoalSheetProps) {
const { t } = useTranslation()
const isEditing = editYear !== undefined
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(editYear ?? currentYear)
const [target, setTarget] = useState(editTarget ?? 52)
const createMutation = useCreateGoal()
const updateMutation = useUpdateGoal()
function handleClose() {
onOpenChange(false)
if (!isEditing) {
setYear(currentYear)
setTarget(52)
}
}
function handleSubmit() {
if (target < 1) return
if (isEditing) {
updateMutation.mutate(
{ year, data: { target_count: target } },
{
onSuccess: () => {
toast.success(t("goals.updated"))
handleClose()
},
},
)
} else {
createMutation.mutate(
{ year, target_count: target },
{
onSuccess: () => {
toast.success(t("goals.created"))
handleClose()
},
},
)
}
}
const isPending = createMutation.isPending || updateMutation.isPending
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="px-4 pb-8">
<VisuallyHidden.Root>
<DrawerTitle>
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
</DrawerTitle>
</VisuallyHidden.Root>
<div className="mx-auto w-full max-w-sm space-y-6 pt-4">
<h2 className="text-lg font-semibold text-center">
{isEditing ? t("goals.editGoal") : t("goals.setGoal")}
</h2>
<div className="space-y-2">
<Label>{t("goals.year")}</Label>
<Input
type="number"
min={2020}
max={2100}
value={year}
onChange={(e) => setYear(Number(e.target.value))}
disabled={isEditing}
/>
</div>
<div className="space-y-2">
<Label>{t("goals.targetMovies")}</Label>
<Input
type="number"
min={1}
max={9999}
value={target}
onChange={(e) => setTarget(Number(e.target.value))}
/>
</div>
<Button
className="w-full"
onClick={handleSubmit}
disabled={isPending || target < 1}
>
{isPending
? t("common.saving")
: isEditing
? t("common.save")
: t("goals.setGoal")}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,92 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
getGoals,
getUserGoals,
createGoal,
updateGoal,
deleteGoal,
getSettings,
updateSettings,
} from "@/lib/api/goals"
import type {
CreateGoalRequest,
UpdateGoalRequest,
UpdateUserSettingsRequest,
} from "@/lib/api/goals"
import { userKeys } from "@/hooks/use-users"
export const goalKeys = {
all: ["goals"] as const,
list: () => [...goalKeys.all, "list"] as const,
user: (userId: string) => [...goalKeys.all, "user", userId] as const,
}
export const settingsKeys = {
all: ["settings"] as const,
}
export function useGoals() {
return useQuery({
queryKey: goalKeys.list(),
queryFn: getGoals,
})
}
export function useUserGoals(userId: string) {
return useQuery({
queryKey: goalKeys.user(userId),
queryFn: () => getUserGoals(userId),
enabled: !!userId,
})
}
export function useCreateGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateGoalRequest) => createGoal(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useUpdateGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ year, data }: { year: number; data: UpdateGoalRequest }) =>
updateGoal(year, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useDeleteGoal() {
const qc = useQueryClient()
return useMutation({
mutationFn: (year: number) => deleteGoal(year),
onSuccess: () => {
qc.invalidateQueries({ queryKey: goalKeys.all })
qc.invalidateQueries({ queryKey: userKeys.all })
},
})
}
export function useSettings() {
return useQuery({
queryKey: settingsKeys.all,
queryFn: getSettings,
})
}
export function useUpdateSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateUserSettingsRequest) => updateSettings(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: settingsKeys.all })
},
})
}

54
spa/src/lib/api/goals.ts Normal file
View File

@@ -0,0 +1,54 @@
import { z } from "zod"
import { get, post, put, del } from "./client"
import { goalDtoSchema } from "./users"
export const goalsResponseSchema = z.object({
goals: z.array(goalDtoSchema),
})
export type GoalsResponse = z.infer<typeof goalsResponseSchema>
export type CreateGoalRequest = {
year: number
target_count: number
}
export type UpdateGoalRequest = {
target_count: number
}
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
}
export function getGoals() {
return get<GoalsResponse>("/goals")
}
export function getUserGoals(userId: string) {
return get<GoalsResponse>(`/users/${userId}/goals`)
}
export function createGoal(data: CreateGoalRequest) {
return post<z.infer<typeof goalDtoSchema>>("/goals", data)
}
export function updateGoal(year: number, data: UpdateGoalRequest) {
return put<z.infer<typeof goalDtoSchema>>(`/goals/${year}`, data)
}
export function deleteGoal(year: number) {
return del(`/goals/${year}`)
}
export function getSettings() {
return get<UserSettingsDto>("/settings")
}
export function updateSettings(data: UpdateUserSettingsRequest) {
return put("/settings", data)
}

View File

@@ -63,6 +63,16 @@ export type MonthActivityDto = z.infer<typeof monthActivityDtoSchema>
const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
export const goalDtoSchema = z.object({
year: z.number(),
target_count: z.number(),
current_count: z.number(),
percentage: z.number(),
is_complete: z.boolean(),
goal_type: z.string(),
})
export type GoalDto = z.infer<typeof goalDtoSchema>
export const userProfileResponseSchema = z.object({
user_id: z.string().uuid(),
username: z.string(),
@@ -74,6 +84,7 @@ export const userProfileResponseSchema = z.object({
entries: userDiaryResponseSchema.optional(),
history: z.array(monthActivityDtoSchema).optional(),
trends: userTrendsDtoSchema.optional(),
goals: z.array(goalDtoSchema).optional(),
})
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>

View File

@@ -159,7 +159,22 @@
"admin": "Admin",
"rebuildSearch": "Rebuild Search Index",
"rebuildSearchDesc": "Re-index all movies and people",
"rebuildSearchDone": "Reindex queued"
"rebuildSearchDone": "Reindex queued",
"privacy": "Privacy",
"federateGoals": "Share goals on Fediverse",
"federateGoalsDesc": "Broadcast goal progress to followers"
},
"goals": {
"yearGoal": "{{year}} Goal",
"movies": "movies",
"reached": "Goal reached!",
"setGoal": "Set Goal",
"editGoal": "Edit Goal",
"year": "Year",
"targetMovies": "Target (movies)",
"created": "Goal created",
"updated": "Goal updated",
"deleted": "Goal deleted"
},
"editProfile": {
"title": "Edit Profile",

View File

@@ -1,12 +1,17 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ChevronDown, ChevronRight, Settings, Sparkles } from "lucide-react"
import { ChevronDown, ChevronRight, Plus, Settings, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { useAuth } from "@/components/auth-provider"
import { useWrapUps } from "@/hooks/use-wrapup"
import { useUserProfile } from "@/hooks/use-users"
import { useDeleteGoal } from "@/hooks/use-goals"
import { GoalCard } from "@/components/goal-card"
import { GoalSheet } from "@/components/goal-sheet"
import { toast } from "sonner"
import type { GoalDto } from "@/lib/api/users"
export const Route = createFileRoute("/_app/profile")({
component: ProfilePage,
@@ -36,6 +41,7 @@ function ProfilePage() {
data={data}
actions={
<>
<GoalSection goals={data.goals ?? []} />
<Link to="/social" className="block">
<Button variant="outline" size="sm" className="w-full justify-between">
<span>{t("profile.followingFollowers")}</span>
@@ -50,6 +56,58 @@ function ProfilePage() {
)
}
function GoalSection({ goals }: { goals: GoalDto[] }) {
const { t } = useTranslation()
const [sheetOpen, setSheetOpen] = useState(false)
const [editGoal, setEditGoal] = useState<GoalDto | null>(null)
const deleteMutation = useDeleteGoal()
function handleEdit(goal: GoalDto) {
setEditGoal(goal)
setSheetOpen(true)
}
function handleDelete(year: number) {
deleteMutation.mutate(year, {
onSuccess: () => toast.success(t("goals.deleted")),
})
}
function handleSheetClose(open: boolean) {
setSheetOpen(open)
if (!open) setEditGoal(null)
}
return (
<div className="space-y-2">
{goals.map((g) => (
<GoalCard
key={g.year}
goal={g}
editable
onEdit={() => handleEdit(g)}
onDelete={() => handleDelete(g.year)}
/>
))}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setSheetOpen(true)}
>
<Plus className="mr-1.5 size-3.5" />
{t("goals.setGoal")}
</Button>
<GoalSheet
open={sheetOpen}
onOpenChange={handleSheetClose}
editYear={editGoal?.year}
editTarget={editGoal?.target_count}
/>
</div>
)
}
function wrapupYear(startDate: string): string {
return startDate.slice(0, 4)
}

View File

@@ -10,11 +10,14 @@ import {
RefreshCw,
ShieldBan,
Sparkles,
Target,
User,
} from "lucide-react"
import { Button } from "@/components/ui/button"
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"
export const Route = createFileRoute("/_app/settings/")({
component: SettingsPage,
@@ -94,6 +97,8 @@ function SettingsPage() {
<SettingsGroup label={t("settings.integrations")} items={integrations} />
<SettingsGroup label={t("settings.socialGroup")} items={social} />
<PrivacySection />
{isAdmin && <AdminActions />}
<button
@@ -109,6 +114,40 @@ function SettingsPage() {
)
}
function PrivacySection() {
const { t } = useTranslation()
const { data: settings } = useSettings()
const updateMutation = useUpdateSettings()
return (
<div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
{t("settings.privacy")}
</p>
<div className="divide-y divide-border rounded-xl bg-card">
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<Target className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateGoals")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateGoalsDesc")}
</p>
</div>
<Switch
checked={settings?.federate_goals ?? false}
onCheckedChange={(checked) =>
updateMutation.mutate({ federate_goals: checked })
}
disabled={updateMutation.isPending}
/>
</div>
</div>
</div>
)
}
function AdminActions() {
const { t } = useTranslation()
const reindex = useMutation({

View File

@@ -4,6 +4,7 @@ import { UserCheck, UserPlus } from "lucide-react"
import { BackButton } from "@/components/back-button"
import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
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"
@@ -34,6 +35,15 @@ function UserProfilePage() {
<ProfileView
data={data}
userId={id}
actions={
data.goals?.length ? (
<div className="space-y-2">
{data.goals.map((g) => (
<GoalCard key={g.year} goal={g} />
))}
</div>
) : undefined
}
headerRight={
!isSelf ? (
isFollowing ? (

View File

@@ -1300,3 +1300,52 @@ form button[type="submit"]:hover {
.wu-card { padding: 1.5rem; }
.wu-genre-name { width: 5rem; }
}
/* ── Goals ─────────────────────────────────────────────────────────────────── */
.goals-section { margin: 1rem 0; display: flex; flex-direction: column; gap: 0.5rem; }
.goal-card {
background: var(--glass-bg);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--glass-border);
border-radius: 12px;
padding: 0.75rem 1rem;
box-shadow: var(--glass-shadow), var(--glass-inset);
position: relative;
overflow: hidden;
}
.goal-card::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.08), transparent);
border-radius: inherit;
pointer-events: none;
}
.goal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
.goal-label { font-weight: 700; font-size: 0.85rem; color: var(--primary); }
.goal-count { font-size: 0.8rem; color: var(--text-muted); }
.progress-track {
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
height: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--primary-mid), var(--primary));
box-shadow: 0 0 8px var(--primary-glow);
transition: width 0.4s ease;
}
.goal-complete {
font-size: 0.75rem;
color: var(--primary);
margin-top: 0.3rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
text-shadow: 0 0 6px var(--primary-glow);
}