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))) }