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:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
89
crates/adapters/activitypub/src/goal_handler.rs
Normal file
89
crates/adapters/activitypub/src/goal_handler.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
25
crates/adapters/postgres/migrations/0025_goals.sql
Normal file
25
crates/adapters/postgres/migrations/0025_goals.sql
Normal 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);
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
195
crates/adapters/postgres/src/goals.rs
Normal file
195
crates/adapters/postgres/src/goals.rs
Normal 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,
|
||||
))
|
||||
}
|
||||
@@ -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 _,
|
||||
})
|
||||
}
|
||||
|
||||
109
crates/adapters/postgres/src/remote_goals.rs
Normal file
109
crates/adapters/postgres/src/remote_goals.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
62
crates/adapters/postgres/src/user_settings.rs
Normal file
62
crates/adapters/postgres/src/user_settings.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
25
crates/adapters/sqlite/migrations/0025_goals.sql
Normal file
25
crates/adapters/sqlite/migrations/0025_goals.sql
Normal 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);
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
196
crates/adapters/sqlite/src/goals.rs
Normal file
196
crates/adapters/sqlite/src/goals.rs
Normal 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,
|
||||
))
|
||||
}
|
||||
@@ -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 _,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
104
crates/adapters/sqlite/src/remote_goals.rs
Normal file
104
crates/adapters/sqlite/src/remote_goals.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
59
crates/adapters/sqlite/src/user_settings.rs
Normal file
59
crates/adapters/sqlite/src/user_settings.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }} / {{ 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>
|
||||
|
||||
37
crates/api-types/src/goals.rs
Normal file
37
crates/api-types/src/goals.rs
Normal 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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
18
crates/application/src/goals/commands.rs
Normal file
18
crates/application/src/goals/commands.rs
Normal 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,
|
||||
}
|
||||
56
crates/application/src/goals/create.rs
Normal file
56
crates/application/src/goals/create.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
28
crates/application/src/goals/delete.rs
Normal file
28
crates/application/src/goals/delete.rs
Normal 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(())
|
||||
}
|
||||
30
crates/application/src/goals/get.rs
Normal file
30
crates/application/src/goals/get.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
27
crates/application/src/goals/list.rs
Normal file
27
crates/application/src/goals/list.rs
Normal 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)
|
||||
}
|
||||
7
crates/application/src/goals/mod.rs
Normal file
7
crates/application/src/goals/mod.rs
Normal 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;
|
||||
10
crates/application/src/goals/queries.rs
Normal file
10
crates/application/src/goals/queries.rs
Normal 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,
|
||||
}
|
||||
44
crates/application/src/goals/update.rs
Normal file
44
crates/application/src/goals/update.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
8
crates/application/src/users/get_settings.rs
Normal file
8
crates/application/src/users/get_settings.rs
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
15
crates/application/src/users/update_settings.rs
Normal file
15
crates/application/src/users/update_settings.rs
Normal 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
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
111
crates/domain/src/models/goal.rs
Normal file
111
crates/domain/src/models/goal.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
11
crates/domain/src/models/remote_goal.rs
Normal file
11
crates/domain/src/models/remote_goal.rs
Normal 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>,
|
||||
}
|
||||
35
crates/domain/src/models/user_settings.rs
Normal file
35
crates/domain/src/models/user_settings.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
70
spa/src/components/goal-card.tsx
Normal file
70
spa/src/components/goal-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
spa/src/components/goal-sheet.tsx
Normal file
121
spa/src/components/goal-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
spa/src/hooks/use-goals.ts
Normal file
92
spa/src/hooks/use-goals.ts
Normal 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
54
spa/src/lib/api/goals.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user