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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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