mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: Microsoft Teams approvals (#5734)
* move branch * openapi version * full interactive approvals * move to ee * move to ee * move ee * merge common logic slack/teams * merge common logic slack/teams * sqlx prepare * formatting * linter ee * update ee-repo ref * ee repo ref --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH job_info AS (\n -- Query for Teams (running jobs)\n SELECT\n parent.job_kind AS \"job_kind!: JobKind\",\n parent.script_hash AS \"script_hash: ScriptHash\",\n parent.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",\n child.parent_job AS \"parent_job: Uuid\",\n parent.created_at AS \"created_at!: chrono::NaiveDateTime\",\n parent.created_by AS \"created_by!\",\n parent.script_path,\n parent.args AS \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_as_queue child\n JOIN v2_as_queue parent ON parent.id = child.parent_job\n WHERE child.id = $1 AND child.workspace_id = $2\n UNION ALL\n -- Query for Slack (completed jobs)\n SELECT\n v2_as_queue.job_kind AS \"job_kind!: JobKind\",\n v2_as_queue.script_hash AS \"script_hash: ScriptHash\",\n v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",\n v2_as_completed_job.parent_job AS \"parent_job: Uuid\",\n v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",\n v2_as_completed_job.created_by AS \"created_by!\",\n v2_as_queue.script_path,\n v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_as_queue\n JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id\n WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2\n )\n SELECT * FROM job_info LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_kind!: JobKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "job_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"preview",
|
||||
"flow",
|
||||
"dependencies",
|
||||
"flowpreview",
|
||||
"script_hub",
|
||||
"identity",
|
||||
"flowdependencies",
|
||||
"http",
|
||||
"graphql",
|
||||
"postgresql",
|
||||
"noop",
|
||||
"appdependencies",
|
||||
"deploymentcallback",
|
||||
"singlescriptflow",
|
||||
"flowscript",
|
||||
"flownode",
|
||||
"appscript"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "script_hash: ScriptHash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "raw_flow: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job: Uuid",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: chrono::NaiveDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "args: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e"
|
||||
}
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n v2_as_queue.job_kind AS \"job_kind!: JobKind\",\n v2_as_queue.script_hash AS \"script_hash: ScriptHash\",\n v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",\n v2_as_completed_job.parent_job AS \"parent_job: Uuid\",\n v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",\n v2_as_completed_job.created_by AS \"created_by!\",\n v2_as_queue.script_path,\n v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_as_queue\n JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id\n WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_kind!: JobKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "job_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"preview",
|
||||
"flow",
|
||||
"dependencies",
|
||||
"flowpreview",
|
||||
"script_hub",
|
||||
"identity",
|
||||
"flowdependencies",
|
||||
"http",
|
||||
"graphql",
|
||||
"postgresql",
|
||||
"noop",
|
||||
"appdependencies",
|
||||
"deploymentcallback",
|
||||
"singlescriptflow",
|
||||
"flowscript",
|
||||
"flownode",
|
||||
"appscript"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "script_hash: ScriptHash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "raw_flow: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job: Uuid",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_at!: chrono::NaiveDateTime",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_by!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "args: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75"
|
||||
}
|
||||
+5
-5
@@ -41,11 +41,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1 +1 @@
|
||||
1c9466b1f7f737033eedb4ff8c89bebdfddd2f26
|
||||
0a0ab1d28132e3a07e00bcc7d038274bbf316e47
|
||||
|
||||
@@ -7899,6 +7899,52 @@ paths:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
|
||||
/w/{workspace}/jobs/teams_approval/{id}:
|
||||
get:
|
||||
summary: generate interactive teams approval for suspended job
|
||||
operationId: getTeamsApprovalPayload
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
- name: approver
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: message
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: team_name
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: channel_name
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: flow_step_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: default_args_json
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: dynamic_enums_json
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
use std::str::FromStr;
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::jobs::{cancel_suspended_job, resume_suspended_job, QueryApprover, QueryOrBody, ResumeUrls, get_resume_urls_internal};
|
||||
use axum::{extract::{Path, Query}, Extension};
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::cache;
|
||||
use windmill_common::jobs::JobKind;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResumeSchema {
|
||||
pub schema: Schema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Schema {
|
||||
pub order: Vec<String>,
|
||||
pub required: Vec<String>,
|
||||
pub properties: HashMap<String, ResumeFormField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FieldType {
|
||||
Boolean,
|
||||
String,
|
||||
Number,
|
||||
Integer,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct ResumeFormField {
|
||||
pub r#type: FieldType,
|
||||
pub format: Option<String>,
|
||||
pub default: Option<serde_json::Value>,
|
||||
pub description: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub r#enum: Option<Vec<String>>,
|
||||
#[serde(rename = "enumLabels")]
|
||||
pub enum_labels: Option<HashMap<String, String>>,
|
||||
pub nullable: Option<bool>,
|
||||
pub placeholder: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ResumeFormRow {
|
||||
pub resume_form: Option<serde_json::Value>,
|
||||
pub hide_cancel: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryMessage {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryFlowStepId {
|
||||
pub flow_step_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryDefaultArgsJson {
|
||||
pub default_args_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryDynamicEnumJson {
|
||||
pub dynamic_enums_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApprovalFormDetails {
|
||||
pub message_str: String,
|
||||
pub urls: ResumeUrls,
|
||||
pub schema: Option<ResumeFormRow>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MessageFormat {
|
||||
Slack,
|
||||
Teams,
|
||||
}
|
||||
|
||||
pub fn extract_w_id_from_resume_url(resume_url: &str) -> Result<&str, Error> {
|
||||
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
|
||||
let captures = re.captures(resume_url).ok_or_else(|| {
|
||||
tracing::error!("Resume URL does not match the pattern.");
|
||||
Error::BadRequest("Invalid URL format.".to_string())
|
||||
})?;
|
||||
Ok(captures.name("w_id").map_or("", |m| m.as_str()))
|
||||
}
|
||||
|
||||
pub async fn handle_resume_action(
|
||||
authed: Option<ApiAuthed>,
|
||||
db: DB,
|
||||
resume_url: &str,
|
||||
form_data: Value,
|
||||
action: &str,
|
||||
) -> Result<(), Error> {
|
||||
// Extract information from resume_url using regex
|
||||
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
|
||||
let captures = re.captures(resume_url).ok_or_else(|| {
|
||||
tracing::error!("Resume URL does not match the pattern.");
|
||||
Error::BadRequest("Invalid URL format.".to_string())
|
||||
})?;
|
||||
|
||||
let (w_id, job_id, resume_id, secret, approver) = (
|
||||
captures.name("w_id").map_or("", |m| m.as_str()),
|
||||
captures.name("job_id").map_or("", |m| m.as_str()),
|
||||
captures.name("resume_id").map_or("", |m| m.as_str()),
|
||||
captures.name("secret").map_or("", |m| m.as_str()),
|
||||
captures.name("approver").map(|m| m.as_str().to_string()),
|
||||
);
|
||||
|
||||
let approver = QueryApprover { approver };
|
||||
|
||||
// Convert job_id and resume_id to appropriate types
|
||||
let job_uuid = Uuid::from_str(job_id)
|
||||
.map_err(|_| Error::BadRequest("Invalid job ID format.".to_string()))?;
|
||||
|
||||
let resume_id_parsed = resume_id
|
||||
.parse::<u32>()
|
||||
.map_err(|_| Error::BadRequest("Invalid resume ID format.".to_string()))?;
|
||||
|
||||
// Call the appropriate function based on the action
|
||||
let res = if action == "resume" {
|
||||
resume_suspended_job(
|
||||
authed,
|
||||
Extension(db.clone()),
|
||||
Path((
|
||||
w_id.to_string(),
|
||||
job_uuid,
|
||||
resume_id_parsed,
|
||||
secret.to_string(),
|
||||
)),
|
||||
Query(approver),
|
||||
QueryOrBody(Some(form_data)),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
cancel_suspended_job(
|
||||
authed,
|
||||
Extension(db.clone()),
|
||||
Path((
|
||||
w_id.to_string(),
|
||||
job_uuid,
|
||||
resume_id_parsed,
|
||||
secret.to_string(),
|
||||
)),
|
||||
Query(approver),
|
||||
QueryOrBody(Some(form_data)),
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
tracing::debug!("Job action result: {:#?}", res);
|
||||
res?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_approval_form_details(
|
||||
db: DB,
|
||||
w_id: &str,
|
||||
job_id: Uuid,
|
||||
flow_step_id: Option<&str>,
|
||||
resume_id: u32,
|
||||
approver: Option<&str>,
|
||||
message: Option<&str>,
|
||||
format: MessageFormat,
|
||||
) -> Result<ApprovalFormDetails, Error> {
|
||||
let res = get_resume_urls_internal(
|
||||
axum::Extension(db.clone()),
|
||||
Path((w_id.to_string(), job_id, resume_id)),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let urls = res.0;
|
||||
|
||||
tracing::debug!("Job ID: {:?}", job_id);
|
||||
|
||||
// TODO: do we have a helper function for this?
|
||||
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
|
||||
"WITH job_info AS (
|
||||
-- Query for Teams (running jobs)
|
||||
SELECT
|
||||
parent.job_kind AS \"job_kind!: JobKind\",
|
||||
parent.script_hash AS \"script_hash: ScriptHash\",
|
||||
parent.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
|
||||
child.parent_job AS \"parent_job: Uuid\",
|
||||
parent.created_at AS \"created_at!: chrono::NaiveDateTime\",
|
||||
parent.created_by AS \"created_by!\",
|
||||
parent.script_path,
|
||||
parent.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
|
||||
FROM v2_as_queue child
|
||||
JOIN v2_as_queue parent ON parent.id = child.parent_job
|
||||
WHERE child.id = $1 AND child.workspace_id = $2
|
||||
UNION ALL
|
||||
-- Query for Slack (completed jobs)
|
||||
SELECT
|
||||
v2_as_queue.job_kind AS \"job_kind!: JobKind\",
|
||||
v2_as_queue.script_hash AS \"script_hash: ScriptHash\",
|
||||
v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
|
||||
v2_as_completed_job.parent_job AS \"parent_job: Uuid\",
|
||||
v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",
|
||||
v2_as_completed_job.created_by AS \"created_by!\",
|
||||
v2_as_queue.script_path,
|
||||
v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
|
||||
FROM v2_as_queue
|
||||
JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id
|
||||
WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2
|
||||
)
|
||||
SELECT * FROM job_info LIMIT 1",
|
||||
job_id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?
|
||||
.ok_or_else(|| Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
|
||||
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
|
||||
|
||||
let flow_data = match cache::job::fetch_flow(&db, job_kind, script_hash).await {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if let Some(parent_job_id) = parent_job_id.as_ref() {
|
||||
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let flow_value = &flow_data.flow;
|
||||
let flow_step_id = flow_step_id.unwrap_or("");
|
||||
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
|
||||
|
||||
tracing::debug!("Module: {:#?}", module);
|
||||
|
||||
let schema = module.and_then(|module| {
|
||||
module.suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
})
|
||||
});
|
||||
|
||||
let args_str = args.map_or("None".to_string(), |a| a.get().to_string());
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let bold_format = match format {
|
||||
MessageFormat::Slack => "*{}*",
|
||||
MessageFormat::Teams => "**{}**",
|
||||
};
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
{}: {created_by}\n\n\
|
||||
{}: {created_at_formatted}\n\n\
|
||||
{}: {script_path_str}\n\n\
|
||||
{}: {args_str}\n\n\
|
||||
{}: {parent_job_id_str}\n\n",
|
||||
bold_format.replace("{}", "Created by"),
|
||||
bold_format.replace("{}", "Created at"),
|
||||
bold_format.replace("{}", "Script path"),
|
||||
bold_format.replace("{}", "Args"),
|
||||
bold_format.replace("{}", "Flow ID")
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
Ok(ApprovalFormDetails { message_str, urls, schema })
|
||||
}
|
||||
@@ -120,6 +120,8 @@ mod scripts;
|
||||
mod service_logs;
|
||||
mod settings;
|
||||
mod slack_approvals;
|
||||
mod approvals;
|
||||
mod teams_approvals_ee;
|
||||
#[cfg(feature = "smtp")]
|
||||
mod smtp_server_ee;
|
||||
#[cfg(all(feature = "enterprise", feature = "sqs_trigger"))]
|
||||
@@ -650,6 +652,10 @@ pub async fn run_server(
|
||||
"/w/:workspace_id/jobs/slack_approval/:job_id",
|
||||
get(slack_approvals::request_slack_approval),
|
||||
)
|
||||
.route(
|
||||
"/w/:workspace_id/jobs/teams_approval/:job_id",
|
||||
get(teams_approvals_ee::request_teams_approval),
|
||||
)
|
||||
.nest("/w/:workspace_id/github_app", {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
|
||||
@@ -3,28 +3,21 @@ use axum::{
|
||||
Extension,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::{RawValue, Value};
|
||||
|
||||
use sqlx::types::Uuid;
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sqlx::types::Uuid;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::variables::get_secret_value_as_admin;
|
||||
|
||||
use crate::approvals::{
|
||||
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
|
||||
MessageFormat, QueryDefaultArgsJson, QueryDynamicEnumJson, QueryFlowStepId, QueryMessage,
|
||||
ResumeFormField, ResumeSchema,
|
||||
};
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::jobs::{
|
||||
cancel_suspended_job, get_resume_urls_internal, resume_suspended_job, QueryApprover,
|
||||
QueryOrBody, ResumeUrls,
|
||||
};
|
||||
|
||||
use windmill_common::{
|
||||
cache,
|
||||
error::{self, Error},
|
||||
jobs::JobKind,
|
||||
scripts::ScriptHash,
|
||||
variables::get_secret_value_as_admin,
|
||||
};
|
||||
use crate::jobs::{QueryApprover, ResumeUrls};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SlackFormData {
|
||||
@@ -91,53 +84,6 @@ struct SelectedOption {
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ResumeSchema {
|
||||
schema: Schema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResumeFormRow {
|
||||
resume_form: Option<serde_json::Value>,
|
||||
hide_cancel: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Schema {
|
||||
order: Vec<String>,
|
||||
required: Vec<String>,
|
||||
properties: HashMap<String, ResumeFormField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum FieldType {
|
||||
Boolean,
|
||||
String,
|
||||
Number,
|
||||
Integer,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ResumeFormField {
|
||||
r#type: FieldType,
|
||||
format: Option<String>,
|
||||
default: Option<serde_json::Value>,
|
||||
description: Option<String>,
|
||||
title: Option<String>,
|
||||
r#enum: Option<Vec<String>>,
|
||||
#[serde(rename = "enumLabels")]
|
||||
enum_labels: Option<HashMap<String, String>>,
|
||||
nullable: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryMessage {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryResourcePath {
|
||||
slack_resource_path: String,
|
||||
@@ -148,21 +94,6 @@ pub struct QueryChannelId {
|
||||
channel_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryFlowStepId {
|
||||
flow_step_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryDefaultArgsJson {
|
||||
default_args_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryDynamicEnumJson {
|
||||
dynamic_enums_json: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ModalActionValue {
|
||||
w_id: String,
|
||||
@@ -362,73 +293,21 @@ async fn handle_submission(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Use regex to extract information from private_metadata
|
||||
let re = Regex::new(r"/api/w/(?P<w_id>[^/]+)/jobs_u/(?P<action>resume|cancel)/(?P<job_id>[^/]+)/(?P<resume_id>[^/]+)/(?P<secret>[a-fA-F0-9]+)(?:\?approver=(?P<approver>[^&]+))?").unwrap();
|
||||
let captures = re.captures(resume_url.as_str()).ok_or_else(|| {
|
||||
tracing::error!("Resume URL does not match the pattern.");
|
||||
Error::BadRequest("Invalid URL format.".to_string())
|
||||
})?;
|
||||
// Use the common handler to process the resume/cancel action
|
||||
handle_resume_action(authed, db.clone(), &resume_url, state_json, action).await?;
|
||||
|
||||
let (w_id, job_id, resume_id, secret, approver) = (
|
||||
captures.name("w_id").map_or("", |m| m.as_str()),
|
||||
captures.name("job_id").map_or("", |m| m.as_str()),
|
||||
captures.name("resume_id").map_or("", |m| m.as_str()),
|
||||
captures.name("secret").map_or("", |m| m.as_str()),
|
||||
captures.name("approver").map(|m| m.as_str().to_string()),
|
||||
);
|
||||
|
||||
let approver = QueryApprover { approver: approver };
|
||||
|
||||
// Convert job_id and resume_id to appropriate types
|
||||
let job_uuid = Uuid::from_str(job_id)
|
||||
.map_err(|_| Error::BadRequest("Invalid job ID format.".to_string()))?;
|
||||
|
||||
let resume_id_parsed = resume_id
|
||||
.parse::<u32>()
|
||||
.map_err(|_| Error::BadRequest("Invalid resume ID format.".to_string()))?;
|
||||
|
||||
// Call the appropriate function based on the action
|
||||
let res = if action == "resume" {
|
||||
resume_suspended_job(
|
||||
authed,
|
||||
Extension(db.clone()),
|
||||
Path((
|
||||
w_id.to_string(),
|
||||
job_uuid,
|
||||
resume_id_parsed,
|
||||
secret.to_string(),
|
||||
)),
|
||||
Query(approver),
|
||||
QueryOrBody(Some(state_json)),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
cancel_suspended_job(
|
||||
authed,
|
||||
Extension(db.clone()),
|
||||
Path((
|
||||
w_id.to_string(),
|
||||
job_uuid,
|
||||
resume_id_parsed,
|
||||
secret.to_string(),
|
||||
)),
|
||||
Query(approver),
|
||||
QueryOrBody(Some(state_json)),
|
||||
)
|
||||
.await
|
||||
};
|
||||
tracing::debug!("Resume job action result: {:#?}", res);
|
||||
let slack_token = get_slack_token(&db, &resource_path, &w_id).await?;
|
||||
let w_id = extract_w_id_from_resume_url(&resume_url)?;
|
||||
let slack_token = get_slack_token(&db, &resource_path, w_id).await?;
|
||||
update_original_slack_message(action, slack_token, container).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn transform_schemas(
|
||||
text: &str,
|
||||
properties: Option<&HashMap<String, ResumeFormField>>,
|
||||
properties: Option<HashMap<String, ResumeFormField>>,
|
||||
urls: &ResumeUrls,
|
||||
order: Option<&Vec<String>>,
|
||||
required: Option<&Vec<String>>,
|
||||
order: Option<Vec<String>>,
|
||||
required: Option<Vec<String>>,
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
@@ -443,16 +322,16 @@ async fn transform_schemas(
|
||||
})];
|
||||
|
||||
if let Some(properties) = properties {
|
||||
for key in order.unwrap() {
|
||||
if let Some(schema) = properties.get(key) {
|
||||
let is_required = required.unwrap().contains(key);
|
||||
for key in order.unwrap_or_default() {
|
||||
if let Some(schema) = properties.get(&key) {
|
||||
let is_required = required.as_ref().map_or(false, |r| r.contains(&key));
|
||||
|
||||
let default_value = default_args_json.and_then(|json| json.get(key).cloned());
|
||||
let default_value = default_args_json.and_then(|json| json.get(&key).cloned());
|
||||
let dynamic_enums_value =
|
||||
dynamic_enums_json.and_then(|json| json.get(key).cloned());
|
||||
dynamic_enums_json.and_then(|json| json.get(&key).cloned());
|
||||
|
||||
let input_block = create_input_block(
|
||||
key,
|
||||
&key,
|
||||
schema,
|
||||
is_required,
|
||||
default_value,
|
||||
@@ -947,155 +826,59 @@ async fn get_modal_blocks(
|
||||
default_args_json: Option<&serde_json::Value>,
|
||||
dynamic_enums_json: Option<&serde_json::Value>,
|
||||
) -> Result<axum::Json<serde_json::Value>, Error> {
|
||||
let res = get_resume_urls_internal(
|
||||
axum::Extension(db.clone()),
|
||||
Path((w_id.to_string(), job_id, resume_id)),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
|
||||
let approval_details = crate::approvals::get_approval_form_details(
|
||||
db,
|
||||
w_id,
|
||||
job_id,
|
||||
flow_step_id,
|
||||
resume_id,
|
||||
approver,
|
||||
message,
|
||||
MessageFormat::Slack,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let urls = res.0;
|
||||
let ApprovalFormDetails { message_str, urls, schema } = approval_details;
|
||||
|
||||
tracing::debug!("Job ID: {:?}", job_id);
|
||||
|
||||
let (job_kind, script_hash, raw_flow, parent_job_id, created_at, created_by, script_path, args) = sqlx::query!(
|
||||
"SELECT
|
||||
v2_as_queue.job_kind AS \"job_kind!: JobKind\",
|
||||
v2_as_queue.script_hash AS \"script_hash: ScriptHash\",
|
||||
v2_as_queue.raw_flow AS \"raw_flow: sqlx::types::Json<Box<RawValue>>\",
|
||||
v2_as_completed_job.parent_job AS \"parent_job: Uuid\",
|
||||
v2_as_completed_job.created_at AS \"created_at!: chrono::NaiveDateTime\",
|
||||
v2_as_completed_job.created_by AS \"created_by!\",
|
||||
v2_as_queue.script_path,
|
||||
v2_as_queue.args AS \"args: sqlx::types::Json<Box<RawValue>>\"
|
||||
FROM v2_as_queue
|
||||
JOIN v2_as_completed_job ON v2_as_completed_job.parent_job = v2_as_queue.id
|
||||
WHERE v2_as_completed_job.id = $1 AND v2_as_completed_job.workspace_id = $2
|
||||
LIMIT 1",
|
||||
job_id,
|
||||
&w_id
|
||||
// Get the card content
|
||||
let card_content = transform_schemas(
|
||||
&message_str,
|
||||
schema
|
||||
.as_ref()
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|f| {
|
||||
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
||||
inner_schema.schema.properties
|
||||
}),
|
||||
&urls,
|
||||
schema
|
||||
.as_ref()
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|f| {
|
||||
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
||||
inner_schema.schema.order
|
||||
}),
|
||||
schema
|
||||
.as_ref()
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|f| {
|
||||
let inner_schema: ResumeSchema = serde_json::from_value(f.clone()).unwrap();
|
||||
inner_schema.schema.required
|
||||
}),
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::BadRequest(e.to_string()))?
|
||||
.ok_or_else(|| error::Error::BadRequest("This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string()))
|
||||
.map(|r| (r.job_kind, r.script_hash, r.raw_flow, r.parent_job, r.created_at, r.created_by, r.script_path, r.args))?;
|
||||
.await?;
|
||||
|
||||
let flow_data = match cache::job::fetch_flow(&db, job_kind, script_hash).await {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if let Some(parent_job_id) = parent_job_id.as_ref() {
|
||||
cache::job::fetch_preview_flow(&db, parent_job_id, raw_flow).await?
|
||||
} else {
|
||||
return Err(error::Error::BadRequest(
|
||||
"This workflow is no longer running and has either already timed out or been cancelled or completed.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let flow_value = &flow_data.flow;
|
||||
let flow_step_id = flow_step_id.unwrap_or("");
|
||||
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
|
||||
|
||||
tracing::debug!("Module: {:#?}", module);
|
||||
|
||||
let schema = module.and_then(|module| {
|
||||
module.suspend.as_ref().map(|suspend| ResumeFormRow {
|
||||
resume_form: suspend.resume_form.clone(),
|
||||
hide_cancel: suspend.hide_cancel,
|
||||
})
|
||||
});
|
||||
|
||||
let args_str = args.map_or("None".to_string(), |a| a.get().to_string());
|
||||
let parent_job_id_str = parent_job_id.map_or("None".to_string(), |id| id.to_string());
|
||||
let script_path_str = script_path.as_deref().unwrap_or("None");
|
||||
|
||||
let created_at_formatted = created_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let mut message_str = format!(
|
||||
"A workflow has been suspended and is waiting for approval:\n\n\
|
||||
*Created by*: {created_by}\n\
|
||||
*Created at*: {created_at_formatted}\n\
|
||||
*Script path*: {script_path_str}\n\
|
||||
*Args*: {args_str}\n\
|
||||
*Flow ID*: {parent_job_id_str}\n\n"
|
||||
);
|
||||
|
||||
// Append custom message if provided
|
||||
if let Some(msg) = message {
|
||||
message_str.push_str(msg);
|
||||
}
|
||||
|
||||
tracing::debug!("Schema: {:#?}", schema);
|
||||
|
||||
if let Some(resume_schema) = schema {
|
||||
let hide_cancel = resume_schema.hide_cancel.unwrap_or(false);
|
||||
|
||||
// if hide cancel is false add note to message
|
||||
if !hide_cancel {
|
||||
message_str.push_str("\n\n*NOTE*: closing this modal will cancel the workflow.\n\n");
|
||||
}
|
||||
|
||||
// Convert message_str back to &str when needed
|
||||
let message_str_ref: &str = &message_str;
|
||||
|
||||
if let Some(schema_obj) = resume_schema.resume_form {
|
||||
let inner_schema: ResumeSchema =
|
||||
serde_json::from_value(schema_obj.clone()).map_err(|e| {
|
||||
tracing::error!("Failed to deserialize form schema: {:?}", e);
|
||||
Error::BadRequest(
|
||||
"Failed to deserialize resume form schema! Unsupported form field used."
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let blocks = transform_schemas(
|
||||
message_str_ref,
|
||||
Some(&inner_schema.schema.properties),
|
||||
&urls,
|
||||
Some(&inner_schema.schema.order),
|
||||
Some(&inner_schema.schema.required),
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Slack Blocks: {:#?}", blocks);
|
||||
return Ok(axum::Json(construct_payload(
|
||||
blocks,
|
||||
hide_cancel,
|
||||
trigger_id,
|
||||
&urls.resume,
|
||||
resource_path,
|
||||
container,
|
||||
)));
|
||||
} else {
|
||||
tracing::debug!("No suspend form found!");
|
||||
let blocks = transform_schemas(
|
||||
message_str_ref,
|
||||
None,
|
||||
&urls,
|
||||
None,
|
||||
None,
|
||||
default_args_json,
|
||||
dynamic_enums_json,
|
||||
)
|
||||
.await?;
|
||||
return Ok(axum::Json(construct_payload(
|
||||
blocks,
|
||||
hide_cancel,
|
||||
trigger_id,
|
||||
&urls.resume,
|
||||
resource_path,
|
||||
container,
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
Err(Error::BadRequest(
|
||||
"No approval form schema found.".to_string(),
|
||||
))
|
||||
}
|
||||
tracing::debug!("Slack Blocks: {:#?}", card_content);
|
||||
Ok(axum::Json(construct_payload(
|
||||
card_content,
|
||||
schema.as_ref().and_then(|s| s.hide_cancel).unwrap_or(false),
|
||||
trigger_id,
|
||||
&urls.resume,
|
||||
resource_path,
|
||||
container,
|
||||
)))
|
||||
}
|
||||
|
||||
fn construct_payload(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
use hyper::StatusCode;
|
||||
|
||||
use windmill_common::error::Error;
|
||||
|
||||
pub async fn request_teams_approval() -> Result<StatusCode, Error> {
|
||||
Err(Error::InternalErr("enterprise feature only".to_string()))
|
||||
}
|
||||
@@ -14,5 +14,5 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
|
||||
echo "" >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
|
||||
echo "" >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval, } from "./client";' >> "${script_dirpath}/src/index.ts"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
rm -rf "${script_dirpath}/src"
|
||||
|
||||
npx --yes @hey-api/openapi-ts@0.43.0 --input "${script_dirpath}/../backend/windmill-api/openapi.yaml" --output "${script_dirpath}/src" --useOptions --schemas false
|
||||
npx --yes @hey-api/openapi-ts@0.43.0 --input "${script_dirpath}/../backend/windmill-api/openapi.yaml" --output "${script_dirpath}/src" --useOptions --schemas false
|
||||
cat <<EOF - src/core/OpenAPI.ts > temp_file && mv temp_file src/core/OpenAPI.ts
|
||||
const getEnv = (key: string) => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -39,4 +39,4 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
|
||||
echo "" >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
|
||||
echo "" >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"
|
||||
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql, requestInteractiveTeamsApproval } from "./client";' >> "${script_dirpath}/src/index.ts"
|
||||
|
||||
+103
-2
@@ -456,8 +456,8 @@ export async function getProgress(jobId?: any): Promise<number | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a flow user state
|
||||
* @param key key of the state
|
||||
* Set a flow user state
|
||||
* @param key key of the state
|
||||
* @param value value of the state
|
||||
|
||||
*/
|
||||
@@ -920,6 +920,15 @@ interface SlackApprovalOptions {
|
||||
dynamicEnumsJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface TeamsApprovalOptions {
|
||||
teamName: string;
|
||||
channelName: string;
|
||||
message?: string;
|
||||
approver?: string;
|
||||
defaultArgsJson?: Record<string, any>;
|
||||
dynamicEnumsJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.
|
||||
*
|
||||
@@ -1012,6 +1021,98 @@ export async function requestInteractiveSlackApproval({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.
|
||||
*
|
||||
* **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**
|
||||
* and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).
|
||||
*
|
||||
* @param {Object} options - The configuration options for the Teams approval request.
|
||||
* @param {string} options.teamName - The Teams team name where the approval request will be sent.
|
||||
* @param {string} options.channelName - The Teams channel name where the approval request will be sent.
|
||||
* @param {string} [options.message] - Optional custom message to include in the Teams approval request.
|
||||
* @param {string} [options.approver] - Optional user ID or name of the approver for the request.
|
||||
* @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.
|
||||
* @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.
|
||||
*
|
||||
* @throws {Error} If the function is not called within a flow or flow preview.
|
||||
* @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.
|
||||
*
|
||||
* **Usage Example:**
|
||||
* ```typescript
|
||||
* await requestInteractiveTeamsApproval({
|
||||
* teamName: "admins-teams",
|
||||
* channelName: "admins-teams-channel",
|
||||
* message: "Please approve this request",
|
||||
* approver: "approver123",
|
||||
* defaultArgsJson: { key1: "value1", key2: 42 },
|
||||
* dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* **Note:** This function requires execution within a Windmill flow or flow preview.
|
||||
*/
|
||||
export async function requestInteractiveTeamsApproval({
|
||||
teamName,
|
||||
channelName,
|
||||
message,
|
||||
approver,
|
||||
defaultArgsJson,
|
||||
dynamicEnumsJson,
|
||||
}: TeamsApprovalOptions): Promise<void> {
|
||||
const workspace = getWorkspace();
|
||||
const flowJobId = getEnv("WM_FLOW_JOB_ID");
|
||||
|
||||
if (!flowJobId) {
|
||||
throw new Error(
|
||||
"You can't use this function in a standalone script or flow step preview. Please use it in a flow or a flow preview."
|
||||
);
|
||||
}
|
||||
|
||||
const flowStepId = getEnv("WM_FLOW_STEP_ID");
|
||||
if (!flowStepId) {
|
||||
throw new Error("This function can only be called as a flow step");
|
||||
}
|
||||
|
||||
// Only include non-empty parameters
|
||||
const params: {
|
||||
approver?: string;
|
||||
message?: string;
|
||||
teamName: string;
|
||||
channelName: string;
|
||||
flowStepId: string;
|
||||
defaultArgsJson?: string;
|
||||
dynamicEnumsJson?: string;
|
||||
} = {
|
||||
teamName,
|
||||
channelName,
|
||||
flowStepId,
|
||||
};
|
||||
|
||||
if (message) {
|
||||
params.message = message;
|
||||
}
|
||||
if (approver) {
|
||||
params.approver = approver;
|
||||
}
|
||||
|
||||
if (defaultArgsJson) {
|
||||
params.defaultArgsJson = JSON.stringify(defaultArgsJson);
|
||||
}
|
||||
|
||||
if (dynamicEnumsJson) {
|
||||
params.dynamicEnumsJson = JSON.stringify(dynamicEnumsJson);
|
||||
}
|
||||
|
||||
await JobService.getTeamsApprovalPayload({
|
||||
workspace,
|
||||
...params,
|
||||
id: getEnv("WM_JOB_ID") ?? "NO_JOB_ID",
|
||||
});
|
||||
}
|
||||
|
||||
async function getMockedApi(): Promise<MockedApi | undefined> {
|
||||
if (mockedApi) {
|
||||
return mockedApi;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
#! /usr/bin/env nu
|
||||
|
||||
let cache = "/tmp/windmill/cache_nomount/bun/"
|
||||
let cache = "/tmp/windmill/cache_nomount/bun/"
|
||||
|
||||
# Clean cache
|
||||
def "main clean" [] {
|
||||
^rm -rf ($cache ++ "/windmill-client")
|
||||
^rm -rf ($cache ++ "/windmill-client")
|
||||
}
|
||||
|
||||
# Watch changes in directory and autopatch (watchexec required)
|
||||
def "main watch" [] {
|
||||
# watchexec -w ../backend/windmill-api/openapi.yaml './dev.nu -g' &
|
||||
# watchexec -w ../backend/windmill-api/openapi.yaml './dev.nu -g' &
|
||||
# TODO: Watch openapi.yaml
|
||||
^watchexec ./dev.nu
|
||||
|
||||
@@ -19,7 +19,7 @@ def "main watch" [] {
|
||||
# To build you will need nushell and tsc (typescript compiler)
|
||||
# If none arguments selected, all will be turned on
|
||||
# If any argument specified, all others will be disabled
|
||||
def main [
|
||||
def main [
|
||||
--gen(-g) # Generate code (OpenAPI codegen)
|
||||
--compile(-c) # Compile code (TS >> JS)
|
||||
--patch(-p) # Patch
|
||||
@@ -47,16 +47,16 @@ def main [
|
||||
rm -rf ($cache ++ windmill-client@*/dist/*)
|
||||
|
||||
# Delete all script bundles
|
||||
# rm -rf /tmp/windmill/cache/bun/*
|
||||
# rm -rf /tmp/windmill/cache_nomount/bun/*
|
||||
|
||||
# Copy files from local ./dist to every wm-client version in cache
|
||||
ls ($cache ++ "windmill-client/") | each {
|
||||
|i|
|
||||
|i|
|
||||
|
||||
let path = $i | get name;
|
||||
^cp -r dist/* ($path ++ "/dist")
|
||||
let path = $i | get name;
|
||||
^cp -r dist/* ($path ++ "/dist")
|
||||
}
|
||||
}
|
||||
|
||||
print Done!
|
||||
print Done!
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user