summaryrefslogtreecommitdiff
path: root/server/src/projects.rs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /server/src/projects.rs
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'server/src/projects.rs')
-rw-r--r--server/src/projects.rs219
1 files changed, 219 insertions, 0 deletions
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<Project> {
+ 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<Project> {
+ 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<String> }
+
+pub async fn create(cx: Cx, auth: AuthUser, body: Option<Json<CreateProject>>) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
+ 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<Json<Value>> {
+ let items: Vec<Project> = 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<String>) -> AppResult<Json<Value>> {
+ let p = load_owned(&cx, &auth.id, &id).await?;
+ Ok(Json(json!(p)))
+}
+
+#[derive(Deserialize)]
+pub struct PatchProject { pub name: Option<String>, pub description: Option<String> }
+
+pub async fn patch(cx: Cx, auth: AuthUser, Path(id): Path<String>, body: Option<Json<PatchProject>>) -> AppResult<Json<Value>> {
+ 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<String>, body: Option<Json<PutDraft>>) -> AppResult<Json<Value>> {
+ 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<Project> {
+ 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<Version> {
+ 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<Version> {
+ 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<String>) -> AppResult<Json<Value>> {
+ load_owned(&cx, &auth.id, &id).await?;
+ let mut items: Vec<Version> = 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<Json<Value>> {
+ 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<Json<Value>>) -> AppResult<Json<Value>> {
+ 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<String>, body: Option<Json<Value>>) -> AppResult<(axum::http::StatusCode, Json<Value>)> {
+ 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<Version> {
+ 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<Version> {
+ 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<String>, body: Option<Json<RestoreReq>>) -> AppResult<Json<Value>> {
+ 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)))
+}