138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
/**
|
|
* Approval Step — send approval request, pause pipeline until callback.
|
|
* Config: { channel_id: 1, message: "...", options: ["Approve","Reject","Escalate"], timeout_min: 60 }
|
|
*/
|
|
|
|
import { registerStepExecutor } from '../pipeline-engine';
|
|
import { postgresClient } from '../postgres-client';
|
|
import { PipelineStep, PipelineContext, StepExecutorResult, NotificationChannel } from '../../types/pipeline';
|
|
|
|
async function executeApproval(
|
|
step: PipelineStep,
|
|
context: PipelineContext,
|
|
executionId: number
|
|
): Promise<StepExecutorResult> {
|
|
const channelId = Number(step.config.channel_id);
|
|
const message = step.config.message || 'Approval required';
|
|
const options = step.config.options || ['Approve', 'Reject'];
|
|
const timeoutMin = Number(step.config.timeout_min) || 60;
|
|
|
|
const expiresAt = new Date(Date.now() + timeoutMin * 60 * 1000);
|
|
|
|
// Create approval request record
|
|
const result = await postgresClient.query<{ id: number }>(
|
|
`INSERT INTO approval_requests (execution_id, step_order, channel_id, message, options, status, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, 'pending', $6)
|
|
RETURNING id`,
|
|
[executionId, step.step_order, channelId || null, message, JSON.stringify(options), expiresAt]
|
|
);
|
|
|
|
const approvalId = result.rows[0].id;
|
|
const callbackUrl = `${process.env.WEBHOOK_BASE_URL || ''}/api/pipelines/approval/${approvalId}`;
|
|
|
|
console.log(`[PIPELINE:approval] Created approval #${approvalId}, callback: ${callbackUrl}`);
|
|
|
|
// Send notification with approval buttons if channel is configured
|
|
if (channelId) {
|
|
const chResult = await postgresClient.query<NotificationChannel>(
|
|
`SELECT * FROM notification_channels WHERE id = $1 AND is_active = true`,
|
|
[channelId]
|
|
);
|
|
|
|
if (chResult.rows.length > 0) {
|
|
const channel = chResult.rows[0];
|
|
await sendApprovalNotification(channel, message, options, approvalId, callbackUrl, context);
|
|
}
|
|
}
|
|
|
|
// Return waiting — pipeline will pause here
|
|
return {
|
|
success: true,
|
|
waiting: true,
|
|
output: { approval_id: approvalId, callback_url: callbackUrl },
|
|
};
|
|
}
|
|
|
|
async function sendApprovalNotification(
|
|
channel: NotificationChannel,
|
|
message: string,
|
|
options: string[],
|
|
approvalId: number,
|
|
callbackUrl: string,
|
|
context: PipelineContext
|
|
): Promise<void> {
|
|
try {
|
|
if (channel.channel_type === 'teams') {
|
|
const actions = options.map(opt => ({
|
|
type: 'Action.OpenUrl',
|
|
title: opt,
|
|
url: `${callbackUrl}?response=${encodeURIComponent(opt)}`,
|
|
}));
|
|
|
|
const card = {
|
|
type: 'message',
|
|
attachments: [{
|
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
content: {
|
|
type: 'AdaptiveCard',
|
|
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
|
version: '1.4',
|
|
body: [
|
|
{ type: 'TextBlock', text: 'Approval Required', weight: 'bolder', size: 'medium' },
|
|
{ type: 'TextBlock', text: message, wrap: true },
|
|
{ type: 'TextBlock', text: `Approval #${approvalId}`, size: 'small', isSubtle: true },
|
|
],
|
|
actions,
|
|
},
|
|
}],
|
|
};
|
|
|
|
await fetch(channel.config.webhook_url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(card),
|
|
});
|
|
} else if (channel.channel_type === 'telegram') {
|
|
const keyboard = {
|
|
inline_keyboard: [options.map(opt => ({
|
|
text: opt,
|
|
callback_data: JSON.stringify({ approval_id: approvalId, response: opt }),
|
|
}))],
|
|
};
|
|
|
|
await fetch(`https://api.telegram.org/bot${channel.config.bot_token}/sendMessage`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: channel.config.chat_id,
|
|
text: `🔔 *Approval Required*\n\n${message}\n\n_Approval #${approvalId}_`,
|
|
parse_mode: 'Markdown',
|
|
reply_markup: keyboard,
|
|
}),
|
|
});
|
|
} else if (channel.channel_type === 'ntfy') {
|
|
const serverUrl = channel.config.server_url || 'https://ntfy.sh';
|
|
const headers: Record<string, string> = {
|
|
'Title': 'Approval Required',
|
|
'Priority': 'high',
|
|
'Tags': 'warning',
|
|
'Actions': options.map(opt =>
|
|
`http, ${opt}, ${callbackUrl}?response=${encodeURIComponent(opt)}, method=POST`
|
|
).join('; '),
|
|
};
|
|
if (channel.config.auth_token) {
|
|
headers['Authorization'] = `Bearer ${channel.config.auth_token}`;
|
|
}
|
|
|
|
await fetch(`${serverUrl}/${channel.config.topic}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: message,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error(`[PIPELINE:approval] Failed to send notification:`, err);
|
|
}
|
|
}
|
|
|
|
registerStepExecutor('approval', executeApproval);
|