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!(
|
||||
|
||||
Reference in New Issue
Block a user