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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user