//! HTTP handlers for the server-side persona store. //! //! Personas previously lived only in mobile AsyncStorage; this module //! elevates them so they can sync across devices and so the //! `entity_facts.persona_id` column has something to reference. //! //! Built-in personas (default / journal / factual) are seeded by the //! migration. Customs are created here and may be migrated up from a //! device's local store via `POST /personas/migrate`. use actix_web::dev::{ServiceFactory, ServiceRequest}; use actix_web::{App, HttpResponse, Responder, web}; use serde::{Deserialize, Serialize}; use std::sync::Mutex; use crate::data::Claims; use crate::database::models::Persona; use crate::database::{ImportPersona, PersonaDao, PersonaPatch}; // --------------------------------------------------------------------------- // Wire shapes — camelCase out the door, snake_case from the DB. // --------------------------------------------------------------------------- #[derive(Serialize)] pub struct PersonaView { pub id: String, pub name: String, #[serde(rename = "systemPrompt")] pub system_prompt: String, #[serde(rename = "isBuiltIn")] pub is_built_in: bool, #[serde(rename = "includeAllMemories")] pub include_all_memories: bool, #[serde(rename = "createdAt")] pub created_at: i64, #[serde(rename = "updatedAt")] pub updated_at: i64, /// "Strict mode" — when true, the agent's recall_* tools return /// only facts whose status is 'reviewed'. See migration /// 2026-05-10-000400. #[serde(rename = "reviewedOnlyFacts")] pub reviewed_only_facts: bool, } impl From for PersonaView { fn from(p: Persona) -> Self { Self { id: p.persona_id, name: p.name, system_prompt: p.system_prompt, is_built_in: p.is_built_in, include_all_memories: p.include_all_memories, created_at: p.created_at, updated_at: p.updated_at, reviewed_only_facts: p.reviewed_only_facts, } } } #[derive(Deserialize)] pub struct CreatePersonaRequest { pub name: String, #[serde(rename = "systemPrompt")] pub system_prompt: String, /// Optional caller-provided id. When present (e.g. a client that /// already minted `"custom-1735124234"` locally and is upgrading from /// the AsyncStorage-only era), the server uses it; collisions return /// 409. When absent the server mints `"custom-"`. #[serde(default, rename = "personaId")] pub persona_id: Option, } #[derive(Deserialize)] pub struct UpdatePersonaRequest { #[serde(default)] pub name: Option, #[serde(default, rename = "systemPrompt")] pub system_prompt: Option, #[serde(default, rename = "includeAllMemories")] pub include_all_memories: Option, #[serde(default, rename = "reviewedOnlyFacts")] pub reviewed_only_facts: Option, } #[derive(Deserialize)] pub struct MigrateRequest { pub personas: Vec, } #[derive(Deserialize)] pub struct MigratePersona { pub id: String, pub name: String, #[serde(rename = "systemPrompt")] pub system_prompt: String, #[serde(default, rename = "isBuiltIn")] pub is_built_in: bool, #[serde(default, rename = "createdAt")] pub created_at: Option, } #[derive(Serialize)] pub struct MigrateResponse { pub inserted: usize, } // --------------------------------------------------------------------------- // Service registration // --------------------------------------------------------------------------- pub type PersonaDaoData = web::Data>>; pub fn add_persona_services(app: App) -> App where T: ServiceFactory, { app.service( web::scope("/personas") .service(web::resource("/migrate").route(web::post().to(migrate_personas))) .service( web::resource("") .route(web::get().to(list_personas)) .route(web::post().to(create_persona)), ) .service( web::resource("/{persona_id}") .route(web::put().to(update_persona)) .route(web::delete().to(delete_persona)), ), ) } // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- fn user_id_from_claims(claims: &Claims) -> Option { claims.sub.parse::().ok() } async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder { let Some(uid) = user_id_from_claims(&claims) else { return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"})); }; let cx = opentelemetry::Context::current(); let mut dao = dao.lock().expect("Unable to lock PersonaDao"); match dao.list_personas(&cx, uid) { Ok(rows) => { let views: Vec = rows.into_iter().map(PersonaView::from).collect(); HttpResponse::Ok().json(views) } Err(e) => { log::error!("list_personas error: {:?}", e); HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"})) } } } async fn create_persona( claims: Claims, body: web::Json, dao: PersonaDaoData, ) -> impl Responder { let Some(uid) = user_id_from_claims(&claims) else { return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"})); }; if body.name.trim().is_empty() { return HttpResponse::BadRequest().json(serde_json::json!({"error": "name is required"})); } if body.system_prompt.trim().is_empty() { return HttpResponse::BadRequest() .json(serde_json::json!({"error": "systemPrompt is required"})); } let cx = opentelemetry::Context::current(); let mut dao = dao.lock().expect("Unable to lock PersonaDao"); let pid = match body.persona_id.as_deref() { Some(s) if !s.trim().is_empty() => s.to_string(), _ => format!("custom-{}", chrono::Utc::now().timestamp_millis()), }; if matches!(pid.as_str(), "default" | "journal" | "factual") { return HttpResponse::Conflict() .json(serde_json::json!({"error": "persona id collides with a built-in"})); } // Pre-check existence so we can return 409 cleanly. The DB UNIQUE // would also catch it, but parsing Diesel's "constraint violation" // out of a generic DbError is uglier than a quick lookup. if let Ok(Some(_)) = dao.get_persona(&cx, uid, &pid) { return HttpResponse::Conflict() .json(serde_json::json!({"error": "persona already exists"})); } match dao.create_persona( &cx, uid, &pid, &body.name, &body.system_prompt, false, false, ) { Ok(p) => HttpResponse::Created().json(PersonaView::from(p)), Err(e) => { log::error!("create_persona error: {:?}", e); HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"})) } } } async fn update_persona( claims: Claims, path: web::Path, body: web::Json, dao: PersonaDaoData, ) -> impl Responder { let Some(uid) = user_id_from_claims(&claims) else { return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"})); }; let pid = path.into_inner(); let cx = opentelemetry::Context::current(); let mut dao = dao.lock().expect("Unable to lock PersonaDao"); // Built-in personas are owned by the migration; the canonical voice // text lives in source. A client renaming or rewriting the prompt // here would diverge from what new users get seeded with and hide // the operator's actual customization (their own custom persona) // from the picker. `include_all_memories` stays editable on // built-ins — that's a per-user preference, not the persona's // identity. Mirrors the same guard delete_persona enforces below. match dao.get_persona(&cx, uid, &pid) { Ok(Some(p)) if p.is_built_in => { let editing_identity = body.name.is_some() || body.system_prompt.is_some(); if editing_identity { return HttpResponse::Conflict().json(serde_json::json!({ "error": "Cannot edit name or systemPrompt of a built-in persona" })); } } Ok(None) => { return HttpResponse::NotFound() .json(serde_json::json!({"error": "Persona not found"})); } Err(e) => { log::error!("update_persona lookup error: {:?}", e); return HttpResponse::InternalServerError() .json(serde_json::json!({"error": "Database error"})); } Ok(Some(_)) => {} } let patch = PersonaPatch { name: body.name.clone(), system_prompt: body.system_prompt.clone(), include_all_memories: body.include_all_memories, reviewed_only_facts: body.reviewed_only_facts, }; match dao.update_persona(&cx, uid, &pid, patch) { Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)), Ok(None) => { HttpResponse::NotFound().json(serde_json::json!({"error": "Persona not found"})) } Err(e) => { log::error!("update_persona error: {:?}", e); HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"})) } } } async fn delete_persona( claims: Claims, path: web::Path, dao: PersonaDaoData, ) -> impl Responder { let Some(uid) = user_id_from_claims(&claims) else { return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"})); }; let pid = path.into_inner(); let cx = opentelemetry::Context::current(); let mut dao = dao.lock().expect("Unable to lock PersonaDao"); match dao.get_persona(&cx, uid, &pid) { Ok(Some(p)) if p.is_built_in => { return HttpResponse::Conflict() .json(serde_json::json!({"error": "Cannot delete built-in persona"})); } Ok(None) => { return HttpResponse::NotFound() .json(serde_json::json!({"error": "Persona not found"})); } Err(e) => { log::error!("delete_persona lookup error: {:?}", e); return HttpResponse::InternalServerError() .json(serde_json::json!({"error": "Database error"})); } Ok(Some(_)) => {} } match dao.delete_persona(&cx, uid, &pid) { Ok(_) => HttpResponse::NoContent().finish(), Err(e) => { log::error!("delete_persona error: {:?}", e); HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"})) } } } async fn migrate_personas( claims: Claims, body: web::Json, dao: PersonaDaoData, ) -> impl Responder { let Some(uid) = user_id_from_claims(&claims) else { return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"})); }; let cx = opentelemetry::Context::current(); let mut dao = dao.lock().expect("Unable to lock PersonaDao"); // Filter out built-in ids — those are already seeded by the // migration and re-importing them would be a no-op anyway thanks to // INSERT OR IGNORE, but skipping early avoids the UNIQUE round-trip. let now = chrono::Utc::now().timestamp_millis(); let rows: Vec = body .personas .iter() .filter(|p| !matches!(p.id.as_str(), "default" | "journal" | "factual")) .map(|p| ImportPersona { persona_id: p.id.clone(), name: p.name.clone(), system_prompt: p.system_prompt.clone(), is_built_in: p.is_built_in, created_at: p.created_at.unwrap_or(now), }) .collect(); match dao.bulk_import(&cx, uid, &rows) { Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }), Err(e) => { log::error!("migrate_personas error: {:?}", e); HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"})) } } }