Add desktop-facing folder and file organization endpoints

This commit is contained in:
2026-07-20 23:59:02 -04:00
parent 538ddf70d8
commit de2c2aaccc
2 changed files with 467 additions and 12 deletions
+459 -7
View File
@@ -269,6 +269,7 @@ pub struct ProjectSummary {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub entrypoint: String, pub entrypoint: String,
pub folder_id: Option<String>,
pub role: String, pub role: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -279,16 +280,16 @@ pub async fn list_projects(
) -> Result<Json<Vec<ProjectSummary>>, (StatusCode, String)> { ) -> Result<Json<Vec<ProjectSummary>>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?; let user_id = authenticate(&state, &headers).await?;
let owned = sqlx::query_as::<_, (String, String, String, String)>( let owned = sqlx::query_as::<_, (String, String, String, Option<String>, String)>(
"SELECT id, name, entrypoint, updated_at FROM projects WHERE owner_id = ? ORDER BY updated_at DESC", "SELECT id, name, entrypoint, folder_id, updated_at FROM projects WHERE owner_id = ? ORDER BY updated_at DESC",
) )
.bind(&user_id) .bind(&user_id)
.fetch_all(&state.db) .fetch_all(&state.db)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let shared = sqlx::query_as::<_, (String, String, String, String, String)>( let shared = sqlx::query_as::<_, (String, String, String, Option<String>, String, String)>(
"SELECT p.id, p.name, p.entrypoint, p.updated_at, c.role FROM projects p \ "SELECT p.id, p.name, p.entrypoint, p.folder_id, p.updated_at, c.role FROM projects p \
INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \ INNER JOIN project_collaborators c ON c.project_id = p.id AND c.user_id = ? \
ORDER BY p.updated_at DESC", ORDER BY p.updated_at DESC",
) )
@@ -299,10 +300,11 @@ pub async fn list_projects(
let mut projects: Vec<ProjectSummary> = owned let mut projects: Vec<ProjectSummary> = owned
.into_iter() .into_iter()
.map(|(id, name, entrypoint, updated_at)| ProjectSummary { .map(|(id, name, entrypoint, folder_id, updated_at)| ProjectSummary {
id, id,
name, name,
entrypoint, entrypoint,
folder_id,
role: "owner".to_string(), role: "owner".to_string(),
updated_at, updated_at,
}) })
@@ -311,10 +313,11 @@ pub async fn list_projects(
projects.extend( projects.extend(
shared shared
.into_iter() .into_iter()
.map(|(id, name, entrypoint, updated_at, role)| ProjectSummary { .map(|(id, name, entrypoint, folder_id, updated_at, role)| ProjectSummary {
id, id,
name, name,
entrypoint, entrypoint,
folder_id,
role, role,
updated_at, updated_at,
}), }),
@@ -327,6 +330,7 @@ pub async fn list_projects(
pub struct CreateProjectBody { pub struct CreateProjectBody {
pub name: String, pub name: String,
pub entrypoint: Option<String>, pub entrypoint: Option<String>,
pub folder_id: Option<String>,
} }
pub async fn create_project( pub async fn create_project(
@@ -340,17 +344,22 @@ pub async fn create_project(
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string())); return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
} }
if let Some(folder_id) = &payload.folder_id {
owned_folder(&state, folder_id, &user_id).await?;
}
let project_id = Uuid::new_v4().to_string(); let project_id = Uuid::new_v4().to_string();
let entrypoint = payload let entrypoint = payload
.entrypoint .entrypoint
.unwrap_or_else(|| "main.typ".to_string()); .unwrap_or_else(|| "main.typ".to_string());
let project = sqlx::query_as::<_, Project>( let project = sqlx::query_as::<_, Project>(
"INSERT INTO projects (id, owner_id, name, entrypoint) VALUES (?, ?, ?, ?) \ "INSERT INTO projects (id, owner_id, folder_id, name, entrypoint) VALUES (?, ?, ?, ?, ?) \
RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at", RETURNING id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at",
) )
.bind(&project_id) .bind(&project_id)
.bind(&user_id) .bind(&user_id)
.bind(&payload.folder_id)
.bind(payload.name.trim()) .bind(payload.name.trim())
.bind(&entrypoint) .bind(&entrypoint)
.fetch_one(&state.db) .fetch_one(&state.db)
@@ -361,6 +370,7 @@ pub async fn create_project(
id: project.id, id: project.id,
name: project.name, name: project.name,
entrypoint: project.entrypoint, entrypoint: project.entrypoint,
folder_id: project.folder_id,
role: "owner".to_string(), role: "owner".to_string(),
updated_at: project.updated_at, updated_at: project.updated_at,
})) }))
@@ -774,6 +784,276 @@ pub async fn list_folders(
)) ))
} }
async fn owned_folder(
state: &AppState,
folder_id: &str,
user_id: &str,
) -> Result<(), (StatusCode, String)> {
let row = sqlx::query_as::<_, (i64,)>(
"SELECT COUNT(*) FROM folders WHERE id = ? AND owner_id = ?",
)
.bind(folder_id)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if row.0 == 0 {
return Err((StatusCode::NOT_FOUND, "Folder not found".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
pub struct CreateFolderBody {
pub name: String,
pub parent_id: Option<String>,
}
pub async fn create_folder(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateFolderBody>,
) -> Result<Json<CloudFolder>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
}
if let Some(parent_id) = &payload.parent_id {
owned_folder(&state, parent_id, &user_id).await?;
}
let folder_id = Uuid::new_v4().to_string();
let name = payload.name.trim().to_string();
sqlx::query("INSERT INTO folders (id, owner_id, parent_id, name) VALUES (?, ?, ?, ?)")
.bind(&folder_id)
.bind(&user_id)
.bind(&payload.parent_id)
.bind(&name)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(CloudFolder {
id: folder_id,
name,
parent_id: payload.parent_id,
}))
}
#[derive(Deserialize)]
pub struct RenameFolderBody {
pub name: String,
}
pub async fn rename_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<RenameFolderBody>,
) -> Result<Json<CloudFolder>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
}
let row = sqlx::query_as::<_, (String, Option<String>)>(
"UPDATE folders SET name = ? WHERE id = ? AND owner_id = ? RETURNING name, parent_id",
)
.bind(payload.name.trim())
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
Ok(Json(CloudFolder {
id,
name: row.0,
parent_id: row.1,
}))
}
async fn creates_cycle(
state: &AppState,
folder_id: &str,
new_parent_id: &str,
user_id: &str,
) -> Result<bool, (StatusCode, String)> {
let mut current = new_parent_id.to_string();
for _ in 0..1000 {
if current == folder_id {
return Ok(true);
}
let parent = sqlx::query_as::<_, (Option<String>,)>(
"SELECT parent_id FROM folders WHERE id = ? AND owner_id = ?",
)
.bind(&current)
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.and_then(|row| row.0);
match parent {
Some(next) => current = next,
None => return Ok(false),
}
}
Ok(true)
}
#[derive(Deserialize)]
pub struct MoveFolderBody {
pub parent_id: Option<String>,
}
pub async fn move_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<MoveFolderBody>,
) -> Result<Json<CloudFolder>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
owned_folder(&state, &id, &user_id).await?;
if let Some(parent_id) = &payload.parent_id {
if parent_id == &id {
return Err((
StatusCode::BAD_REQUEST,
"A folder cannot be moved into itself".to_string(),
));
}
owned_folder(&state, parent_id, &user_id).await?;
if creates_cycle(&state, &id, parent_id, &user_id).await? {
return Err((
StatusCode::BAD_REQUEST,
"Cannot move a folder into one of its own subfolders".to_string(),
));
}
}
let row = sqlx::query_as::<_, (String, Option<String>)>(
"UPDATE folders SET parent_id = ? WHERE id = ? AND owner_id = ? RETURNING name, parent_id",
)
.bind(&payload.parent_id)
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Folder not found".to_string()))?;
Ok(Json(CloudFolder {
id,
name: row.0,
parent_id: row.1,
}))
}
async fn folder_is_empty(
state: &AppState,
folder_id: &str,
user_id: &str,
) -> Result<bool, (StatusCode, String)> {
let row = sqlx::query_as::<_, (i64, i64, i64)>(
"SELECT \
(SELECT COUNT(*) FROM folders WHERE parent_id = ? AND owner_id = ?), \
(SELECT COUNT(*) FROM projects WHERE folder_id = ? AND owner_id = ?), \
(SELECT COUNT(*) FROM documents WHERE folder_id = ? AND owner_id = ?)",
)
.bind(folder_id)
.bind(user_id)
.bind(folder_id)
.bind(user_id)
.bind(folder_id)
.bind(user_id)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(row.0 == 0 && row.1 == 0 && row.2 == 0)
}
pub async fn delete_folder(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
owned_folder(&state, &id, &user_id).await?;
if !folder_is_empty(&state, &id, &user_id).await? {
return Err((
StatusCode::BAD_REQUEST,
"Move or delete everything inside this folder first".to_string(),
));
}
let result = sqlx::query("DELETE FROM folders WHERE id = ? AND owner_id = ?")
.bind(&id)
.bind(&user_id)
.execute(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Folder not found or unauthorized".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct MoveProjectBody {
pub folder_id: Option<String>,
}
pub async fn move_project(
State(state): State<AppState>,
headers: HeaderMap,
Path(project_id): Path<String>,
Json(payload): Json<MoveProjectBody>,
) -> Result<Json<ProjectSummary>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if let Some(folder_id) = &payload.folder_id {
owned_folder(&state, folder_id, &user_id).await?;
}
let row = sqlx::query_as::<_, (String, String, String, Option<String>, String)>(
"UPDATE projects SET folder_id = ? WHERE id = ? AND owner_id = ? \
RETURNING id, name, entrypoint, folder_id, updated_at",
)
.bind(&payload.folder_id)
.bind(&project_id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?;
Ok(Json(ProjectSummary {
id: row.0,
name: row.1,
entrypoint: row.2,
folder_id: row.3,
role: "owner".to_string(),
updated_at: row.4,
}))
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CloudDocument { pub struct CloudDocument {
pub id: String, pub id: String,
@@ -876,6 +1156,7 @@ pub async fn list_shared(
id, id,
name, name,
entrypoint, entrypoint,
folder_id: None,
role, role,
updated_at, updated_at,
}) })
@@ -1051,6 +1332,44 @@ pub async fn create_document(
})) }))
} }
#[derive(Deserialize)]
pub struct MoveDocumentBody {
pub folder_id: Option<String>,
}
pub async fn move_document(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<MoveDocumentBody>,
) -> Result<Json<CloudDocument>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if let Some(folder_id) = &payload.folder_id {
owned_folder(&state, folder_id, &user_id).await?;
}
let row = sqlx::query_as::<_, (String, String, Option<String>, String)>(
"UPDATE documents SET folder_id = ? WHERE id = ? AND owner_id = ? \
RETURNING id, title, folder_id, updated_at",
)
.bind(&payload.folder_id)
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "Document not found".to_string()))?;
Ok(Json(CloudDocument {
id: row.0,
title: row.1,
folder_id: row.2,
role: "owner".to_string(),
updated_at: row.3,
}))
}
pub async fn delete_document( pub async fn delete_document(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -1120,6 +1439,139 @@ pub async fn list_account_files(
)) ))
} }
#[derive(Deserialize)]
pub struct UploadFileBody {
pub name: String,
pub mime_type: String,
pub encoding: String,
pub content: String,
pub folder_id: Option<String>,
}
pub async fn upload_account_file(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UploadFileBody>,
) -> Result<Json<CloudFile>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
}
if let Some(folder_id) = &payload.folder_id {
owned_folder(&state, folder_id, &user_id).await?;
}
let data = match payload.encoding.as_str() {
"base64" => BASE64
.decode(payload.content.as_bytes())
.map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid base64: {}", e)))?,
_ => payload.content.into_bytes(),
};
let file_id = Uuid::new_v4().to_string();
let name = payload.name.trim().to_string();
let row = sqlx::query_as::<_, (String,)>(
"INSERT INTO files (id, owner_id, folder_id, name, mime_type, data) \
VALUES (?, ?, ?, ?, ?, ?) RETURNING created_at",
)
.bind(&file_id)
.bind(&user_id)
.bind(&payload.folder_id)
.bind(&name)
.bind(&payload.mime_type)
.bind(&data)
.fetch_one(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(CloudFile {
id: file_id,
name,
mime_type: payload.mime_type,
folder_id: payload.folder_id,
created_at: row.0,
}))
}
#[derive(Deserialize)]
pub struct RenameFileBody {
pub name: String,
}
pub async fn rename_account_file(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<RenameFileBody>,
) -> Result<Json<CloudFile>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if payload.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Name cannot be empty".to_string()));
}
let row = sqlx::query_as::<_, (String, String, Option<String>, String)>(
"UPDATE files SET name = ? WHERE id = ? AND owner_id = ? \
RETURNING name, mime_type, folder_id, created_at",
)
.bind(payload.name.trim())
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
Ok(Json(CloudFile {
id,
name: row.0,
mime_type: row.1,
folder_id: row.2,
created_at: row.3,
}))
}
#[derive(Deserialize)]
pub struct MoveFileBody {
pub folder_id: Option<String>,
}
pub async fn move_account_file(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
Json(payload): Json<MoveFileBody>,
) -> Result<Json<CloudFile>, (StatusCode, String)> {
let user_id = authenticate(&state, &headers).await?;
if let Some(folder_id) = &payload.folder_id {
owned_folder(&state, folder_id, &user_id).await?;
}
let row = sqlx::query_as::<_, (String, String, Option<String>, String)>(
"UPDATE files SET folder_id = ? WHERE id = ? AND owner_id = ? \
RETURNING name, mime_type, folder_id, created_at",
)
.bind(&payload.folder_id)
.bind(&id)
.bind(&user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((StatusCode::NOT_FOUND, "File not found".to_string()))?;
Ok(Json(CloudFile {
id,
name: row.0,
mime_type: row.1,
folder_id: row.2,
created_at: row.3,
}))
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CloudFileContent { pub struct CloudFileContent {
pub name: String, pub name: String,
+8 -5
View File
@@ -147,14 +147,17 @@ async fn main() {
.route("/auth/logout", post(desktop::logout)) .route("/auth/logout", post(desktop::logout))
.route("/auth/me", get(desktop::me)) .route("/auth/me", get(desktop::me))
.route("/projects", get(desktop::list_projects).post(desktop::create_project)) .route("/projects", get(desktop::list_projects).post(desktop::create_project))
.route("/projects/{id}", get(desktop::pull_project).delete(desktop::delete_project)) .route("/projects/{id}", get(desktop::pull_project).delete(desktop::delete_project).patch(desktop::move_project))
.route("/projects/{id}/manifest", get(desktop::get_manifest)) .route("/projects/{id}/manifest", get(desktop::get_manifest))
.route("/folders", get(desktop::list_folders)) .route("/folders", get(desktop::list_folders).post(desktop::create_folder))
.route("/folders/{id}", patch(desktop::rename_folder).delete(desktop::delete_folder))
.route("/folders/{id}/move", patch(desktop::move_folder))
.route("/documents", get(desktop::list_documents).post(desktop::create_document)) .route("/documents", get(desktop::list_documents).post(desktop::create_document))
.route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document).delete(desktop::delete_document)) .route("/documents/{id}", get(desktop::pull_document).put(desktop::push_document).delete(desktop::delete_document).patch(desktop::move_document))
.route("/shared", get(desktop::list_shared)) .route("/shared", get(desktop::list_shared))
.route("/files", get(desktop::list_account_files)) .route("/files", get(desktop::list_account_files).post(desktop::upload_account_file))
.route("/files/{id}", get(desktop::pull_account_file).delete(desktop::delete_account_file)) .route("/files/{id}", get(desktop::pull_account_file).patch(desktop::rename_account_file).delete(desktop::delete_account_file))
.route("/files/{id}/move", patch(desktop::move_account_file))
.route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file)); .route("/projects/{id}/file", get(desktop::pull_file).put(desktop::push_file).delete(desktop::delete_file));
let v1_routes = Router::new() let v1_routes = Router::new()