feat(backend): add scoped get-draft and list-drafts endpoints

Never-deployed items now live only in the draft table, so the editors and home
list need to read drafts directly:
- GET /w/{workspace}/drafts/get/{kind}/{path} returns the requesting user's
  draft for that item (email-scoped), 404 if none.
- GET /w/{workspace}/drafts/list returns the user's drafts (path, typ, value,
  created_at), email-scoped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-06-03 17:09:15 +02:00
parent 548f504edc
commit 71db1caa23
4 changed files with 245 additions and 21 deletions
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, value as \"value: _\", typ as \"typ: DraftType\"\n FROM draft\n WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value: _",
"type_info": "Json"
},
{
"ordinal": 2,
"name": "typ: DraftType",
"type_info": {
"Custom": {
"name": "draft_type",
"kind": {
"Enum": [
"script",
"flow",
"app"
]
}
}
}
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "draft_type",
"kind": {
"Enum": [
"script",
"flow",
"app"
]
}
}
},
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "0877f40f086fb5324af54701b29c1edc9d3afb796a06ae4c673aa4c54ad87ddb"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, value as \"value: _\", typ as \"typ: DraftType\", created_at\n FROM draft\n WHERE workspace_id = $1 AND email = $2\n ORDER BY created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "value: _",
"type_info": "Json"
},
{
"ordinal": 2,
"name": "typ: DraftType",
"type_info": {
"Custom": {
"name": "draft_type",
"kind": {
"Enum": [
"script",
"flow",
"app"
]
}
}
}
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "67f91733c44e9db3124a713dd950e2bad22709254f20a2c385377b9a00e7d79f"
}
+68
View File
@@ -7854,6 +7854,74 @@ paths:
schema:
type: string
/w/{workspace}/drafts/get/{kind}/{path}:
get:
summary: get draft
operationId: getDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
type: string
enum:
- script
- flow
- app
- $ref: "#/components/parameters/ScriptPath"
responses:
"200":
description: draft
content:
application/json:
schema:
type: object
properties:
path:
type: string
typ:
type: string
enum: ["flow", "script", "app"]
value: {}
required:
- path
- typ
/w/{workspace}/drafts/list:
get:
summary: list drafts of the current user
operationId: listDrafts
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of drafts
content:
application/json:
schema:
type: array
items:
type: object
properties:
path:
type: string
typ:
type: string
enum: ["flow", "script", "app"]
value: {}
created_at:
type: string
format: date-time
required:
- path
- typ
- created_at
/w/{workspace}/scripts/create:
post:
summary: create script
+66 -21
View File
@@ -13,17 +13,23 @@ use crate::{
use axum::{
extract::{Extension, Path},
routing::{delete, post},
routing::{delete, get, post},
Json, Router,
};
use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use windmill_common::{db::UserDB, error::Result, utils::StripPath};
use windmill_common::{
db::UserDB,
error::{JsonResult, Result},
utils::{not_found_if_none, StripPath},
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_draft))
.route("/delete/{kind}/{*path}", delete(delete_draft))
.route("/get/{kind}/{*path}", get(get_draft))
.route("/list", get(list_drafts))
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]
@@ -117,24 +123,63 @@ async fn delete_draft(
Ok(format!("deleted draft"))
}
// async fn get_draft(
// authed: ApiAuthed,
// Extension(user_db): Extension<UserDB>,
// Path((w_id, path)): Path<(String, StripPath)>,
// ) -> JsonResult<Draft> {
// let path = path.to_path();
// let mut tx = user_db.begin(&authed).await?;
// Fetch a single draft (scoped to the requesting user). Used by the editors to
// (re)open a never-deployed item that lives only in the `draft` table.
async fn get_draft(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, DraftType, StripPath)>,
) -> JsonResult<Draft> {
let path = path.to_path();
let mut tx = user_db.begin(&authed).await?;
// let script_o = sqlx::query_as!(
// Draft,
// r#"SELECT path, value, typ as "typ: DraftType" FROM draft WHERE path = $1 AND workspace_id = $2"#,
// path,
// w_id
// )
// .fetch_optional(&mut *tx)
// .await?;
// tx.commit().await?;
let draft_o = sqlx::query_as!(
Draft,
r#"SELECT path, value as "value: _", typ as "typ: DraftType"
FROM draft
WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4"#,
path,
kind as DraftType,
w_id,
&authed.email,
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
// let draft = not_found_if_none(script_o, "draft", path)?;
// Ok(Json(draft))
// }
let draft = not_found_if_none(draft_o, "draft", path)?;
Ok(Json(draft))
}
#[derive(Serialize, Debug)]
pub struct DraftWithMetadata {
pub path: String,
pub value: sqlx::types::Json<Box<serde_json::value::Value>>,
pub typ: DraftType,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// List the requesting user's drafts. The editors/home use this to surface
// never-deployed items (which no longer have a row in script/flow/app).
async fn list_drafts(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DraftWithMetadata>> {
let mut tx = user_db.begin(&authed).await?;
let drafts = sqlx::query_as!(
DraftWithMetadata,
r#"SELECT path, value as "value: _", typ as "typ: DraftType", created_at
FROM draft
WHERE workspace_id = $1 AND email = $2
ORDER BY created_at DESC"#,
w_id,
&authed.email,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(drafts))
}