From 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Thu, 17 Sep 2026 14:32:37 +0800 Subject: chore: establish Strategy Lab source baseline (development, not release) --- server/src/projects.rs | 219 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 server/src/projects.rs (limited to 'server/src/projects.rs') diff --git a/server/src/projects.rs b/server/src/projects.rs new file mode 100644 index 0000000..f5305da --- /dev/null +++ b/server/src/projects.rs @@ -0,0 +1,219 @@ +use axum::extract::Path; +use axum::Json; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::auth::{audit, AuthUser}; +use crate::error::{AppError, AppResult}; +use crate::util::{new_id, now_iso, sha256_hex, unified_diff}; + +pub type Cx = crate::state::Cx; + +#[derive(Serialize)] +pub struct Project { + pub id: String, + pub name: String, + pub description: String, + pub draft_code: String, + pub draft_generation: i64, + pub created_at: String, + pub updated_at: String, +} + +pub async fn load_owned(cx: &Cx, user_id: &str, project_id: &str) -> AppResult { + cx.with_db(|db| { + db.query_row("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![project_id, user_id], row_project) + .map_err(|_| AppError::not_found("project not found")) + }).await +} + +fn row_project(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Project { + id: r.get(0)?, name: r.get(1)?, description: r.get(2)?, + draft_code: r.get(3)?, draft_generation: r.get(4)?, + created_at: r.get(5)?, updated_at: r.get(6)?, + }) +} + +#[derive(Deserialize)] +pub struct CreateProject { pub name: String, pub description: Option } + +pub async fn create(cx: Cx, auth: AuthUser, body: Option>) -> AppResult<(axum::http::StatusCode, Json)> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + let name = r.name.trim().to_string(); + if name.is_empty() || name.len() > 200 { return Err(AppError::bad("validation", "name required (max 200 chars)")); } + let id = new_id(); + let desc = r.description.unwrap_or_default(); + let ts = now_iso(); + let uid = auth.id.clone(); + cx.with_db(|db| -> AppResult<()> { + db.execute("INSERT INTO projects (id,user_id,name,description,draft_code,draft_generation,created_at,updated_at) VALUES (?1,?2,?3,?4,'',0,?5,?5)", + rusqlite::params![&id, &uid, &name, &desc, &ts])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "project_create", &id, "ok").await; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok((axum::http::StatusCode::CREATED, Json(json!(p)))) +} + +pub async fn list(cx: Cx, auth: AuthUser) -> AppResult> { + let items: Vec = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE user_id=?1 ORDER BY updated_at DESC")?; + let mut rows = st.query([auth.id.clone()])?; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_project(r)?); } + Ok::<_, AppError>(out) + }).await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn get(cx: Cx, auth: AuthUser, Path(id): Path) -> AppResult> { + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} + +#[derive(Deserialize)] +pub struct PatchProject { pub name: Option, pub description: Option } + +pub async fn patch(cx: Cx, auth: AuthUser, Path(id): Path, body: Option>) -> AppResult> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if let Some(n) = &r.name { + if n.trim().is_empty() { return Err(AppError::bad("validation", "name must not be empty")); } + } + load_owned(&cx, &auth.id, &id).await?; + cx.with_db(|db| -> AppResult<()> { + db.execute("UPDATE projects SET name=COALESCE(?1,name), description=COALESCE(?2,description), updated_at=?3 WHERE id=?4", + rusqlite::params![r.name, r.description, now_iso(), &id])?; + Ok(()) + }).await?; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} + +#[derive(Deserialize)] +pub struct PutDraft { pub code: String, pub expected_generation: i64 } + +pub async fn put_draft(cx: Cx, auth: AuthUser, Path(id): Path, body: Option>) -> AppResult> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + if r.code.len() > crate::config::MAX_CODE_LEN { return Err(AppError::bad("validation", "code too long")); } + let p: Project = cx.with_db(|db| -> AppResult { + let cur: i64 = db.query_row("SELECT draft_generation FROM projects WHERE id=?1 AND user_id=?2", + rusqlite::params![&id, &auth.id], |row| row.get(0)) + .map_err(|_| AppError::not_found("project not found"))?; + if cur != r.expected_generation { + return Err(AppError::conflict("stale_generation", "draft changed; reload first") + .with_details(json!({ "current_generation": cur }))); + } + db.execute("UPDATE projects SET draft_code=?1, draft_generation=?2, updated_at=?3 WHERE id=?4", + rusqlite::params![&r.code, cur + 1, now_iso(), &id])?; + db.query_row("SELECT id,name,description,draft_code,draft_generation,created_at,updated_at FROM projects WHERE id=?1", [&id], row_project) + .map_err(AppError::from) + }).await?; + Ok(Json(json!(p))) +} + +// ---- versions ---- + +#[derive(Serialize)] +pub struct Version { + pub id: String, + pub project_id: String, + pub code: String, + pub hash: String, + pub message: String, + pub created_at: String, + pub source: String, +} + +fn row_version(r: &rusqlite::Row) -> rusqlite::Result { + Ok(Version { id: r.get(0)?, project_id: r.get(1)?, code: r.get(2)?, hash: r.get(3)?, message: r.get(4)?, created_at: r.get(5)?, source: r.get(6)? }) +} + +fn select_version(db: &mut rusqlite::Connection, vid: &str, pid: &str) -> rusqlite::Result { + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE id=?1 AND project_id=?2")?; + st.query_row(rusqlite::params![vid, pid], row_version) +} + +pub async fn list_versions(cx: Cx, auth: AuthUser, Path(id): Path) -> AppResult> { + load_owned(&cx, &auth.id, &id).await?; + let mut items: Vec = cx.with_db(|db| { + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE project_id=?1 ORDER BY created_at ASC")?; + let mut rows = st.query([id.clone()])?; + let mut out = Vec::new(); + while let Some(r) = rows.next()? { out.push(row_version(r)?); } + Ok::<_, AppError>(out) + }).await?; + items.reverse(); + Ok(Json(json!({ "items": items }))) +} + +pub async fn get_version(cx: Cx, auth: AuthUser, Path((pid, vid)): Path<(String, String)>) -> AppResult> { + load_owned(&cx, &auth.id, &pid).await?; + let v: Version = cx.with_db(|db| select_version(db, &vid, &pid)) + .await + .map_err(|_| AppError::not_found("version not found"))?; + Ok(Json(json!(v))) +} + +pub async fn diff_versions(cx: Cx, auth: AuthUser, Path((pid, vid)): Path<(String, String)>, body: Option>) -> AppResult> { + let base: Value = body.map(|b| b.0).unwrap_or(json!({})); + load_owned(&cx, &auth.id, &pid).await?; + let v: Version = cx.with_db(|db| select_version(db, &vid, &pid)) + .await + .map_err(|_| AppError::not_found("version not found"))?; + let base_code: String = match base.get("base_version").and_then(|b| b.as_str()) { + Some(bv) => cx.with_db(|db| { + db.query_row("SELECT code FROM project_versions WHERE id=?1 AND project_id=?2", rusqlite::params![bv, &pid], |r| r.get(0)) + .map_err(|_| AppError::bad("validation", "base_version not found")) + }).await?, + None => String::new(), + }; + Ok(Json(json!({ "diff": unified_diff(&base_code, &v.code), "version": { "id": v.id, "hash": v.hash, "message": v.message } }))) +} + +pub async fn create_version(cx: Cx, auth: AuthUser, Path(id): Path, body: Option>) -> AppResult<(axum::http::StatusCode, Json)> { + let b = body.map(|b| b.0).unwrap_or(json!({})); + let message = b.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string(); + if message.trim().is_empty() { return Err(AppError::bad("validation", "message required")); } + let v = create_version_from_draft(&cx, &auth.id, &id, &message, "manual").await?; + Ok((axum::http::StatusCode::CREATED, Json(json!(v)))) +} + +/// Snapshot the current draft as an immutable version. +pub async fn create_version_from_draft(cx: &Cx, user_id: &str, project_id: &str, message: &str, source: &str) -> AppResult { + let p = load_owned(cx, user_id, project_id).await?; + if p.draft_code.trim().is_empty() { return Err(AppError::bad("validation", "draft is empty; nothing to version")); } + let hash = sha256_hex(p.draft_code.as_bytes()); + let draft = p.draft_code.clone(); + let v: Version = cx.with_db(|db| -> AppResult { + let id = new_id(); + let ts = now_iso(); + db.execute("INSERT INTO project_versions (id,project_id,code,hash,message,source,created_at) VALUES (?1,?2,?3,?4,?5,?6,?7)", + rusqlite::params![&id, project_id, &draft, &hash, message, source, &ts])?; + let mut st = db.prepare("SELECT id,project_id,code,hash,message,created_at,source FROM project_versions WHERE id=?1")?; + st.query_row([&id], row_version).map_err(AppError::from) + }).await?; + audit(cx, Some(user_id), "version_create", project_id, "ok").await; + Ok(v) +} + +#[derive(Deserialize)] +pub struct RestoreReq { pub version_id: String, pub expected_generation: i64 } + +pub async fn restore(cx: Cx, auth: AuthUser, Path(id): Path, body: Option>) -> AppResult> { + let Json(r) = body.ok_or_else(|| AppError::bad("invalid_body", "JSON body required"))?; + cx.with_db(|db| -> AppResult<()> { + let cur: i64 = db.query_row("SELECT draft_generation FROM projects WHERE id=?1 AND user_id=?2", rusqlite::params![&id, &auth.id], |row| row.get(0)) + .map_err(|_| AppError::not_found("project not found"))?; + if cur != r.expected_generation { return Err(AppError::conflict("stale_generation", "draft changed; reload first")); } + let code: String = db.query_row("SELECT code FROM project_versions WHERE id=?1 AND project_id=?2", rusqlite::params![&r.version_id, &id], |row| row.get(0)) + .map_err(|_| AppError::not_found("version not found"))?; + db.execute("UPDATE projects SET draft_code=?1, draft_generation=?2, updated_at=?3 WHERE id=?4", + rusqlite::params![&code, cur + 1, now_iso(), &id])?; + Ok(()) + }).await?; + audit(&cx, Some(&auth.id), "version_restore", &id, "ok").await; + let p = load_owned(&cx, &auth.id, &id).await?; + Ok(Json(json!(p))) +} -- cgit v1.2.3