From 039f3e02268f2acda48abea420479216970e58e7 Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 13 May 2025 17:06:49 -0400 Subject: [PATCH] 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 --- ...10fffb37599f2a703885ee723cb9290fed13e.json | 91 +++++ ...0cb549a34b96554ae1872355b90304f5dcb76.json | 4 +- ...bbd7ec86610ddc8589bf5b606fab0947c8b75.json | 91 ----- ...508835affcb7679a48f2a443777e829bd1e74.json | 10 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 46 +++ backend/windmill-api/src/approvals.rs | 292 ++++++++++++++ backend/windmill-api/src/lib.rs | 6 + backend/windmill-api/src/slack_approvals.rs | 361 ++++-------------- .../windmill-api/src/teams_approvals_ee.rs | 7 + typescript-client/build.jsr.sh | 2 +- typescript-client/build.sh | 4 +- typescript-client/client.ts | 105 ++++- typescript-client/dev.nu | 18 +- 14 files changed, 637 insertions(+), 402 deletions(-) create mode 100644 backend/.sqlx/query-3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e.json delete mode 100644 backend/.sqlx/query-f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75.json create mode 100644 backend/windmill-api/src/approvals.rs create mode 100644 backend/windmill-api/src/teams_approvals_ee.rs diff --git a/backend/.sqlx/query-3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e.json b/backend/.sqlx/query-3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e.json new file mode 100644 index 0000000000..9f5e2e9d8f --- /dev/null +++ b/backend/.sqlx/query-3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e.json @@ -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>\",\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>\"\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>\",\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>\"\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>", + "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>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "3f08ffbb5c71b873a9e164ecb0b10fffb37599f2a703885ee723cb9290fed13e" +} diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75.json b/backend/.sqlx/query-f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75.json deleted file mode 100644 index 26111685ba..0000000000 --- a/backend/.sqlx/query-f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75.json +++ /dev/null @@ -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>\",\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>\"\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>", - "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>", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "f0fdeb7aea3e71099e7db0f4343bbd7ec86610ddc8589bf5b606fab0947c8b75" -} diff --git a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json index 649ab85650..de2e819af2 100644 --- a/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json +++ b/backend/.sqlx/query-fec6d5674dc6b5a6a0ece419c40508835affcb7679a48f2a443777e829bd1e74.json @@ -41,11 +41,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true ] }, diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e742bdd62f..14abf6dc74 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1c9466b1f7f737033eedb4ff8c89bebdfddd2f26 \ No newline at end of file +0a0ab1d28132e3a07e00bcc7d038274bbf316e47 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 544b1348d1..b0982e097e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/approvals.rs b/backend/windmill-api/src/approvals.rs new file mode 100644 index 0000000000..f4ee9dd843 --- /dev/null +++ b/backend/windmill-api/src/approvals.rs @@ -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, + pub required: Vec, + pub properties: HashMap, +} + +#[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, + pub default: Option, + pub description: Option, + pub title: Option, + pub r#enum: Option>, + #[serde(rename = "enumLabels")] + pub enum_labels: Option>, + pub nullable: Option, + pub placeholder: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ResumeFormRow { + pub resume_form: Option, + pub hide_cancel: Option, +} + +#[derive(Deserialize)] +pub struct QueryMessage { + pub message: Option, +} + +#[derive(Deserialize)] +pub struct QueryFlowStepId { + pub flow_step_id: String, +} + +#[derive(Deserialize, Debug)] +pub struct QueryDefaultArgsJson { + pub default_args_json: Option, +} + +#[derive(Deserialize, Debug)] +pub struct QueryDynamicEnumJson { + pub dynamic_enums_json: Option, +} + +#[derive(Debug)] +pub struct ApprovalFormDetails { + pub message_str: String, + pub urls: ResumeUrls, + pub schema: Option, +} + +#[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[^/]+)/jobs_u/(?Presume|cancel)/(?P[^/]+)/(?P[^/]+)/(?P[a-fA-F0-9]+)(?:\?approver=(?P[^&]+))?").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, + 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[^/]+)/jobs_u/(?Presume|cancel)/(?P[^/]+)/(?P[^/]+)/(?P[a-fA-F0-9]+)(?:\?approver=(?P[^&]+))?").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::() + .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 { + 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>\", + 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>\" + 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>\", + 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>\" + 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 }) +} diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index d7d9498fab..7fe3ce3392 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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")] { diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs index 4f37b648a9..019f83ecb2 100644 --- a/backend/windmill-api/src/slack_approvals.rs +++ b/backend/windmill-api/src/slack_approvals.rs @@ -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, - hide_cancel: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -struct Schema { - order: Vec, - required: Vec, - properties: HashMap, -} - -#[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, - default: Option, - description: Option, - title: Option, - r#enum: Option>, - #[serde(rename = "enumLabels")] - enum_labels: Option>, - nullable: Option, -} - -#[derive(Deserialize)] -pub struct QueryMessage { - message: Option, -} - #[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, -} - -#[derive(Deserialize, Debug)] -pub struct QueryDynamicEnumJson { - dynamic_enums_json: Option, -} - #[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[^/]+)/jobs_u/(?Presume|cancel)/(?P[^/]+)/(?P[^/]+)/(?P[a-fA-F0-9]+)(?:\?approver=(?P[^&]+))?").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::() - .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>, + properties: Option>, urls: &ResumeUrls, - order: Option<&Vec>, - required: Option<&Vec>, + order: Option>, + required: Option>, default_args_json: Option<&serde_json::Value>, dynamic_enums_json: Option<&serde_json::Value>, ) -> Result { @@ -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, 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>\", - 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>\" - 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( diff --git a/backend/windmill-api/src/teams_approvals_ee.rs b/backend/windmill-api/src/teams_approvals_ee.rs new file mode 100644 index 0000000000..05698b98e3 --- /dev/null +++ b/backend/windmill-api/src/teams_approvals_ee.rs @@ -0,0 +1,7 @@ +use hyper::StatusCode; + +use windmill_common::error::Error; + +pub async fn request_teams_approval() -> Result { + Err(Error::InternalErr("enterprise feature only".to_string())) +} \ No newline at end of file diff --git a/typescript-client/build.jsr.sh b/typescript-client/build.jsr.sh index 06dae6605b..e203c17fdf 100755 --- a/typescript-client/build.jsr.sh +++ b/typescript-client/build.jsr.sh @@ -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" diff --git a/typescript-client/build.sh b/typescript-client/build.sh index 5f9cdbc4ec..4934c5e673 100755 --- a/typescript-client/build.sh +++ b/typescript-client/build.sh @@ -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 < 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" diff --git a/typescript-client/client.ts b/typescript-client/client.ts index 258ea76215..25de187598 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -456,8 +456,8 @@ export async function getProgress(jobId?: any): Promise { } /** - * 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; } +interface TeamsApprovalOptions { + teamName: string; + channelName: string; + message?: string; + approver?: string; + defaultArgsJson?: Record; + dynamicEnumsJson?: Record; +} + /** * 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} 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 { + 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 { if (mockedApi) { return mockedApi; diff --git a/typescript-client/dev.nu b/typescript-client/dev.nu index 36b384eeb3..9bd61a7bd3 100755 --- a/typescript-client/dev.nu +++ b/typescript-client/dev.nu @@ -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! }