summaryrefslogtreecommitdiff
path: root/makima/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-04-29 01:10:11 +0100
committerGitHub <noreply@github.com>2026-04-29 01:10:11 +0100
commit4b1d608b839769052634b4facc345b891d468926 (patch)
tree1d5ff45b5b34b2e3e378a4cf69fd62ff39cf12de /makima/src
parent5bde7c2d7e099fd9c8b2615602ab1d096bd9b6be (diff)
downloadsoryu-4b1d608b839769052634b4facc345b891d468926.tar.gz
soryu-4b1d608b839769052634b4facc345b891d468926.zip
feat: document-mode directive UI proof of concept (Lexical) (#101)
* WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Backend: feature flag + goal-edit interrupt messaging * WIP: heartbeat checkpoint * WIP: heartbeat checkpoint * feat: soryu-co/soryu - makima: Frontend: Lexical document editor with step blocks, context menu, countdown
Diffstat (limited to 'makima/src')
-rw-r--r--makima/src/db/repository.rs50
-rw-r--r--makima/src/orchestration/directive.rs346
-rw-r--r--makima/src/server/handlers/directives.rs85
-rw-r--r--makima/src/server/handlers/users.rs154
-rw-r--r--makima/src/server/mod.rs5
-rw-r--r--makima/src/server/openapi.rs4
6 files changed, 624 insertions, 20 deletions
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 57e8a78..ca07d92 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -4962,6 +4962,22 @@ pub async fn get_directive_for_owner(
.await
}
+/// Get a directive without an owner scope check.
+///
+/// Used by background orchestration code that has already established the
+/// directive identity through other means (e.g. it just received the
+/// directive_id from a different already-authorized query). HTTP handlers
+/// must continue to use `get_directive_for_owner` to enforce isolation.
+pub async fn get_directive(
+ pool: &PgPool,
+ id: Uuid,
+) -> Result<Option<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(r#"SELECT * FROM directives WHERE id = $1"#)
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
/// Get a directive with all its steps.
pub async fn get_directive_with_steps_for_owner(
pool: &PgPool,
@@ -5637,6 +5653,40 @@ pub async fn update_directive_goal(
.await
}
+/// Update a directive's goal WITHOUT clearing the orchestrator task id.
+///
+/// This is the path used by the goal-edit interrupt cycle: when a small goal
+/// edit arrives while a planner is already running, we want to keep the
+/// planner attached so a `SendMessage` can summarise the change in-flight
+/// instead of cancelling and respawning. We still bump `goal_updated_at` so
+/// the timestamp reflects the edit, but we do NOT trigger replanning by
+/// clearing the orchestrator task. We also do not flip status from
+/// idle/paused → active here, since by definition a planner is already
+/// running.
+pub async fn update_directive_goal_keep_orchestrator(
+ pool: &PgPool,
+ owner_id: Uuid,
+ directive_id: Uuid,
+ goal: &str,
+) -> Result<Option<Directive>, sqlx::Error> {
+ sqlx::query_as::<_, Directive>(
+ r#"
+ UPDATE directives
+ SET goal = $3,
+ goal_updated_at = NOW(),
+ updated_at = NOW(),
+ version = version + 1
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(directive_id)
+ .bind(owner_id)
+ .bind(goal)
+ .fetch_optional(pool)
+ .await
+}
+
/// Save a goal to the directive goal history.
pub async fn save_directive_goal_history(
pool: &PgPool,
diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs
index 8b3ae7e..22279e8 100644
--- a/makima/src/orchestration/directive.rs
+++ b/makima/src/orchestration/directive.rs
@@ -56,7 +56,7 @@ impl DirectiveOrchestrator {
"Directive needs planning — spawning planning task"
);
- let plan = build_planning_prompt(&directive, &[], 1, &[]);
+ let plan = build_planning_prompt(&directive, &[], 1, &[], None);
if let Err(e) = self
.spawn_orchestrator_task(
@@ -477,8 +477,20 @@ impl DirectiveOrchestrator {
let goal_history =
repository::get_directive_goal_history(&self.pool, directive.id, 3).await?;
- let plan =
- build_planning_prompt(&directive, &existing_steps, generation, &goal_history);
+ // If steps are currently running (or recently completed), build a
+ // WORK IN PROGRESS summary for the planner so it doesn't re-issue
+ // already-running work. We only include this section when there
+ // really is work in flight — pure pending plans don't need it.
+ let progress_summary =
+ summarize_in_progress_steps(&existing_steps);
+
+ let plan = build_planning_prompt(
+ &directive,
+ &existing_steps,
+ generation,
+ &goal_history,
+ progress_summary.as_deref(),
+ );
if let Err(e) = self
.spawn_orchestrator_task(
@@ -1390,15 +1402,97 @@ pub async fn trigger_completion_task(
Ok(task.id)
}
+/// Summarize currently-running and recently-completed steps for the planner.
+///
+/// Returns `None` when there is no in-flight or recently-completed work to
+/// report; otherwise returns a multi-line block listing running steps first
+/// (these are the ones the planner most needs to be aware of so it doesn't
+/// re-issue them) followed by recently completed steps.
+fn summarize_in_progress_steps(
+ steps: &[crate::db::models::DirectiveStep],
+) -> Option<String> {
+ let mut running: Vec<&crate::db::models::DirectiveStep> = Vec::new();
+ let mut completed: Vec<&crate::db::models::DirectiveStep> = Vec::new();
+
+ for step in steps {
+ match step.status.as_str() {
+ "running" => running.push(step),
+ "completed" => completed.push(step),
+ _ => {}
+ }
+ }
+
+ if running.is_empty() && completed.is_empty() {
+ return None;
+ }
+
+ let mut out = String::new();
+ if !running.is_empty() {
+ out.push_str("Currently running:\n");
+ for step in &running {
+ out.push_str(&format!(
+ " • {} (id: {}){}\n",
+ step.name,
+ step.id,
+ step.description
+ .as_deref()
+ .map(|d| format!(" — {}", d))
+ .unwrap_or_default()
+ ));
+ }
+ }
+ if !completed.is_empty() {
+ if !running.is_empty() {
+ out.push('\n');
+ }
+ out.push_str("Recently completed (work already done — do not re-issue):\n");
+ for step in &completed {
+ out.push_str(&format!(
+ " • {} (id: {}){}\n",
+ step.name,
+ step.id,
+ step.description
+ .as_deref()
+ .map(|d| format!(" — {}", d))
+ .unwrap_or_default()
+ ));
+ }
+ }
+
+ Some(out)
+}
+
/// Build the planning prompt for a directive.
+///
+/// `progress_summary` — when supplied, a `WORK IN PROGRESS` section is rendered
+/// near the top of the prompt so the (re)planning task knows what step work is
+/// currently in flight or recently completed. This is used by replanning when
+/// steps are running but the planner has finished, so the new plan can take
+/// in-progress work into account instead of re-issuing it.
fn build_planning_prompt(
directive: &crate::db::models::Directive,
existing_steps: &[crate::db::models::DirectiveStep],
generation: i32,
goal_history: &[crate::db::models::DirectiveGoalHistory],
+ progress_summary: Option<&str>,
) -> String {
let mut prompt = String::new();
+ if let Some(progress) = progress_summary {
+ let trimmed = progress.trim();
+ if !trimmed.is_empty() {
+ prompt.push_str("── WORK IN PROGRESS ──\n");
+ prompt.push_str(
+ "Steps from the previous plan are already executing or recently completed. \
+ Take this into account when revising the plan: do not re-issue work that is \
+ already underway, and prefer to extend / refine the in-flight chain rather \
+ than rebuild it.\n\n",
+ );
+ prompt.push_str(trimmed);
+ prompt.push_str("\n\n");
+ }
+ }
+
if !existing_steps.is_empty() {
// ── RE-PLANNING header ──────────────────────────────────────
prompt.push_str(&format!(
@@ -2364,3 +2458,249 @@ Do NOT ask questions for trivial decisions — use your best judgment.
prompt
}
+
+// =============================================================================
+// Goal-edit classification (small vs large) and interrupt helpers
+// =============================================================================
+
+/// Classification of a goal change for the goal-edit interrupt cycle.
+///
+/// When a user edits a directive's goal while a planning/replanning task is
+/// already running, we want to differentiate between:
+/// • Small edits (typo fixes, clarifications, small additions) → interrupt
+/// the current planner with a `SendMessage` so it can adjust its in-flight
+/// plan rather than throwing away its work.
+/// • Large edits (substantial rewrites, completely different objective) →
+/// fall back to the existing replan path (cancel + spawn a new planner).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GoalChangeKind {
+ /// Small change — interrupt the running planner with the diff.
+ Small,
+ /// Large change — proceed with full replan.
+ Large,
+}
+
+/// Heuristic: classify a goal edit as small or large.
+///
+/// Rules (POC heuristic, kept deliberately simple):
+/// 1. Empty old goal or empty new goal → Large (treat as a fresh start).
+/// 2. If one goal is a prefix of the other → Small (pure addition / truncation).
+/// 3. If the absolute length difference relative to the longer goal is < 0.3,
+/// classify as Small. Otherwise Large.
+pub fn classify_goal_change(old: &str, new: &str) -> GoalChangeKind {
+ let old = old.trim();
+ let new = new.trim();
+
+ if old.is_empty() || new.is_empty() {
+ return GoalChangeKind::Large;
+ }
+
+ if old == new {
+ // No content change — treat as small (no-op for the planner).
+ return GoalChangeKind::Small;
+ }
+
+ // Pure prefix changes (added a sentence at the end, or removed a trailing
+ // clause) are almost always small.
+ if old.starts_with(new) || new.starts_with(old) {
+ return GoalChangeKind::Small;
+ }
+
+ let old_len = old.chars().count();
+ let new_len = new.chars().count();
+ let longer = old_len.max(new_len) as f64;
+ let diff = (old_len as i64 - new_len as i64).unsigned_abs() as f64;
+ if longer == 0.0 {
+ return GoalChangeKind::Large;
+ }
+ let length_ratio = diff / longer;
+
+ if length_ratio < 0.3 {
+ GoalChangeKind::Small
+ } else {
+ GoalChangeKind::Large
+ }
+}
+
+/// Format the goal-edit interrupt message sent to a running planner task
+/// when the user edits the directive goal mid-flight.
+pub fn build_goal_edit_interrupt_message(old_goal: &str, new_goal: &str) -> String {
+ format!(
+ "GOAL_UPDATED: The user has edited the directive goal. Summary of changes follows. \
+ Adjust your current plan in-flight rather than starting over.\n\
+ --- OLD GOAL ---\n\
+ {old}\n\
+ --- NEW GOAL ---\n\
+ {new}\n",
+ old = old_goal,
+ new = new_goal,
+ )
+}
+
+/// Result of attempting to send a goal-edit interrupt to a running planner.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GoalEditInterruptResult {
+ /// A `SendMessage` daemon command was dispatched to the running planner.
+ Sent,
+ /// No suitable planner task was running, or the change was classified as
+ /// large — caller should fall through to the regular replanning path.
+ Skipped,
+}
+
+/// Attempt to interrupt a directive's currently-running planner with a goal
+/// edit summary instead of replanning from scratch.
+///
+/// Returns `Ok(GoalEditInterruptResult::Sent)` when a `SendMessage` was
+/// dispatched. Returns `Ok(GoalEditInterruptResult::Skipped)` when the change
+/// was large, no orchestrator task exists, the task has already finished, or
+/// no daemon is currently assigned.
+///
+/// This function is best-effort: errors talking to the daemon are logged and
+/// translated into `Skipped` so the caller can fall through to the normal
+/// replan path.
+pub async fn try_interrupt_planner_with_goal_edit(
+ pool: &PgPool,
+ state: &SharedState,
+ directive_id: Uuid,
+ old_goal: &str,
+ new_goal: &str,
+) -> Result<GoalEditInterruptResult, anyhow::Error> {
+ // Only fire if the change classifies as small.
+ if classify_goal_change(old_goal, new_goal) != GoalChangeKind::Small {
+ tracing::debug!(
+ directive_id = %directive_id,
+ "Goal change classified as large — skipping planner interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ }
+
+ // Look up the directive's current orchestrator task (planner).
+ let directive = match repository::get_directive(pool, directive_id).await? {
+ Some(d) => d,
+ None => return Ok(GoalEditInterruptResult::Skipped),
+ };
+ let Some(orchestrator_task_id) = directive.orchestrator_task_id else {
+ return Ok(GoalEditInterruptResult::Skipped);
+ };
+
+ // Fetch the planner task to confirm it's still queued/running.
+ let task = match repository::get_task(pool, orchestrator_task_id).await? {
+ Some(t) => t,
+ None => return Ok(GoalEditInterruptResult::Skipped),
+ };
+
+ let interruptible = matches!(
+ task.status.as_str(),
+ "queued" | "pending" | "starting" | "running"
+ );
+ if !interruptible {
+ tracing::debug!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ task_status = %task.status,
+ "Planner task is not in an interruptible state — skipping interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ }
+
+ let Some(daemon_id) = task.daemon_id else {
+ tracing::debug!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ "Planner task has no assigned daemon — skipping interrupt"
+ );
+ return Ok(GoalEditInterruptResult::Skipped);
+ };
+
+ let message = build_goal_edit_interrupt_message(old_goal, new_goal);
+ let command = DaemonCommand::SendMessage {
+ task_id: orchestrator_task_id,
+ message,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => {
+ tracing::info!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ daemon_id = %daemon_id,
+ "Sent goal-edit interrupt to running planner"
+ );
+ Ok(GoalEditInterruptResult::Sent)
+ }
+ Err(e) => {
+ tracing::warn!(
+ directive_id = %directive_id,
+ task_id = %orchestrator_task_id,
+ daemon_id = %daemon_id,
+ error = %e,
+ "Failed to send goal-edit interrupt — falling back to replan"
+ );
+ Ok(GoalEditInterruptResult::Skipped)
+ }
+ }
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classifier_identical_goal_is_small() {
+ assert_eq!(
+ classify_goal_change("Build a todo app", "Build a todo app"),
+ GoalChangeKind::Small
+ );
+ }
+
+ #[test]
+ fn classifier_pure_addition_is_small() {
+ let old = "Build a todo app";
+ let new = "Build a todo app with authentication";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_pure_truncation_is_small() {
+ let old = "Build a todo app with authentication and tests";
+ let new = "Build a todo app";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_typo_fix_is_small() {
+ // Same length, single character diff — well below 0.3 length ratio.
+ let old = "Build a todo aap with authentication and tests today";
+ let new = "Build a todo app with authentication and tests today";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Small);
+ }
+
+ #[test]
+ fn classifier_completely_different_is_large() {
+ // Wildly different lengths and content.
+ let old = "Build a todo app";
+ let new = "Migrate the entire backend to Rust, port the frontend to Svelte, \
+ and add a new realtime collaboration feature with operational transforms";
+ assert_eq!(classify_goal_change(old, new), GoalChangeKind::Large);
+ }
+
+ #[test]
+ fn classifier_empty_goals_are_large() {
+ assert_eq!(classify_goal_change("", "Anything"), GoalChangeKind::Large);
+ assert_eq!(classify_goal_change("Anything", ""), GoalChangeKind::Large);
+ }
+
+ #[test]
+ fn interrupt_message_contains_old_and_new() {
+ let msg = build_goal_edit_interrupt_message("OLD", "NEW");
+ assert!(msg.contains("GOAL_UPDATED"));
+ assert!(msg.contains("OLD"));
+ assert!(msg.contains("NEW"));
+ assert!(msg.contains("--- OLD GOAL ---"));
+ assert!(msg.contains("--- NEW GOAL ---"));
+ }
+}
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, &current.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, &current.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(&current.goal, &req.goal) == GoalChangeKind::Small
+ {
+ match try_interrupt_planner_with_goal_edit(
+ pool,
+ &state,
+ id,
+ &current.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,