From 9176eddad72b433de54038875f75725a9e47071f Mon Sep 17 00:00:00 2001 From: Sahil Deshmukh Date: Thu, 11 Dec 2025 22:39:56 -0800 Subject: [PATCH 1/2] Add API, docs --- API.md | 494 ++++++++++++++++++++++++++++++++++++++++++ src/api/auth.ts | 86 ++++++++ src/api/executions.ts | 139 ++++++++++++ src/api/metadata.ts | 77 +++++++ src/api/triggers.ts | 138 ++++++++++++ src/api/workflows.ts | 278 ++++++++++++++++++++++++ src/index.ts | 115 ++++++++++ 7 files changed, 1327 insertions(+) create mode 100644 API.md create mode 100644 src/api/auth.ts create mode 100644 src/api/executions.ts create mode 100644 src/api/metadata.ts create mode 100644 src/api/triggers.ts create mode 100644 src/api/workflows.ts diff --git a/API.md b/API.md new file mode 100644 index 0000000..d0df1b1 --- /dev/null +++ b/API.md @@ -0,0 +1,494 @@ +# Winterflows API Documentation + +## Overview + +The Winterflows API provides programmatic access to workflow management. Authenticate using API keys obtained via the `/winterflows-api` Slack command. + +**Base URL:** `/api/v1` + +## Authentication + +Include your API key in the `Authorization` header: + +``` +Authorization: Bearer +``` + +Get your API key by running `/winterflows-api` in Slack. + +## Workflows + +### List Workflows + +``` +GET /api/v1/workflows +``` + +**Query Parameters:** + +- `limit` (optional, max 100, default 50) +- `offset` (optional, default 0) +- `sort` (optional): `created_asc`, `created_desc`, `name_asc`, `name_desc` + +**Response:** + +```json +{ + "workflows": [ + { + "id": 1, + "name": "Daily Standup", + "description": "Morning reminders", + "created_at": "2025-12-01T10:00:00Z", + "is_installed": true, + "trigger": { "type": "cron", "schedule": "0 9 * * 1-5" }, + "step_count": 3 + } + ], + "total": 1, + "limit": 50, + "offset": 0, + "has_more": false +} +``` + +### Get Workflow + +``` +GET /api/v1/workflows/:id +``` + +**Response:** + +```json +{ + "id": 1, + "name": "Daily Standup", + "description": "Morning reminders", + "creator_user_id": "U12345678", + "app_id": "A98765432", + "is_installed": true, + "created_at": "2025-12-01T10:00:00Z", + "trigger": { "type": "cron", "schedule": "0 9 * * 1-5" }, + "steps": [ + { + "id": "step_1", + "type_id": "send_message", + "inputs": { + "channel_id": "C12345678", + "message": "Good morning!" + } + } + ] +} +``` + +### Create Workflow + +``` +POST /api/v1/workflows +``` + +**Request:** + +```json +{ + "name": "Daily Standup", + "description": "Morning reminders", + "steps": [ + { + "id": "step_1", + "type_id": "send_message", + "inputs": { + "channel_id": "C12345678", + "message": "Good morning!" + } + } + ], + "trigger": { + "type": "cron", + "schedule": "0 9 * * 1-5" + } +} +``` + +**Response:** `201 Created` + +```json +{ + "id": 1, + "name": "Daily Standup", + "installation_url": "https://slack.com/oauth/v2/authorize?...", + ... +} +``` + +### Update Workflow + +``` +PATCH /api/v1/workflows/:id +``` + +**Request (all fields optional):** + +```json +{ + "name": "Updated Name", + "description": "Updated description", + "steps": [...], + "trigger": { "type": "cron", "schedule": "0 10 * * *" } +} +``` + +### Delete Workflow + +``` +DELETE /api/v1/workflows/:id?force=false +``` + +**Query Parameters:** + +- `force` (optional, default false) - Delete even with active executions + +**Response:** `204 No Content` + +## Executions + +### List Workflow Executions + +``` +GET /api/v1/workflows/:id/executions +``` + +**Query Parameters:** + +- `limit` (optional, max 100, default 50) +- `offset` (optional, default 0) +- `status` (optional): `running`, `completed`, `failed`, `cancelled` +- `from` (optional, ISO 8601 timestamp) +- `to` (optional, ISO 8601 timestamp) + +**Response:** + +```json +{ + "executions": [ + { + "id": 42, + "workflow_id": 1, + "trigger_user_id": "U12345678", + "status": "completed", + "started_at": "2025-12-11T14:30:00Z", + "current_step": 3, + "total_steps": 3, + "trigger_type": "manual" + } + ], + "total": 1, + "limit": 50, + "offset": 0, + "has_more": false +} +``` + +### Get Execution Details + +``` +GET /api/v1/executions/:execution_id +``` + +**Response:** + +```json +{ + "id": 42, + "workflow_id": 1, + "workflow_name": "Daily Standup", + "trigger_user_id": "U12345678", + "status": "completed", + "started_at": "2025-12-11T14:30:00Z", + "trigger_type": "manual", + "steps": [ + { + "id": "step_1", + "type_id": "send_message", + "status": "completed", + "output": "Message sent" + } + ], + "context": {}, + "outputs": { + "step_1": "Message sent" + } +} +``` + +### Cancel Execution + +``` +POST /api/v1/executions/:execution_id/cancel +``` + +**Response:** + +```json +{ + "id": 42, + "status": "cancelled", + "cancelled_at": "2025-12-11T14:30:03Z" +} +``` + +## Triggers + +**Note:** Each workflow can have only one trigger. Updating replaces the existing trigger. + +### Get Workflow Trigger + +``` +GET /api/v1/workflows/:id/trigger +``` + +**Response:** + +```json +{ + "workflow_id": 1, + "type": "cron", + "schedule": "0 9 * * 1-5" +} +``` + +### Update Workflow Trigger + +``` +PUT /api/v1/workflows/:id/trigger +``` + +**Trigger Types:** + +Cron: + +```json +{ + "type": "cron", + "schedule": "0 10 * * 1-5" +} +``` + +Message (on message sent): + +```json +{ + "type": "message", + "channel_id": "C12345678" +} +``` + +Reaction (on emoji added): + +```json +{ + "type": "reaction", + "channel_id": "C12345678", + "emoji": "fire" +} +``` + +Member join (on user joins channel): + +```json +{ + "type": "member_join", + "channel_id": "C12345678" +} +``` + +Manual only: + +```json +{ + "type": "none" +} +``` + +### Delete Workflow Trigger + +``` +DELETE /api/v1/workflows/:id/trigger +``` + +Sets trigger to manual only (`type: "none"`). + +**Response:** `204 No Content` + +## Metadata + +### List Step Types + +``` +GET /api/v1/steps/types +``` + +**Response:** + +```json +{ + "steps": [ + { + "type_id": "send_message", + "name": "Send Message", + "category": "messages", + "inputs": { + "channel_id": { + "type": "string", + "required": true, + "description": "Channel ID to send message to" + }, + "message": { + "type": "string", + "required": true, + "description": "Message text" + } + }, + "outputs": { + "message_ts": { + "type": "string", + "description": "Timestamp of sent message" + } + } + } + ] +} +``` + +### Get Workflow Statistics + +``` +GET /api/v1/workflows/:id/stats +``` + +**Query Parameters:** + +- `from` (optional, ISO 8601 timestamp) +- `to` (optional, ISO 8601 timestamp) + +**Response:** + +```json +{ + "workflow_id": 1, + "period": { + "from": "2025-12-01T00:00:00Z", + "to": "2025-12-11T23:59:59Z" + }, + "total_executions": 50, + "successful_executions": 48, + "failed_executions": 2, + "average_duration_ms": 3500 +} +``` + +## Error Responses + +All errors follow this format: + +```json +{ + "error": { + "code": "error_code", + "message": "Human readable message", + "details": {} + } +} +``` + +**Error Codes:** + +- `authentication_failed` (401) - Invalid/missing API key +- `authorization_failed` (403) - No permission +- `not_found` (404) - Resource not found +- `invalid_request` (400) - Bad request +- `validation_error` (422) - Validation failed +- `conflict` (409) - Resource conflict + +## Examples + +j + +### cURL + +List workflows: + +```bash +curl -H "Authorization: Bearer YOUR_API_KEY" \ + https://winterflows.davidwhy.me/api/v1/workflows +``` + +Create workflow: + +```bash +curl -X POST \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"My Workflow","steps":[]}' \ + https://winterflows.davidwhy.me/api/v1/workflows +``` + +### JavaScript + +```javascript +const apiKey = 'your_api_key' +const baseUrl = 'https://winterflows.davidwhy.me/api/v1' + +const response = await fetch(`${baseUrl}/workflows`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, +}) + +const data = await response.json() +console.log(data.workflows) +``` + +### Python + +```python +import requests + +api_key = 'your_api_key' +base_url = 'https://winterflows.davidwhy.me/api/v1' + +headers = {'Authorization': f'Bearer {api_key}'} +response = requests.get(f'{base_url}/workflows', headers=headers) +workflows = response.json()['workflows'] +``` + +## Pagination + +List endpoints (`/workflows`, `/workflows/:id/executions`) support offset-based pagination: + +**Request:** + +``` +GET /api/v1/workflows?limit=50&offset=100 +``` + +**Response includes:** + +- `total` - Total number of items +- `limit` - Items per page (max 100, default 50) +- `offset` - Current offset +- `has_more` - Boolean indicating if more items exist + +**Example:** + +```json +{ + "workflows": [...], + "total": 250, + "limit": 50, + "offset": 100, + "has_more": true +} +``` diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..374c580 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,86 @@ +import { sql } from 'bun' +import type { User } from '../database/users' + +export async function authenticateRequest(req: Request): Promise { + const authHeader = req.headers.get('Authorization') + + if (!authHeader?.startsWith('Bearer ')) return null + + const apiKey = authHeader.slice(7) + const users = await sql`SELECT * FROM users WHERE api_key = ${apiKey}` + return users[0] || null +} + +export function unauthorizedResponse() { + return Response.json( + { + error: { + code: 'authentication_failed', + message: 'Invalid or missing API key', + }, + }, + { status: 401 } + ) +} + +export function forbiddenResponse() { + return Response.json( + { + error: { + code: 'authorization_failed', + message: 'User does not have permission', + }, + }, + { status: 403 } + ) +} + +export function notFoundResponse(message = 'Resource not found') { + return Response.json( + { + error: { + code: 'not_found', + message, + }, + }, + { status: 404 } + ) +} + +export function badRequestResponse(message: string, details?: any) { + return Response.json( + { + error: { + code: 'invalid_request', + message, + ...(details && { details }), + }, + }, + { status: 400 } + ) +} + +export function conflictResponse(message: string) { + return Response.json( + { + error: { + code: 'conflict', + message, + }, + }, + { status: 409 } + ) +} + +export function validationErrorResponse(message: string, details?: any) { + return Response.json( + { + error: { + code: 'validation_error', + message, + ...(details && { details }), + }, + }, + { status: 422 } + ) +} diff --git a/src/api/executions.ts b/src/api/executions.ts new file mode 100644 index 0000000..c17fd2f --- /dev/null +++ b/src/api/executions.ts @@ -0,0 +1,139 @@ +import type { User } from '../database/users' +import { + getWorkflowById, +} from '../database/workflows' +import { + getWorkflowExecutionById, + updateWorkflowExecution, + type WorkflowExecution, +} from '../database/workflow_executions' +import { notFoundResponse, badRequestResponse, conflictResponse } from './auth' +import { sql } from 'bun' + +function formatExecutionSummary(execution: WorkflowExecution) { + const steps = JSON.parse(execution.steps || '[]') + const totalSteps = steps.length + const isDone = execution.step_index >= totalSteps + + return { + id: execution.id, + workflow_id: execution.workflow_id, + trigger_user_id: execution.trigger_user_id, + status: isDone ? 'completed' : 'running', + started_at: new Date(execution.id * 100000).toISOString(), + current_step: execution.step_index, + total_steps: totalSteps, + trigger_type: execution.trigger_id ? 'automatic' : 'manual', + } +} + +function formatExecutionDetail(execution: WorkflowExecution, workflowName: string) { + const steps = JSON.parse(execution.steps || '[]') + const state = JSON.parse(execution.state || '{}') + const totalSteps = steps.length + const isDone = execution.step_index >= totalSteps + + const formattedSteps = steps.map((step: any, idx: number) => ({ + id: step.id, + type_id: step.type_id, + status: idx < execution.step_index ? 'completed' : idx === execution.step_index ? 'running' : 'pending', + output: state.outputs?.[step.id], + })) + + return { + id: execution.id, + workflow_id: execution.workflow_id, + workflow_name: workflowName, + trigger_user_id: execution.trigger_user_id, + status: isDone ? 'completed' : 'running', + started_at: new Date(execution.id * 100000).toISOString(), + trigger_type: execution.trigger_id ? 'automatic' : 'manual', + steps: formattedSteps, + context: state.additionalCtx || {}, + outputs: state.outputs || {}, + } +} + +export async function listWorkflowExecutions(user: User, workflowId: number, searchParams: URLSearchParams) { + const workflow = await getWorkflowById(workflowId) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100) + const offset = parseInt(searchParams.get('offset') || '0') + const status = searchParams.get('status') + + let query = sql`SELECT * FROM workflow_executions WHERE workflow_id = ${workflowId}` + + if (status) { + if (status === 'running') { + query = sql`SELECT * FROM workflow_executions WHERE workflow_id = ${workflowId} + AND step_index < json_array_length(steps)` + } else if (status === 'completed') { + query = sql`SELECT * FROM workflow_executions WHERE workflow_id = ${workflowId} + AND step_index >= json_array_length(steps)` + } + } + + const allExecutions = await query + const total = allExecutions.length + const executions = allExecutions.slice(offset, offset + limit) + + const formattedExecutions = executions.map(formatExecutionSummary) + + return Response.json({ + executions: formattedExecutions, + total, + limit, + offset, + has_more: offset + limit < total, + }) +} + +export async function getExecution(user: User, executionId: number) { + const execution = await getWorkflowExecutionById(executionId) + + if (!execution) { + return notFoundResponse('Execution not found') + } + + const workflow = await getWorkflowById(execution.workflow_id) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Execution not found') + } + + return Response.json(formatExecutionDetail(execution, workflow.name)) +} + +export async function cancelExecution(user: User, executionId: number) { + const execution = await getWorkflowExecutionById(executionId) + + if (!execution) { + return notFoundResponse('Execution not found') + } + + const workflow = await getWorkflowById(execution.workflow_id) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Execution not found') + } + + const steps = JSON.parse(execution.steps || '[]') + const isDone = execution.step_index >= steps.length + + if (isDone) { + return conflictResponse('Execution already completed') + } + + execution.step_index = steps.length + await updateWorkflowExecution(execution) + + return Response.json({ + id: execution.id, + status: 'cancelled', + cancelled_at: new Date().toISOString(), + }) +} diff --git a/src/api/metadata.ts b/src/api/metadata.ts new file mode 100644 index 0000000..a0cfd7f --- /dev/null +++ b/src/api/metadata.ts @@ -0,0 +1,77 @@ +import type { User } from '../database/users' +import { getWorkflowById } from '../database/workflows' +import { notFoundResponse } from './auth' +import stepSpecs from '../workflows/steps' +import { sql } from 'bun' + +export async function listStepTypes() { + const steps = Object.entries(stepSpecs).map(([typeId, spec]) => ({ + type_id: typeId, + name: spec.name, + category: spec.category, + inputs: Object.entries(spec.inputs).reduce( + (acc, [key, input]) => { + acc[key] = { + type: input.type, + required: input.required, + description: input.description || input.name, + } + return acc + }, + {} as Record + ), + outputs: Object.entries(spec.outputs).reduce( + (acc, [key, output]) => { + acc[key] = { + type: output.type, + description: output.description || output.name, + } + return acc + }, + {} as Record + ), + })) + + return Response.json({ steps }) +} + +export async function getWorkflowStats(user: User, workflowId: number, searchParams: URLSearchParams) { + const workflow = await getWorkflowById(workflowId) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + const from = searchParams.get('from') + const to = searchParams.get('to') + + let query = sql`SELECT * FROM workflow_executions WHERE workflow_id = ${workflowId}` + + const executions = await query + + const totalExecutions = executions.length + let successfulExecutions = 0 + let failedExecutions = 0 + + for (const exec of executions) { + const steps = JSON.parse(exec.steps || '[]') + const isDone = exec.step_index >= steps.length + if (isDone) { + successfulExecutions++ + } else { + failedExecutions++ + } + } + + return Response.json({ + workflow_id: workflowId, + period: { + from: from || new Date(0).toISOString(), + to: to || new Date().toISOString(), + }, + total_executions: totalExecutions, + successful_executions: successfulExecutions, + failed_executions: failedExecutions, + average_duration_ms: 0, + }) +} diff --git a/src/api/triggers.ts b/src/api/triggers.ts new file mode 100644 index 0000000..b30ef0b --- /dev/null +++ b/src/api/triggers.ts @@ -0,0 +1,138 @@ +import type { User } from '../database/users' +import { getWorkflowById } from '../database/workflows' +import { + getWorkflowTrigger, + deleteTriggersByWorkflowId, +} from '../database/triggers' +import { notFoundResponse, badRequestResponse } from './auth' +import { generateManifest, getActiveConfigToken } from '../utils/slack' +import slack from '../clients/slack' +import { + createCronTrigger, + createMemberJoinTrigger, + createMessageTrigger, + createReactionTrigger, +} from '../triggers/create' + +function formatTriggerResponse(trigger: any) { + const base = { workflow_id: trigger.workflow_id, type: trigger.type } + + if (trigger.type === 'cron') { + return { ...base, schedule: trigger.val_string } + } else if (trigger.type === 'message' || trigger.type === 'member_join') { + return { ...base, channel_id: trigger.val_string } + } else if (trigger.type === 'reaction') { + const [channel, emoji] = (trigger.val_string || '|').split('|') + return { ...base, channel_id: channel, emoji } + } + + return base +} + +export async function getWorkflowTriggerEndpoint(user: User, workflowId: number) { + const workflow = await getWorkflowById(workflowId) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + const trigger = await getWorkflowTrigger(workflowId) + + if (!trigger) { + return Response.json({ workflow_id: workflowId, type: 'none' }) + } + + return Response.json(formatTriggerResponse(trigger)) +} + +export async function updateWorkflowTrigger(user: User, workflowId: number, body: any) { + const workflow = await getWorkflowById(workflowId) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + if (!body.type || typeof body.type !== 'string') { + return badRequestResponse('Trigger type is required') + } + + await deleteTriggersByWorkflowId(workflowId) + + if (body.type !== 'none') { + const configToken = await getActiveConfigToken() + if (configToken) { + const manifest = generateManifest(workflow.name, body.type) + try { + await slack.apps.manifest.update({ + token: configToken, + app_id: workflow.app_id, + manifest, + }) + } catch (e) { + console.error('Failed to update manifest:', e) + } + } + + const base = { + execution_id: null, + workflow_id: workflowId, + details: null, + } + + if (body.type === 'cron') { + if (!body.schedule) { + return badRequestResponse('Cron trigger requires schedule field') + } + await createCronTrigger(body.schedule, { + ...base, + func: 'workflow.execute.cron', + }) + } else if (body.type === 'message') { + if (!body.channel_id) { + return badRequestResponse('Message trigger requires channel_id field') + } + await createMessageTrigger(body.channel_id, { + ...base, + func: 'workflow.execute.message', + }) + } else if (body.type === 'reaction') { + if (!body.channel_id || !body.emoji) { + return badRequestResponse('Reaction trigger requires channel_id and emoji fields') + } + await createReactionTrigger(body.channel_id, body.emoji, { + ...base, + func: 'workflow.execute.reaction', + }) + } else if (body.type === 'member_join') { + if (!body.channel_id) { + return badRequestResponse('Member join trigger requires channel_id field') + } + await createMemberJoinTrigger(body.channel_id, { + ...base, + func: 'workflow.execute.member_join', + }) + } else { + return badRequestResponse('Invalid trigger type') + } + } + + const trigger = await getWorkflowTrigger(workflowId) + + if (!trigger) { + return Response.json({ workflow_id: workflowId, type: 'none' }) + } + + return Response.json(formatTriggerResponse(trigger)) +} + +export async function deleteWorkflowTrigger(user: User, workflowId: number) { + const workflow = await getWorkflowById(workflowId) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + await deleteTriggersByWorkflowId(workflowId) + + return new Response(null, { status: 204 }) +} diff --git a/src/api/workflows.ts b/src/api/workflows.ts new file mode 100644 index 0000000..2b791f9 --- /dev/null +++ b/src/api/workflows.ts @@ -0,0 +1,278 @@ +import type { User } from '../database/users' +import { + getWorkflowById, + getWorkflowsByCreator, + addWorkflow, + updateWorkflow, + deleteWorkflowById, + type Workflow, +} from '../database/workflows' +import { + getWorkflowTrigger, + deleteTriggersByWorkflowId, +} from '../database/triggers' +import { getWorkflowSteps } from '../utils/workflows' +import { + notFoundResponse, + badRequestResponse, + validationErrorResponse, + conflictResponse, +} from './auth' +import { generateManifest, getActiveConfigToken } from '../utils/slack' +import slack from '../clients/slack' +import { createCronTrigger, createMemberJoinTrigger, createMessageTrigger, createReactionTrigger } from '../triggers/create' +import { sql } from 'bun' + +function formatWorkflowSummary(workflow: Workflow, trigger: any) { + const steps = JSON.parse(workflow.steps || '[]') + return { + id: workflow.id, + name: workflow.name, + description: workflow.description, + created_at: new Date(workflow.id * 100000).toISOString(), + is_installed: !!workflow.access_token, + trigger: trigger ? formatTriggerResponse(trigger) : { type: 'none' }, + step_count: steps.length, + } +} + +function formatWorkflowDetail(workflow: Workflow, trigger: any) { + const steps = JSON.parse(workflow.steps || '[]') + return { + id: workflow.id, + name: workflow.name, + description: workflow.description, + creator_user_id: workflow.creator_user_id, + app_id: workflow.app_id, + is_installed: !!workflow.access_token, + created_at: new Date(workflow.id * 100000).toISOString(), + trigger: trigger ? formatTriggerResponse(trigger) : { type: 'none' }, + steps, + } +} + +function formatTriggerResponse(trigger: any) { + const base = { type: trigger.type } + + if (trigger.type === 'cron') { + return { ...base, schedule: trigger.val_string } + } else if (trigger.type === 'message' || trigger.type === 'member_join') { + return { ...base, channel_id: trigger.val_string } + } else if (trigger.type === 'reaction') { + const [channel, emoji] = (trigger.val_string || '|').split('|') + return { ...base, channel_id: channel, emoji } + } + + return base +} + +export async function listWorkflows(user: User, searchParams: URLSearchParams) { + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 100) + const offset = parseInt(searchParams.get('offset') || '0') + const sort = searchParams.get('sort') || 'created_desc' + + let workflows = await getWorkflowsByCreator(user.id) + + if (sort === 'created_asc') { + workflows.sort((a, b) => a.id - b.id) + } else if (sort === 'name_asc') { + workflows.sort((a, b) => a.name.localeCompare(b.name)) + } else if (sort === 'name_desc') { + workflows.sort((a, b) => b.name.localeCompare(a.name)) + } + + const total = workflows.length + workflows = workflows.slice(offset, offset + limit) + + const triggers = await sql`SELECT * FROM triggers WHERE workflow_id = ANY(${workflows.map(w => w.id)})` + const triggerMap = new Map(triggers.map((t: any) => [t.workflow_id, t])) + + const formattedWorkflows = workflows.map(w => + formatWorkflowSummary(w, triggerMap.get(w.id)) + ) + + return Response.json({ + workflows: formattedWorkflows, + total, + limit, + offset, + has_more: offset + limit < total, + }) +} + +export async function getWorkflow(user: User, id: number) { + const workflow = await getWorkflowById(id) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + const trigger = await getWorkflowTrigger(workflow.id) + + return Response.json(formatWorkflowDetail(workflow, trigger)) +} + +export async function createWorkflow(user: User, body: any) { + if (!body.name || typeof body.name !== 'string') { + return badRequestResponse('Workflow name is required') + } + + const configToken = await getActiveConfigToken() + if (!configToken) { + return badRequestResponse('System configuration unavailable') + } + + const triggerType = body.trigger?.type || 'none' + const manifest = generateManifest(body.name, triggerType === 'none' ? undefined : triggerType) + + let app + try { + app = await slack.apps.manifest.create({ + token: configToken, + manifest, + }) + } catch (e) { + console.error('Failed to create app:', e) + return badRequestResponse('Failed to create Slack app') + } + + const workflow = await addWorkflow({ + name: body.name, + creator_user_id: user.id, + app_id: app.app_id!, + client_id: app.credentials!.client_id!, + client_secret: app.credentials!.client_secret!, + signing_secret: app.credentials!.signing_secret!, + access_token: null, + }) + + workflow.description = body.description || 'A brand new workflow' + + if (body.steps && Array.isArray(body.steps)) { + workflow.steps = JSON.stringify(body.steps) + await updateWorkflow(workflow) + } + + if (body.trigger && body.trigger.type !== 'none') { + await createTriggerFromSpec(workflow.id, body.trigger) + } + + const trigger = await getWorkflowTrigger(workflow.id) + + const url = new URL(app.oauth_authorize_url!) + url.searchParams.set('state', app.app_id!) + + return Response.json( + { + ...formatWorkflowDetail(workflow, trigger), + installation_url: url.toString(), + }, + { status: 201 } + ) +} + +export async function patchWorkflow(user: User, id: number, body: any) { + const workflow = await getWorkflowById(id) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + if (body.name !== undefined) { + workflow.name = body.name + } + if (body.description !== undefined) { + workflow.description = body.description + } + if (body.steps !== undefined) { + if (!Array.isArray(body.steps)) { + return badRequestResponse('Steps must be an array') + } + workflow.steps = JSON.stringify(body.steps) + } + + await updateWorkflow(workflow) + + if (body.trigger !== undefined) { + await deleteTriggersByWorkflowId(workflow.id) + + if (body.trigger.type && body.trigger.type !== 'none') { + const configToken = await getActiveConfigToken() + if (configToken) { + const manifest = generateManifest(workflow.name, body.trigger.type) + try { + await slack.apps.manifest.update({ + token: configToken, + app_id: workflow.app_id, + manifest, + }) + } catch (e) { + console.error('Failed to update manifest:', e) + } + } + + await createTriggerFromSpec(workflow.id, body.trigger) + } + } + + const trigger = await getWorkflowTrigger(workflow.id) + + return Response.json(formatWorkflowDetail(workflow, trigger)) +} + +export async function deleteWorkflow(user: User, id: number, searchParams: URLSearchParams) { + const workflow = await getWorkflowById(id) + + if (!workflow || workflow.creator_user_id !== user.id) { + return notFoundResponse('Workflow not found') + } + + const force = searchParams.get('force') === 'true' + + if (!force) { + const runningExecutions = await sql` + SELECT COUNT(*) as count FROM workflow_executions + WHERE workflow_id = ${id} AND step_index < ( + SELECT json_array_length(steps) FROM workflow_executions WHERE id = workflow_executions.id + ) + ` + + if (runningExecutions[0]?.count > 0) { + return conflictResponse('Cannot delete workflow with active executions') + } + } + + await deleteWorkflowById(id) + + return new Response(null, { status: 204 }) +} + +async function createTriggerFromSpec(workflowId: number, triggerSpec: any) { + const base = { + execution_id: null, + workflow_id: workflowId, + details: null, + } + + if (triggerSpec.type === 'cron') { + await createCronTrigger(triggerSpec.schedule, { + ...base, + func: 'workflow.execute.cron', + }) + } else if (triggerSpec.type === 'message') { + await createMessageTrigger(triggerSpec.channel_id, { + ...base, + func: 'workflow.execute.message', + }) + } else if (triggerSpec.type === 'reaction') { + await createReactionTrigger(triggerSpec.channel_id, triggerSpec.emoji, { + ...base, + func: 'workflow.execute.reaction', + }) + } else if (triggerSpec.type === 'member_join') { + await createMemberJoinTrigger(triggerSpec.channel_id, { + ...base, + func: 'workflow.execute.member_join', + }) + } +} diff --git a/src/index.ts b/src/index.ts index 32c81ef..79f6e2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,11 @@ import { cronTriggerTask, timeTriggerTask } from './triggers/task' import { getActiveConfigToken, getDMLink, getUserLink } from './utils/slack' import { handleWorkflowEvent } from './workflows/events' import { handleInteraction } from './workflows/interaction' +import { authenticateRequest, unauthorizedResponse } from './api/auth' +import * as workflowApi from './api/workflows' +import * as executionApi from './api/executions' +import * as triggerApi from './api/triggers' +import * as metadataApi from './api/metadata' const PORT = process.env.PORT || '8000' const { SLACK_APP_ID } = process.env @@ -207,6 +212,116 @@ Bun.serve({ ) return Response.redirect(await getUserLink(workflow.access_token)) }, + + '/api/v1/workflows': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + return workflowApi.listWorkflows(user, new URL(req.url).searchParams) + }, + POST: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const body = await req.json() + return workflowApi.createWorkflow(user, body) + }, + }, + + '/api/v1/workflows/:id': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return workflowApi.getWorkflow(user, id) + }, + PATCH: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + const body = await req.json() + return workflowApi.patchWorkflow(user, id, body) + }, + DELETE: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return workflowApi.deleteWorkflow(user, id, new URL(req.url).searchParams) + }, + }, + + '/api/v1/workflows/:id/executions': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return executionApi.listWorkflowExecutions(user, id, new URL(req.url).searchParams) + }, + }, + + '/api/v1/executions/:execution_id': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.execution_id) + if (isNaN(id)) return NOT_FOUND + return executionApi.getExecution(user, id) + }, + }, + + '/api/v1/executions/:execution_id/cancel': { + POST: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.execution_id) + if (isNaN(id)) return NOT_FOUND + return executionApi.cancelExecution(user, id) + }, + }, + + '/api/v1/workflows/:id/trigger': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return triggerApi.getWorkflowTriggerEndpoint(user, id) + }, + PUT: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + const body = await req.json() + return triggerApi.updateWorkflowTrigger(user, id, body) + }, + DELETE: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return triggerApi.deleteWorkflowTrigger(user, id) + }, + }, + + '/api/v1/steps/types': { + GET: async () => { + return metadataApi.listStepTypes() + }, + }, + + '/api/v1/workflows/:id/stats': { + GET: async (req) => { + const user = await authenticateRequest(req) + if (!user) return unauthorizedResponse() + const id = parseInt(req.params.id) + if (isNaN(id)) return NOT_FOUND + return metadataApi.getWorkflowStats(user, id, new URL(req.url).searchParams) + }, + }, }, port: PORT, }) From 3b019b11898d64eec09ba454b519e8e800847994 Mon Sep 17 00:00:00 2001 From: Sahil Deshmukh Date: Thu, 11 Dec 2025 22:50:08 -0800 Subject: [PATCH 2/2] oops --- API.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/API.md b/API.md index d0df1b1..152e604 100644 --- a/API.md +++ b/API.md @@ -414,8 +414,6 @@ All errors follow this format: ## Examples -j - ### cURL List workflows: