diff --git a/domain/src/coaching_session.rs b/domain/src/coaching_session.rs index 55ff48fc..a77dab19 100644 --- a/domain/src/coaching_session.rs +++ b/domain/src/coaching_session.rs @@ -11,7 +11,10 @@ use log::*; use sea_orm::{DatabaseConnection, IntoActiveModel}; use service::config::Config; -pub use entity_api::coaching_session::{find_by_id, find_by_id_with_coaching_relationship}; +pub use entity_api::coaching_session::{ + find_by_id, find_by_id_with_coaching_relationship, find_by_user_with_includes, EnrichedSession, + IncludeOptions, +}; #[derive(Debug, Clone)] struct SessionDate(NaiveDateTime); diff --git a/domain/src/lib.rs b/domain/src/lib.rs index cbc3532d..b33c0e35 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -9,10 +9,10 @@ pub use entity_api::{ query::{FilterOnly, IntoQueryFilterMap, QueryFilterMap}, }; -// Re-exports from `entity` crate +// Re-exports from `entity` crate via `entity_api` pub use entity_api::{ actions, agreements, coachees, coaches, coaching_relationships, coaching_sessions, jwts, notes, - organizations, overarching_goals, query::QuerySort, user_roles, users, Id, + organizations, overarching_goals, query::QuerySort, status, user_roles, users, Id, }; pub mod action; diff --git a/entity/src/status.rs b/entity/src/status.rs index 9d7a5a19..e020d618 100644 --- a/entity/src/status.rs +++ b/entity/src/status.rs @@ -1,12 +1,15 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Serialize, DeriveActiveEnum)] +#[derive( + Debug, Clone, Eq, PartialEq, EnumIter, Deserialize, Serialize, DeriveActiveEnum, Default, +)] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "status")] pub enum Status { #[sea_orm(string_value = "not_started")] NotStarted, #[sea_orm(string_value = "in_progress")] + #[default] InProgress, #[sea_orm(string_value = "completed")] Completed, @@ -14,12 +17,6 @@ pub enum Status { WontDo, } -impl std::default::Default for Status { - fn default() -> Self { - Self::InProgress - } -} - impl From<&str> for Status { fn from(value: &str) -> Self { match value { @@ -31,3 +28,14 @@ impl From<&str> for Status { } } } + +impl std::fmt::Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotStarted => write!(f, "Not Started"), + Self::InProgress => write!(f, "In Progress"), + Self::Completed => write!(f, "Completed"), + Self::WontDo => write!(f, "Won't Do"), + } + } +} diff --git a/entity_api/src/coaching_session.rs b/entity_api/src/coaching_session.rs index 775e5644..e0ba14c2 100644 --- a/entity_api/src/coaching_session.rs +++ b/entity_api/src/coaching_session.rs @@ -1,11 +1,14 @@ use super::error::{EntityApiErrorKind, Error}; use entity::{ - coaching_relationships, - coaching_sessions::{ActiveModel, Entity, Model}, - Id, + agreements, coaching_relationships, + coaching_sessions::{self, ActiveModel, Entity, Model, Relation}, + organizations, overarching_goals, users, Id, }; use log::debug; -use sea_orm::{entity::prelude::*, DatabaseConnection, Set, TryIntoModel}; +use sea_orm::{ + entity::prelude::*, DatabaseConnection, JoinType, QueryOrder, QuerySelect, Set, TryIntoModel, +}; +use std::collections::HashMap; pub async fn create( db: &DatabaseConnection, @@ -61,6 +64,429 @@ pub async fn delete(db: &impl ConnectionTrait, coaching_session_id: Id) -> Resul Ok(()) } +pub async fn find_by_user(db: &impl ConnectionTrait, user_id: Id) -> Result, Error> { + let sessions = Entity::find() + .join(JoinType::InnerJoin, Relation::CoachingRelationships.def()) + .filter( + coaching_relationships::Column::CoachId + .eq(user_id) + .or(coaching_relationships::Column::CoacheeId.eq(user_id)), + ) + .all(db) + .await?; + + Ok(sessions) +} + +/// Public API response type: a single coaching session with its optional related resources. +/// +/// # Purpose +/// This struct solves the N+1 query problem when fetching coaching sessions with their +/// related data. Instead of making separate database queries for each session's relationships, +/// users, organizations, etc., this struct enables batch loading all related resources in a +/// single efficient operation. +/// +/// **Contrast with `RelatedData`:** This holds specific related data for ONE session (returned to clients), +/// while `RelatedData` holds ALL related data in lookup tables (internal only). +/// +/// # Usage Pattern +/// Clients can request specific related resources via query parameters (e.g., `?include=relationship,organization`), +/// and the `find_by_user_with_includes` function will: +/// 1. Fetch all coaching sessions for the user +/// 2. Batch load requested related resources into `RelatedData` using `IN` queries +/// 3. Assemble each `EnrichedSession` by looking up its specific related data +/// +/// # Serialization Behavior +/// - The base `session` fields are flattened into the JSON root using `#[serde(flatten)]` +/// - Optional related resources are only included in JSON when present (`skip_serializing_if`) +/// - This allows the same struct to represent sessions with varying levels of detail +/// +/// # Example JSON Output +/// ```json +/// { +/// "id": "session-123", +/// "date": "2025-01-15", +/// "relationship": { "id": "rel-456", ... }, // Only if included +/// "coach": { "id": "user-789", ... }, // Only if included +/// "organization": { "id": "org-101", ... } // Only if included +/// } +/// ``` +#[derive(Debug, Clone, serde::Serialize)] +pub struct EnrichedSession { + #[serde(flatten)] + pub session: Model, + #[serde(skip_serializing_if = "Option::is_none")] + pub relationship: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coach: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coachee: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub organization: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub overarching_goal: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agreement: Option, +} + +/// Configuration for which related resources to include when fetching coaching sessions. +/// +/// # Purpose +/// This struct acts as a feature flag configuration for batch loading related resources. +/// It controls which additional database queries are executed beyond fetching the base +/// coaching sessions, enabling clients to request only the data they need. +/// +/// # Design Rationale +/// Using boolean flags instead of an enum allows for: +/// - Multiple resources to be requested simultaneously +/// - Fine-grained control over query execution +/// - Easy addition of new optional resources without breaking changes +/// - Zero-cost abstraction (Copy trait) for passing options around +/// +/// # Relationship Dependencies +/// Some resources have dependencies on others due to database foreign key relationships: +/// - `organization` requires `relationship` because organizations are linked via coaching_relationships +/// - The `validate()` method enforces these constraints at the API boundary +/// +/// # Usage Example +/// ```rust +/// use entity_api::coaching_session::IncludeOptions; +/// +/// // Create options requesting relationship and organization data +/// let mut options = IncludeOptions::none(); +/// options.relationship = true; +/// options.organization = true; +/// options.validate().unwrap(); // Passes: organization depends on relationship +/// +/// // This would fail validation: +/// let mut invalid = IncludeOptions::none(); +/// invalid.organization = true; // Without relationship: true +/// assert!(invalid.validate().is_err()); // Error: organization requires relationship +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct IncludeOptions { + pub relationship: bool, + pub organization: bool, + pub goal: bool, + pub agreements: bool, +} + +impl IncludeOptions { + /// Creates an `IncludeOptions` with all resources disabled. + /// + /// This is the baseline configuration - only the base coaching session data + /// will be fetched without any related resources. + pub fn none() -> Self { + Self { + relationship: false, + organization: false, + goal: false, + agreements: false, + } + } + + /// Returns true if any option requires loading coaching_relationships data. + /// + /// This helper method determines whether we need to execute the batch query + /// for coaching_relationships. Currently, both the `relationship` and `organization` + /// options require this data (organization is accessed via relationship.organization_id). + pub fn needs_relationships(&self) -> bool { + self.relationship || self.organization + } + + /// Validates that the include options form a valid dependency graph. + /// + /// # Validation Rules + /// - `organization = true` requires `relationship = true` + /// (organizations are accessed through coaching_relationships) + /// + /// # Errors + /// Returns `EntityApiErrorKind::InvalidQueryTerm` if validation fails. + /// + /// # Why This Matters + /// Early validation at the entity_api layer prevents invalid database queries + /// and provides clear error messages to clients about invalid include combinations. + pub fn validate(&self) -> Result<(), Error> { + // organization requires relationship (can't get org without relationship) + if self.organization && !self.relationship { + return Err(Error { + source: None, + error_kind: EntityApiErrorKind::InvalidQueryTerm, + }); + } + Ok(()) + } +} +/// Find sessions by user with optional date filtering, sorting, and related data includes +pub async fn find_by_user_with_includes( + db: &impl ConnectionTrait, + user_id: Id, + from_date: Option, + to_date: Option, + sort_column: Option, + sort_order: Option, + includes: IncludeOptions, +) -> Result, Error> { + // Validate include options + includes.validate()?; + + // Build query for sessions filtered by user + let mut query = Entity::find() + .join(JoinType::InnerJoin, Relation::CoachingRelationships.def()) + .filter( + coaching_relationships::Column::CoachId + .eq(user_id) + .or(coaching_relationships::Column::CoacheeId.eq(user_id)), + ); + + // Apply date filtering + if let Some(from) = from_date { + query = query.filter(coaching_sessions::Column::Date.gte(from)); + } + + if let Some(to) = to_date { + // Use next day with less-than for inclusive end date + let end_of_day = to.succ_opt().unwrap_or(to); + query = query.filter(coaching_sessions::Column::Date.lt(end_of_day)); + } + + // Apply sorting if both column and order are provided + if let (Some(column), Some(order)) = (sort_column, sort_order) { + query = query.order_by(column, order); + } + + // Execute query to load base sessions + let sessions = query.all(db).await?; + + // Early return if no includes requested + if !includes.needs_relationships() && !includes.goal && !includes.agreements { + return Ok(sessions + .into_iter() + .map(EnrichedSession::from_session) + .collect()); + } + + // Load all related data in efficient batches + let related_data = load_related_data(db, &sessions, includes).await?; + + // Assemble enriched sessions + Ok(sessions + .into_iter() + .map(|session| assemble_enriched_session(session, &related_data)) + .collect()) +} + +/// Internal lookup tables for batch-loaded related data (not serialized). +/// +/// This struct stores ALL related resources in HashMaps for O(1) lookup during assembly. +/// Contrast with `EnrichedSession`, which holds the specific related data for a single session. +/// +/// **Usage:** Temporary container used only within `find_by_user_with_includes`: +/// 1. Load phase: Execute bulk queries, populate these HashMaps +/// 2. Assembly phase: For each session, lookup its specific related data by ID +/// 3. Discard: This struct is not returned to clients +#[derive(Debug, Default)] +struct RelatedData { + relationships: HashMap, + coaches: HashMap, + coachees: HashMap, + organizations: HashMap, + goals: HashMap, + agreements: HashMap, +} + +/// Load all requested related data in efficient batches +async fn load_related_data( + db: &impl ConnectionTrait, + sessions: &[Model], + includes: IncludeOptions, +) -> Result { + let mut data = RelatedData::default(); + + // Extract IDs for batch loading + let relationship_ids: Vec = sessions + .iter() + .map(|s| s.coaching_relationship_id) + .collect(); + let session_ids: Vec = sessions.iter().map(|s| s.id).collect(); + + // Load relationships (needed for both relationship and organization includes) + if includes.needs_relationships() { + data.relationships = batch_load_relationships(db, &relationship_ids).await?; + } + + // Load users (coaches and coachees) if relationship is included + if includes.relationship { + let coach_ids: Vec = data.relationships.values().map(|r| r.coach_id).collect(); + let coachee_ids: Vec = data.relationships.values().map(|r| r.coachee_id).collect(); + + data.coaches = batch_load_users(db, &coach_ids).await?; + data.coachees = batch_load_users(db, &coachee_ids).await?; + } + + // Load organizations if requested + if includes.organization { + let org_ids: Vec = data + .relationships + .values() + .map(|r| r.organization_id) + .collect(); + data.organizations = batch_load_organizations(db, &org_ids).await?; + } + + // Load goals by session_id + if includes.goal { + data.goals = batch_load_goals(db, &session_ids).await?; + } + + // Load agreements by session_id + if includes.agreements { + data.agreements = batch_load_agreements(db, &session_ids).await?; + } + + Ok(data) +} + +/// Batch load coaching relationships by IDs +async fn batch_load_relationships( + db: &impl ConnectionTrait, + ids: &[Id], +) -> Result, Error> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(coaching_relationships::Entity::find() + .filter(coaching_relationships::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|r| (r.id, r)) + .collect()) +} + +/// Batch load users by IDs +async fn batch_load_users( + db: &impl ConnectionTrait, + ids: &[Id], +) -> Result, Error> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(users::Entity::find() + .filter(users::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|u| (u.id, u)) + .collect()) +} + +/// Batch load organizations by IDs +async fn batch_load_organizations( + db: &impl ConnectionTrait, + ids: &[Id], +) -> Result, Error> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(organizations::Entity::find() + .filter(organizations::Column::Id.is_in(ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|o| (o.id, o)) + .collect()) +} + +/// Batch load overarching goals by session IDs +async fn batch_load_goals( + db: &impl ConnectionTrait, + session_ids: &[Id], +) -> Result, Error> { + if session_ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(overarching_goals::Entity::find() + .filter(overarching_goals::Column::CoachingSessionId.is_in(session_ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|g| (g.coaching_session_id, g)) + .collect()) +} + +/// Batch load agreements by session IDs +async fn batch_load_agreements( + db: &impl ConnectionTrait, + session_ids: &[Id], +) -> Result, Error> { + if session_ids.is_empty() { + return Ok(HashMap::new()); + } + + Ok(agreements::Entity::find() + .filter(agreements::Column::CoachingSessionId.is_in(session_ids.iter().copied())) + .all(db) + .await? + .into_iter() + .map(|a| (a.coaching_session_id, a)) + .collect()) +} + +/// Assemble an enriched session from base session and related data +fn assemble_enriched_session(session: Model, related: &RelatedData) -> EnrichedSession { + let relationship = related + .relationships + .get(&session.coaching_relationship_id) + .cloned(); + + let (coach, coachee) = relationship + .as_ref() + .map(|rel| { + ( + related.coaches.get(&rel.coach_id).cloned(), + related.coachees.get(&rel.coachee_id).cloned(), + ) + }) + .unwrap_or((None, None)); + + let organization = relationship + .as_ref() + .and_then(|rel| related.organizations.get(&rel.organization_id).cloned()); + + let overarching_goal = related.goals.get(&session.id).cloned(); + let agreement = related.agreements.get(&session.id).cloned(); + + EnrichedSession { + session, + relationship, + coach, + coachee, + organization, + overarching_goal, + agreement, + } +} + +impl EnrichedSession { + /// Create an enriched session from just the base session model + fn from_session(session: Model) -> Self { + Self { + session, + relationship: None, + coach: None, + coachee: None, + organization: None, + overarching_goal: None, + agreement: None, + } + } +} + #[cfg(test)] // We need to gate seaORM's mock feature behind conditional compilation because // the feature removes the Clone trait implementation from seaORM's DatabaseConnection. @@ -157,4 +583,203 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn find_by_user_returns_sessions_where_user_is_coach_or_coachee() -> Result<(), Error> { + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + + let user_id = Id::new_v4(); + let _ = find_by_user(&db, user_id).await; + + assert_eq!( + db.into_transaction_log(), + [Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"SELECT "coaching_sessions"."id", "coaching_sessions"."coaching_relationship_id", "coaching_sessions"."collab_document_name", "coaching_sessions"."date", "coaching_sessions"."created_at", "coaching_sessions"."updated_at" FROM "refactor_platform"."coaching_sessions" INNER JOIN "refactor_platform"."coaching_relationships" ON "coaching_sessions"."coaching_relationship_id" = "coaching_relationships"."id" WHERE "coaching_relationships"."coach_id" = $1 OR "coaching_relationships"."coachee_id" = $2"#, + [user_id.into(), user_id.into()] + )] + ); + + Ok(()) + } + + #[tokio::test] + async fn find_by_user_with_includes_no_includes_returns_basic_sessions() -> Result<(), Error> { + let now = chrono::Utc::now(); + let user_id = Id::new_v4(); + let session_id = Id::new_v4(); + let relationship_id = Id::new_v4(); + + let session = Model { + id: session_id, + coaching_relationship_id: relationship_id, + date: chrono::Local::now().naive_utc(), + collab_document_name: None, + created_at: now.into(), + updated_at: now.into(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![session.clone()]]) + .into_connection(); + + let includes = IncludeOptions::none(); + let results = + find_by_user_with_includes(&db, user_id, None, None, None, None, includes).await?; + + assert_eq!(results.len(), 1); + assert_eq!(results[0].session.id, session_id); + assert!(results[0].relationship.is_none()); + assert!(results[0].organization.is_none()); + assert!(results[0].overarching_goal.is_none()); + assert!(results[0].agreement.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn find_by_user_with_includes_with_date_filters() -> Result<(), Error> { + let now = chrono::Utc::now(); + let user_id = Id::new_v4(); + let from_date = chrono::NaiveDate::from_ymd_opt(2025, 10, 26).unwrap(); + let to_date = chrono::NaiveDate::from_ymd_opt(2025, 10, 27).unwrap(); + + // Create a session within the date range + let session = Model { + id: Id::new_v4(), + coaching_relationship_id: Id::new_v4(), + date: from_date.into(), + collab_document_name: None, + created_at: now.into(), + updated_at: now.into(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results(vec![vec![session.clone()]]) + .into_connection(); + + let includes = IncludeOptions::none(); + let results = find_by_user_with_includes( + &db, + user_id, + Some(from_date), + Some(to_date), + None, + None, + includes, + ) + .await?; + + assert_eq!(results.len(), 1); + assert_eq!(results[0].session.id, session.id); + assert_eq!(results[0].session.date, from_date.into()); + + Ok(()) + } + + #[tokio::test] + async fn include_options_needs_relationships_returns_true_when_relationship_included() { + let includes = IncludeOptions { + relationship: true, + organization: false, + goal: false, + agreements: false, + }; + assert!(includes.needs_relationships()); + } + + #[tokio::test] + async fn include_options_needs_relationships_returns_true_when_organization_included() { + let includes = IncludeOptions { + relationship: false, + organization: true, + goal: false, + agreements: false, + }; + assert!(includes.needs_relationships()); + } + + #[tokio::test] + async fn include_options_needs_relationships_returns_false_when_only_goals() { + let includes = IncludeOptions { + relationship: false, + organization: false, + goal: true, + agreements: false, + }; + assert!(!includes.needs_relationships()); + } + + #[tokio::test] + async fn enriched_session_from_session_creates_empty_enrichment() { + let now = chrono::Utc::now(); + let session = Model { + id: Id::new_v4(), + coaching_relationship_id: Id::new_v4(), + date: chrono::Local::now().naive_utc(), + collab_document_name: None, + created_at: now.into(), + updated_at: now.into(), + }; + + let enriched = EnrichedSession::from_session(session.clone()); + + assert_eq!(enriched.session.id, session.id); + assert!(enriched.relationship.is_none()); + assert!(enriched.coach.is_none()); + assert!(enriched.coachee.is_none()); + assert!(enriched.organization.is_none()); + assert!(enriched.overarching_goal.is_none()); + assert!(enriched.agreement.is_none()); + } + + #[test] + fn validate_allows_organization_with_relationship() { + let includes = IncludeOptions { + relationship: true, + organization: true, + goal: false, + agreements: false, + }; + assert!(includes.validate().is_ok()); + } + + #[test] + fn validate_rejects_organization_without_relationship() { + let includes = IncludeOptions { + relationship: false, + organization: true, + goal: false, + agreements: false, + }; + assert!(includes.validate().is_err()); + } + + #[test] + fn validate_allows_goal_alone() { + let includes = IncludeOptions { + relationship: false, + organization: false, + goal: true, + agreements: false, + }; + assert!(includes.validate().is_ok()); + } + + #[test] + fn validate_allows_all_includes() { + let includes = IncludeOptions { + relationship: true, + organization: true, + goal: true, + agreements: true, + }; + assert!(includes.validate().is_ok()); + } + + #[test] + fn validate_allows_none() { + let includes = IncludeOptions::none(); + assert!(includes.validate().is_ok()); + } } diff --git a/entity_api/src/lib.rs b/entity_api/src/lib.rs index e774d05b..b9e1bdeb 100644 --- a/entity_api/src/lib.rs +++ b/entity_api/src/lib.rs @@ -4,7 +4,7 @@ use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; pub use entity::{ actions, agreements, coachees, coaches, coaching_relationships, coaching_sessions, jwts, notes, - organizations, overarching_goals, user_roles, users, users::Role, Id, + organizations, overarching_goals, status, user_roles, users, users::Role, Id, }; pub mod action; diff --git a/web/src/controller/user/action_controller.rs b/web/src/controller/user/action_controller.rs new file mode 100644 index 00000000..19133d97 --- /dev/null +++ b/web/src/controller/user/action_controller.rs @@ -0,0 +1,57 @@ +use crate::controller::ApiResponse; +use crate::extractors::{ + authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion, +}; +use crate::params::user::action::IndexParams; +use crate::{AppState, Error}; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use domain::{action as ActionApi, Id}; +use service::config::ApiVersion; + +use log::*; + +/// GET all actions for a specific user +#[utoipa::path( + get, + path = "/users/{user_id}/actions", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID to retrieve actions for"), + ("coaching_session_id" = Option, Query, description = "Filter by coaching_session_id"), + ("status" = Option, Query, description = "Filter by action status"), + ("sort_by" = Option, Query, description = "Sort by field. Valid values: 'due_by', 'created_at', 'updated_at'. Must be provided with sort_order.", example = "created_at"), + ("sort_order" = Option, Query, description = "Sort order. Valid values: 'asc' (ascending), 'desc' (descending). Must be provided with sort_by.", example = "desc") + ), + responses( + (status = 200, description = "Successfully retrieved actions for user", body = [domain::actions::Model]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn index( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, + Query(params): Query, +) -> Result { + debug!("GET Actions for User: {user_id}"); + debug!("Filter Params: {params:?}"); + + // Set user_id from path parameter and apply default sorting + let params = params.with_user_id(user_id).apply_defaults(); + + let actions = ActionApi::find_by(app_state.db_conn_ref(), params).await?; + + debug!("Found {} actions for user {user_id}", actions.len()); + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), actions))) +} diff --git a/web/src/controller/user/coaching_session_controller.rs b/web/src/controller/user/coaching_session_controller.rs new file mode 100644 index 00000000..92847e97 --- /dev/null +++ b/web/src/controller/user/coaching_session_controller.rs @@ -0,0 +1,86 @@ +use crate::controller::ApiResponse; +use crate::extractors::{ + authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion, +}; +use crate::params::user::coaching_session::{IncludeParam, IndexParams}; +use crate::{AppState, Error}; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use domain::{coaching_session as CoachingSessionApi, Id, QuerySort}; +use service::config::ApiVersion; + +use log::*; + +/// GET all coaching sessions for a specific user with optional related data +#[utoipa::path( + get, + path = "/users/{user_id}/coaching_sessions", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID to retrieve coaching sessions for"), + ("from_date" = Option, Query, description = "Filter by from_date (inclusive, UTC)"), + ("to_date" = Option, Query, description = "Filter by to_date (inclusive, UTC)"), + ("include" = Option, Query, description = "Comma-separated list of related resources to include. Valid values: 'relationship', 'organization', 'goal', 'agreements'. Example: 'relationship,organization,goal'"), + ("sort_by" = Option, Query, description = "Sort by field. Valid values: 'date', 'created_at', 'updated_at'. Must be provided with sort_order.", example = "date"), + ("sort_order" = Option, Query, description = "Sort order. Valid values: 'asc' (ascending), 'desc' (descending). Must be provided with sort_by.", example = "desc") + ), + responses( + (status = 200, description = "Successfully retrieved coaching sessions for user", body = [domain::coaching_session::EnrichedSession]), + (status = 400, description = "Bad Request - Invalid include parameter"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn index( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, + Query(params): Query, +) -> Result { + debug!("GET Coaching Sessions for User: {user_id}"); + debug!("Query Params: {params:?}"); + + // Set user_id from path parameter and apply defaults + let params = params.with_user_id(user_id).apply_defaults(); + + // Build include options from parameters + let includes = CoachingSessionApi::IncludeOptions { + relationship: params.include.contains(&IncludeParam::Relationship), + organization: params.include.contains(&IncludeParam::Organization), + goal: params.include.contains(&IncludeParam::Goal), + agreements: params.include.contains(&IncludeParam::Agreements), + }; + let sort_column = params.get_sort_column(); + let sort_order = params.get_sort_order(); + + // Fetch sessions with optional includes and sorting at database level + let enriched_sessions = CoachingSessionApi::find_by_user_with_includes( + app_state.db_conn_ref(), + user_id, + params.from_date, + params.to_date, + sort_column, + sort_order, + includes, + ) + .await?; + + debug!( + "Found {} coaching sessions for user {user_id}", + enriched_sessions.len() + ); + + // Return entity_api type directly - it's already serializable + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + enriched_sessions, + ))) +} diff --git a/web/src/controller/user/mod.rs b/web/src/controller/user/mod.rs index 770c26a2..55fdf17e 100644 --- a/web/src/controller/user/mod.rs +++ b/web/src/controller/user/mod.rs @@ -1 +1,5 @@ +pub(crate) mod action_controller; +pub(crate) mod coaching_session_controller; +pub(crate) mod organization_controller; +pub(crate) mod overarching_goal_controller; pub(crate) mod password_controller; diff --git a/web/src/controller/user/organization_controller.rs b/web/src/controller/user/organization_controller.rs new file mode 100644 index 00000000..f799de99 --- /dev/null +++ b/web/src/controller/user/organization_controller.rs @@ -0,0 +1,50 @@ +use crate::controller::ApiResponse; +use crate::extractors::{ + authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion, +}; +use crate::{AppState, Error}; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use domain::{organization as OrganizationApi, Id}; +use service::config::ApiVersion; + +use log::*; + +/// GET all organizations for a specific user +#[utoipa::path( + get, + path = "/users/{user_id}/organizations", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID to retrieve organizations for") + ), + responses( + (status = 200, description = "Successfully retrieved organizations for user", body = [domain::organizations::Model]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn index( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, +) -> Result { + debug!("GET Organizations for User: {user_id}"); + + let organizations = OrganizationApi::find_by_user(app_state.db_conn_ref(), user_id).await?; + + debug!( + "Found {} organizations for user {user_id}", + organizations.len() + ); + + Ok(Json(ApiResponse::new(StatusCode::OK.into(), organizations))) +} diff --git a/web/src/controller/user/overarching_goal_controller.rs b/web/src/controller/user/overarching_goal_controller.rs new file mode 100644 index 00000000..abb57604 --- /dev/null +++ b/web/src/controller/user/overarching_goal_controller.rs @@ -0,0 +1,62 @@ +use crate::controller::ApiResponse; +use crate::extractors::{ + authenticated_user::AuthenticatedUser, compare_api_version::CompareApiVersion, +}; +use crate::params::user::overarching_goal::IndexParams; +use crate::{AppState, Error}; +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use domain::{overarching_goal as OverarchingGoalApi, Id}; +use service::config::ApiVersion; + +use log::*; + +/// GET all overarching goals for a specific user +#[utoipa::path( + get, + path = "/users/{user_id}/overarching_goals", + params( + ApiVersion, + ("user_id" = Id, Path, description = "User ID to retrieve overarching goals for"), + ("coaching_session_id" = Option, Query, description = "Filter by coaching_session_id"), + ("sort_by" = Option, Query, description = "Sort by field. Valid values: 'title', 'created_at', 'updated_at'. Must be provided with sort_order.", example = "title"), + ("sort_order" = Option, Query, description = "Sort order. Valid values: 'asc' (ascending), 'desc' (descending). Must be provided with sort_by.", example = "desc") + ), + responses( + (status = 200, description = "Successfully retrieved overarching goals for user", body = [domain::overarching_goals::Model]), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "User not found"), + (status = 405, description = "Method not allowed") + ), + security( + ("cookie_auth" = []) + ) +)] +pub async fn index( + CompareApiVersion(_v): CompareApiVersion, + AuthenticatedUser(_user): AuthenticatedUser, + State(app_state): State, + Path(user_id): Path, + Query(params): Query, +) -> Result { + debug!("GET Overarching Goals for User: {user_id}"); + debug!("Filter Params: {params:?}"); + + // Set user_id from path parameter and apply default sorting + let params = params.with_user_id(user_id).apply_defaults(); + + let overarching_goals = OverarchingGoalApi::find_by(app_state.db_conn_ref(), params).await?; + + debug!( + "Found {} overarching goals for user {user_id}", + overarching_goals.len() + ); + + Ok(Json(ApiResponse::new( + StatusCode::OK.into(), + overarching_goals, + ))) +} diff --git a/web/src/params/user/action.rs b/web/src/params/user/action.rs new file mode 100644 index 00000000..178027db --- /dev/null +++ b/web/src/params/user/action.rs @@ -0,0 +1,112 @@ +use sea_orm::{Order, Value}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use crate::params::sort::SortOrder; +use crate::params::WithSortDefaults; +use domain::{actions, status::Status, Id, IntoQueryFilterMap, QueryFilterMap, QuerySort}; + +/// Sortable fields for user actions endpoint. +/// +/// Maps query parameter values (e.g., `?sort_by=due_by`) to database columns. +#[derive(Debug, Deserialize, ToSchema)] +#[schema(example = "created_at")] +pub(crate) enum SortField { + #[serde(rename = "due_by")] + DueBy, + #[serde(rename = "created_at")] + CreatedAt, + #[serde(rename = "updated_at")] + UpdatedAt, +} + +/// Query parameters for GET `/users/{user_id}/actions` endpoint. +/// +/// Supports filtering by coaching session and status, plus standard sorting. +/// The `user_id` is populated from the URL path parameter, not query string. +#[derive(Debug, Deserialize, IntoParams)] +pub(crate) struct IndexParams { + /// User ID from URL path (not a query parameter) + #[serde(skip)] + pub(crate) user_id: Id, + /// Optional: filter actions by coaching session + pub(crate) coaching_session_id: Option, + /// Optional: filter actions by status (e.g., "open", "completed") + pub(crate) status: Option, + /// Optional: field to sort by (defaults via WithSortDefaults) + pub(crate) sort_by: Option, + /// Optional: sort direction (defaults via WithSortDefaults) + pub(crate) sort_order: Option, +} + +impl IndexParams { + /// Sets the user_id field (useful when user_id comes from path parameter). + /// + /// This allows using `Query` to deserialize query parameters, + /// then setting the path-based user_id afterward. + pub fn with_user_id(mut self, user_id: Id) -> Self { + self.user_id = user_id; + self + } + + /// Applies default sorting parameters if any sort parameter is provided. + /// + /// Uses `CreatedAt` as the default sort field for actions. + /// This encapsulates the default field choice within the params module. + pub fn apply_defaults(mut self) -> Self { + ::apply_sort_defaults( + &mut self.sort_by, + &mut self.sort_order, + SortField::CreatedAt, + ); + self + } +} + +impl IntoQueryFilterMap for IndexParams { + fn into_query_filter_map(self) -> QueryFilterMap { + let mut query_filter_map = QueryFilterMap::new(); + + query_filter_map.insert( + "user_id".to_string(), + Some(Value::Uuid(Some(Box::new(self.user_id)))), + ); + + if let Some(coaching_session_id) = self.coaching_session_id { + query_filter_map.insert( + "coaching_session_id".to_string(), + Some(Value::Uuid(Some(Box::new(coaching_session_id)))), + ); + } + + if let Some(status) = self.status { + query_filter_map.insert( + "status".to_string(), + Some(Value::String(Some(Box::new(status.to_string())))), + ); + } + + query_filter_map + } +} + +impl QuerySort for IndexParams { + fn get_sort_column(&self) -> Option { + self.sort_by.as_ref().map(|field| match field { + SortField::DueBy => actions::Column::DueBy, + SortField::CreatedAt => actions::Column::CreatedAt, + SortField::UpdatedAt => actions::Column::UpdatedAt, + }) + } + + fn get_sort_order(&self) -> Option { + self.sort_order.as_ref().map(|order| match order { + SortOrder::Asc => Order::Asc, + SortOrder::Desc => Order::Desc, + }) + } +} + +impl WithSortDefaults for IndexParams { + type SortField = SortField; +} diff --git a/web/src/params/user/coaching_session.rs b/web/src/params/user/coaching_session.rs new file mode 100644 index 00000000..4a5c7680 --- /dev/null +++ b/web/src/params/user/coaching_session.rs @@ -0,0 +1,121 @@ +use chrono::NaiveDate; +use sea_orm::Order; +use serde::Deserialize; +use utoipa::IntoParams; + +use crate::params::coaching_session::SortField; +use crate::params::sort::SortOrder; +use crate::params::WithSortDefaults; +use domain::{coaching_sessions, Id, QuerySort}; + +/// Related resources that can be batch-loaded with coaching sessions. +/// +/// Used in `?include=` query parameter to eliminate N+1 queries. Supports +/// comma-separated values: `?include=relationship,organization,goal,agreements` +/// +/// Maps to `entity_api::coaching_session::IncludeOptions` for database queries. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub enum IncludeParam { + /// Include coaching relationship (coach/coachee info) + Relationship, + /// Include organization (requires relationship) + Organization, + /// Include overarching goal + Goal, + /// Include session agreements + Agreements, +} + +/// Query parameters for GET `/users/{user_id}/coaching_sessions` endpoint. +/// +/// Supports date range filtering, sorting, and optional batch loading of related resources. +/// The enhanced `include` parameter enables efficient data fetching (see `IncludeParam`). +#[derive(Debug, Deserialize, IntoParams)] +pub(crate) struct IndexParams { + /// User ID from URL path (not a query parameter) + #[serde(skip)] + pub(crate) user_id: Id, + /// Optional: filter sessions starting from this date (inclusive) + pub(crate) from_date: Option, + /// Optional: filter sessions up to this date (inclusive) + pub(crate) to_date: Option, + /// Optional: field to sort by (e.g., "date", "created_at") + pub(crate) sort_by: Option, + /// Optional: sort direction (asc/desc) + pub(crate) sort_order: Option, + /// Optional: comma-separated list of related resources to batch-load + /// + /// Example: `?include=relationship,organization,goal` + /// + /// See `IncludeParam` for valid values and N+1 query optimization details. + #[serde(default, deserialize_with = "deserialize_comma_separated")] + pub(crate) include: Vec, +} + +/// Custom deserializer for comma-separated include parameter +fn deserialize_comma_separated<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: Option = Option::deserialize(deserializer)?; + match s { + None => Ok(Vec::new()), + Some(s) if s.is_empty() => Ok(Vec::new()), + Some(s) => s + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| { + serde_json::from_value(serde_json::Value::String(s.to_string())) + .map_err(serde::de::Error::custom) + }) + .collect(), + } +} + +impl IndexParams { + /// Sets the user_id field (useful when user_id comes from path parameter). + /// + /// This allows using `Query` to deserialize query parameters, + /// then setting the path-based user_id afterward for consistency with other + /// user sub-resource endpoints. + pub fn with_user_id(mut self, user_id: Id) -> Self { + self.user_id = user_id; + self + } + + /// Applies default sorting parameters if any sort parameter is provided. + /// + /// Uses `Date` as the default sort field for coaching sessions. + /// This encapsulates the default field choice within the params module. + pub fn apply_defaults(mut self) -> Self { + ::apply_sort_defaults( + &mut self.sort_by, + &mut self.sort_order, + SortField::Date, + ); + self + } +} + +impl QuerySort for IndexParams { + fn get_sort_column(&self) -> Option { + self.sort_by.as_ref().map(|field| match field { + SortField::Date => coaching_sessions::Column::Date, + SortField::CreatedAt => coaching_sessions::Column::CreatedAt, + SortField::UpdatedAt => coaching_sessions::Column::UpdatedAt, + }) + } + + fn get_sort_order(&self) -> Option { + self.sort_order.as_ref().map(|order| match order { + SortOrder::Asc => Order::Asc, + SortOrder::Desc => Order::Desc, + }) + } +} + +impl WithSortDefaults for IndexParams { + type SortField = SortField; +} diff --git a/web/src/params/user.rs b/web/src/params/user/mod.rs similarity index 94% rename from web/src/params/user.rs rename to web/src/params/user/mod.rs index 56c53bfb..2623e95f 100644 --- a/web/src/params/user.rs +++ b/web/src/params/user/mod.rs @@ -1,7 +1,13 @@ +pub(crate) mod action; +pub(crate) mod coaching_session; +pub(crate) mod overarching_goal; + +// Re-export user profile update params for backward compatibility use domain::{IntoUpdateMap, UpdateMap}; use sea_orm::Value; use serde::Deserialize; use utoipa::{IntoParams, ToSchema}; + #[derive(Debug, Deserialize, IntoParams, ToSchema)] pub struct UpdateParams { pub email: Option, @@ -11,6 +17,7 @@ pub struct UpdateParams { pub github_profile_url: Option, pub timezone: Option, } + impl IntoUpdateMap for UpdateParams { fn into_update_map(self) -> UpdateMap { let mut update_map = UpdateMap::new(); diff --git a/web/src/params/user/overarching_goal.rs b/web/src/params/user/overarching_goal.rs new file mode 100644 index 00000000..da11fe20 --- /dev/null +++ b/web/src/params/user/overarching_goal.rs @@ -0,0 +1,103 @@ +use sea_orm::{Order, Value}; +use serde::Deserialize; +use utoipa::{IntoParams, ToSchema}; + +use crate::params::sort::SortOrder; +use crate::params::WithSortDefaults; +use domain::{overarching_goals, Id, IntoQueryFilterMap, QueryFilterMap, QuerySort}; + +/// Sortable fields for user overarching goals endpoint. +/// +/// Maps query parameter values (e.g., `?sort_by=title`) to database columns. +#[derive(Debug, Deserialize, ToSchema)] +#[schema(example = "title")] +pub(crate) enum SortField { + #[serde(rename = "title")] + Title, + #[serde(rename = "created_at")] + CreatedAt, + #[serde(rename = "updated_at")] + UpdatedAt, +} + +/// Query parameters for GET `/users/{user_id}/overarching_goals` endpoint. +/// +/// Supports filtering by coaching session and standard sorting. +/// Overarching goals are long-term objectives that span multiple coaching sessions. +#[derive(Debug, Deserialize, IntoParams)] +pub(crate) struct IndexParams { + /// User ID from URL path (not a query parameter) + #[serde(skip)] + pub(crate) user_id: Id, + /// Optional: filter goals associated with a specific coaching session + pub(crate) coaching_session_id: Option, + /// Optional: field to sort by (defaults via WithSortDefaults) + pub(crate) sort_by: Option, + /// Optional: sort direction (defaults via WithSortDefaults) + pub(crate) sort_order: Option, +} + +impl IndexParams { + /// Sets the user_id field (useful when user_id comes from path parameter). + /// + /// This allows using `Query` to deserialize query parameters, + /// then setting the path-based user_id afterward. + pub fn with_user_id(mut self, user_id: Id) -> Self { + self.user_id = user_id; + self + } + + /// Applies default sorting parameters if any sort parameter is provided. + /// + /// Uses `Title` as the default sort field for overarching goals. + /// This encapsulates the default field choice within the params module. + pub fn apply_defaults(mut self) -> Self { + ::apply_sort_defaults( + &mut self.sort_by, + &mut self.sort_order, + SortField::Title, + ); + self + } +} + +impl IntoQueryFilterMap for IndexParams { + fn into_query_filter_map(self) -> QueryFilterMap { + let mut query_filter_map = QueryFilterMap::new(); + + query_filter_map.insert( + "user_id".to_string(), + Some(Value::Uuid(Some(Box::new(self.user_id)))), + ); + + if let Some(coaching_session_id) = self.coaching_session_id { + query_filter_map.insert( + "coaching_session_id".to_string(), + Some(Value::Uuid(Some(Box::new(coaching_session_id)))), + ); + } + + query_filter_map + } +} + +impl QuerySort for IndexParams { + fn get_sort_column(&self) -> Option { + self.sort_by.as_ref().map(|field| match field { + SortField::Title => overarching_goals::Column::Title, + SortField::CreatedAt => overarching_goals::Column::CreatedAt, + SortField::UpdatedAt => overarching_goals::Column::UpdatedAt, + }) + } + + fn get_sort_order(&self) -> Option { + self.sort_order.as_ref().map(|order| match order { + SortOrder::Asc => Order::Asc, + SortOrder::Desc => Order::Desc, + }) + } +} + +impl WithSortDefaults for IndexParams { + type SortField = SortField; +} diff --git a/web/src/protect/users/actions.rs b/web/src/protect/users/actions.rs new file mode 100644 index 00000000..02f4fd47 --- /dev/null +++ b/web/src/protect/users/actions.rs @@ -0,0 +1,29 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use domain::Id; +use log::*; + +/// Checks that the `user_id` matches the `authenticated_user.id` +pub(crate) async fn index( + State(_app_state): State, + AuthenticatedUser(authenticated_user): AuthenticatedUser, + Path(user_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + // check that we are only allowing authenticated users to read their own actions (for now) + if authenticated_user.id == user_id { + next.run(request).await + } else { + error!( + "Unauthorized: user_id {} does not match authenticated_user_id {}", + user_id, authenticated_user.id + ); + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} diff --git a/web/src/protect/users/coaching_sessions.rs b/web/src/protect/users/coaching_sessions.rs new file mode 100644 index 00000000..03361855 --- /dev/null +++ b/web/src/protect/users/coaching_sessions.rs @@ -0,0 +1,29 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use domain::Id; +use log::*; + +/// Checks that the `user_id` matches the `authenticated_user.id` +pub(crate) async fn index( + State(_app_state): State, + AuthenticatedUser(authenticated_user): AuthenticatedUser, + Path(user_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + // check that we are only allowing authenticated users to read their own coaching sessions (for now) + if authenticated_user.id == user_id { + next.run(request).await + } else { + error!( + "Unauthorized: user_id {} does not match authenticated_user_id {}", + user_id, authenticated_user.id + ); + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} diff --git a/web/src/protect/users/mod.rs b/web/src/protect/users/mod.rs index db5ba00e..822f78a4 100644 --- a/web/src/protect/users/mod.rs +++ b/web/src/protect/users/mod.rs @@ -8,10 +8,13 @@ use axum::{ use domain::Id; use log::*; +pub(crate) mod actions; +pub(crate) mod coaching_sessions; +pub(crate) mod organizations; +pub(crate) mod overarching_goals; pub(crate) mod passwords; -// checks: -// - that the `user_id` matches the `authenticated_user.id` +/// Checks that the `user_id` matches the `authenticated_user.id` pub(crate) async fn read( State(_app_state): State, AuthenticatedUser(authenticated_user): AuthenticatedUser, @@ -31,8 +34,7 @@ pub(crate) async fn read( } } -// checks: -// - that the `user_id` matches the `authenticated_user.id` +/// Checks that the `user_id` matches the `authenticated_user.id` pub(crate) async fn update( State(_app_state): State, AuthenticatedUser(authenticated_user): AuthenticatedUser, diff --git a/web/src/protect/users/organizations.rs b/web/src/protect/users/organizations.rs new file mode 100644 index 00000000..768a4a6f --- /dev/null +++ b/web/src/protect/users/organizations.rs @@ -0,0 +1,29 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use domain::Id; +use log::*; + +/// Checks that the `user_id` matches the `authenticated_user.id` +pub(crate) async fn index( + State(_app_state): State, + AuthenticatedUser(authenticated_user): AuthenticatedUser, + Path(user_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + // check that we are only allowing authenticated users to read their own organizations (for now) + if authenticated_user.id == user_id { + next.run(request).await + } else { + error!( + "Unauthorized: user_id {} does not match authenticated_user_id {}", + user_id, authenticated_user.id + ); + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} diff --git a/web/src/protect/users/overarching_goals.rs b/web/src/protect/users/overarching_goals.rs new file mode 100644 index 00000000..eeb186ad --- /dev/null +++ b/web/src/protect/users/overarching_goals.rs @@ -0,0 +1,29 @@ +use crate::{extractors::authenticated_user::AuthenticatedUser, AppState}; +use axum::{ + extract::{Path, Request, State}, + http::StatusCode, + middleware::Next, + response::IntoResponse, +}; +use domain::Id; +use log::*; + +/// Checks that the `user_id` matches the `authenticated_user.id` +pub(crate) async fn index( + State(_app_state): State, + AuthenticatedUser(authenticated_user): AuthenticatedUser, + Path(user_id): Path, + request: Request, + next: Next, +) -> impl IntoResponse { + // check that we are only allowing authenticated users to read their own overarching goals (for now) + if authenticated_user.id == user_id { + next.run(request).await + } else { + error!( + "Unauthorized: user_id {} does not match authenticated_user_id {}", + user_id, authenticated_user.id + ); + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } +} diff --git a/web/src/router.rs b/web/src/router.rs index 023af472..5550c30e 100644 --- a/web/src/router.rs +++ b/web/src/router.rs @@ -70,6 +70,10 @@ use self::organization::coaching_relationship_controller; user_session_controller::login, user_session_controller::delete, user::password_controller::update_password, + user::organization_controller::index, + user::action_controller::index, + user::coaching_session_controller::index, + user::overarching_goal_controller::index, jwt_controller::generate_collab_token, ), components( @@ -124,6 +128,10 @@ pub fn define_routes(app_state: AppState) -> Router { .merge(overarching_goal_routes(app_state.clone())) .merge(user_routes(app_state.clone())) .merge(user_password_routes(app_state.clone())) + .merge(user_organizations_routes(app_state.clone())) + .merge(user_actions_routes(app_state.clone())) + .merge(user_coaching_sessions_routes(app_state.clone())) + .merge(user_overarching_goals_routes(app_state.clone())) .merge(user_session_routes()) .merge(user_session_protected_routes(app_state.clone())) .merge(coaching_sessions_routes(app_state.clone())) @@ -432,6 +440,74 @@ fn jwt_routes(app_state: AppState) -> Router { .with_state(app_state) } +fn user_organizations_routes(app_state: AppState) -> Router { + Router::new() + .merge( + Router::new() + .route( + "/users/:user_id/organizations", + get(user::organization_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::users::organizations::index, + )), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +fn user_actions_routes(app_state: AppState) -> Router { + Router::new() + .merge( + Router::new() + .route( + "/users/:user_id/actions", + get(user::action_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::users::actions::index, + )), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +fn user_coaching_sessions_routes(app_state: AppState) -> Router { + Router::new() + .merge( + Router::new() + .route( + "/users/:user_id/coaching_sessions", + get(user::coaching_session_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::users::coaching_sessions::index, + )), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + +fn user_overarching_goals_routes(app_state: AppState) -> Router { + Router::new() + .merge( + Router::new() + .route( + "/users/:user_id/overarching_goals", + get(user::overarching_goal_controller::index), + ) + .route_layer(from_fn_with_state( + app_state.clone(), + protect::users::overarching_goals::index, + )), + ) + .route_layer(from_fn(require_auth)) + .with_state(app_state) +} + // This will serve static files that we can use as a "fallback" for when the server panics pub fn static_routes() -> Router { Router::new().nest_service("/", ServeDir::new("./"))