diff options
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 85 | ||||
| -rw-r--r-- | makima/src/server/handlers/users.rs | 154 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 5 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 4 |
4 files changed, 231 insertions, 17 deletions
diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index d1edf7e..01c4659 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -18,7 +18,10 @@ use crate::db::models::{ OrderListResponse, }; use crate::db::repository; -use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt}; +use crate::orchestration::directive::{ + build_cleanup_prompt, build_order_pickup_prompt, classify_goal_change, + try_interrupt_planner_with_goal_edit, GoalChangeKind, GoalEditInterruptResult, +}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; @@ -827,31 +830,79 @@ pub async fn update_goal( .into_response(); }; - // Save old goal to history before overwriting (best-effort) - match repository::get_directive_for_owner(pool, auth.owner_id, id).await { - Ok(Some(current)) => { - if let Err(e) = repository::save_directive_goal_history(pool, id, ¤t.goal).await - { - tracing::warn!( - directive_id = %id, - error = %e, - "Failed to save goal history before update — continuing with goal update" - ); - } - } - Ok(None) => { - // Directive not found — update_directive_goal will handle this - } + // Fetch the current directive so we can: + // 1. Save the old goal to history (best-effort). + // 2. Decide whether to fire a goal-edit interrupt at a running planner. + let current = match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(d)) => Some(d), + Ok(None) => None, Err(e) => { tracing::warn!( directive_id = %id, error = %e, "Failed to fetch current directive for goal history — continuing with goal update" ); + None + } + }; + + // Save old goal to history before overwriting (best-effort). + if let Some(ref current) = current { + if let Err(e) = repository::save_directive_goal_history(pool, id, ¤t.goal).await { + tracing::warn!( + directive_id = %id, + error = %e, + "Failed to save goal history before update — continuing with goal update" + ); + } + } + + // Goal-edit interrupt cycle: if a planner task is currently running for + // this directive AND the goal change classifies as 'small', interrupt the + // running planner via SendMessage instead of clearing it (which would + // trigger a fresh replan on the next orchestrator tick). + let mut interrupted = false; + if let Some(ref current) = current { + if current.orchestrator_task_id.is_some() + && classify_goal_change(¤t.goal, &req.goal) == GoalChangeKind::Small + { + match try_interrupt_planner_with_goal_edit( + pool, + &state, + id, + ¤t.goal, + &req.goal, + ) + .await + { + Ok(GoalEditInterruptResult::Sent) => { + interrupted = true; + } + Ok(GoalEditInterruptResult::Skipped) => {} + Err(e) => { + tracing::warn!( + directive_id = %id, + error = %e, + "Goal-edit interrupt attempt errored — falling back to replan" + ); + } + } } } - match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await { + // If we successfully interrupted a running planner, persist the new goal + // WITHOUT clearing the orchestrator task — the planner will react to the + // SendMessage and adjust in-flight. Otherwise, fall through to the normal + // path which clears orchestrator_task_id and lets phase_replanning kick + // in on the next tick. + let update_result = if interrupted { + repository::update_directive_goal_keep_orchestrator(pool, auth.owner_id, id, &req.goal) + .await + } else { + repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await + }; + + match update_result { Ok(Some(directive)) => Json(directive).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs index 0b2ccdd..0b86592 100644 --- a/makima/src/server/handlers/users.rs +++ b/makima/src/server/handlers/users.rs @@ -928,6 +928,160 @@ pub async fn delete_account_handler( } // ============================================================================= +// User Settings (per-user feature flags) +// ============================================================================= + +/// User settings response. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSettingsResponse { + /// Whether the new "document mode" UI is enabled for this user. + pub document_mode_enabled: bool, +} + +/// Update user settings request. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateUserSettingsRequest { + /// Whether to enable the new "document mode" UI for this user. + pub document_mode_enabled: bool, +} + +/// Get the authenticated user's settings (feature flags). +/// +/// Returns the user's per-user settings, currently consisting of feature flags +/// used by the frontend to decide which UI to show. +#[utoipa::path( + get, + path = "/api/v1/users/me/settings", + responses( + (status = 200, description = "User settings", body = UserSettingsResponse), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Users" +)] +pub async fn get_user_settings_handler( + State(state): State<SharedState>, + UserOnly(user): UserOnly, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match sqlx::query_scalar::<_, bool>( + "SELECT document_mode_enabled FROM users WHERE id = $1", + ) + .bind(user.user_id) + .fetch_optional(pool) + .await + { + Ok(Some(document_mode_enabled)) => Json(UserSettingsResponse { + document_mode_enabled, + }) + .into_response(), + Ok(None) => { + // User row missing — fall back to defaults rather than 404 since the + // user is authenticated. + tracing::warn!(user_id = %user.user_id, "User row not found when fetching settings — returning defaults"); + Json(UserSettingsResponse { + document_mode_enabled: false, + }) + .into_response() + } + Err(e) => { + tracing::error!("Failed to fetch user settings: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update the authenticated user's settings (feature flags). +/// +/// Replaces the user's settings record with the provided values. +#[utoipa::path( + put, + path = "/api/v1/users/me/settings", + request_body = UpdateUserSettingsRequest, + responses( + (status = 200, description = "Updated user settings", body = UserSettingsResponse), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "User not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Users" +)] +pub async fn update_user_settings_handler( + State(state): State<SharedState>, + UserOnly(user): UserOnly, + Json(req): Json<UpdateUserSettingsRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + let result = sqlx::query_scalar::<_, bool>( + r#" + UPDATE users + SET document_mode_enabled = $1, updated_at = NOW() + WHERE id = $2 + RETURNING document_mode_enabled + "#, + ) + .bind(req.document_mode_enabled) + .bind(user.user_id) + .fetch_optional(pool) + .await; + + match result { + Ok(Some(document_mode_enabled)) => { + tracing::info!( + user_id = %user.user_id, + document_mode_enabled = document_mode_enabled, + "Updated user settings" + ); + Json(UserSettingsResponse { + document_mode_enabled, + }) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("USER_NOT_FOUND", "User not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update user settings: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= // Tests // ============================================================================= diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b382f04..beee0e9 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -162,6 +162,11 @@ pub fn make_router(state: SharedState) -> Router { ) .route("/users/me/password", axum::routing::put(users::change_password_handler)) .route("/users/me/email", axum::routing::put(users::change_email_handler)) + .route( + "/users/me/settings", + get(users::get_user_settings_handler) + .put(users::update_user_settings_handler), + ) // Contract endpoints .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler)) .route( diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 6065eeb..37cd113 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -90,6 +90,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::change_password_handler, users::change_email_handler, users::delete_account_handler, + users::get_user_settings_handler, + users::update_user_settings_handler, // Contract endpoints contracts::list_contracts, contracts::get_contract, @@ -210,6 +212,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::ChangeEmailResponse, users::DeleteAccountRequest, users::DeleteAccountResponse, + users::UserSettingsResponse, + users::UpdateUserSettingsRequest, // Contract schemas Contract, ContractSummary, |
