Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions src/components/settings/WebhooksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { useState } from 'react'
import {
Globe, MessageSquare, Send, Hash, Plus, Trash2, Power, PowerOff,
CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3,
CheckCircle2, XCircle, ChevronDown, ChevronUp, Loader2, Zap, CheckSquare, Pencil, Columns3, BookOpen,
} from 'lucide-react'
import { useWorkspace } from '@/hooks/useWorkspace'
import { useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useWebhookLogs } from '@/hooks/useWebhooks'
Expand All @@ -14,7 +14,7 @@ import { cn } from '@/lib/utils'

// ─── Constants ──────────────────────────────────────────────────────────────

type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello'
type DestinationType = 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion'

const DESTINATION_META: Record<DestinationType, { label: string; icon: typeof Globe; color: string; bgColor: string }> = {
http: { label: 'HTTP Webhook', icon: Globe, color: 'text-blue-400', bgColor: 'bg-blue-500/10' },
Expand All @@ -24,6 +24,7 @@ const DESTINATION_META: Record<DestinationType, { label: string; icon: typeof Gl
teams: { label: 'Teams', icon: MessageSquare, color: 'text-violet-400', bgColor: 'bg-violet-500/10' },
asana: { label: 'Asana', icon: CheckSquare, color: 'text-rose-400', bgColor: 'bg-rose-500/10' },
trello: { label: 'Trello', icon: Columns3, color: 'text-teal-400', bgColor: 'bg-teal-500/10' },
notion: { label: 'Notion', icon: BookOpen, color: 'text-gray-300', bgColor: 'bg-gray-500/10' },
}

const EVENT_OPTIONS = [
Expand Down Expand Up @@ -169,7 +170,7 @@ function CreateWebhookForm({
{/* Destination Type */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-400">Destination</label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(Object.keys(DESTINATION_META) as DestinationType[]).map((type) => {
const meta = DESTINATION_META[type]
const Icon = meta.icon
Expand Down Expand Up @@ -485,6 +486,44 @@ function DestinationConfigFields({
</div>
</div>
)

case 'notion':
return (
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-400">Integration Token</label>
<input
type="password"
value={config.api_key ?? ''}
onChange={(e) => updateField('api_key', e.target.value)}
placeholder="secret_xxx..."
className={inputClass}
/>
<p className="mt-1 text-xs text-gray-600">
Create an internal integration at{' '}
<a href="https://www.notion.so/my-integrations" target="_blank" rel="noopener noreferrer" className="text-gray-300 hover:underline">
notion.so/my-integrations
</a>
{' '}and copy the secret.
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-400">Database ID</label>
<input
type="text"
value={config.database_id ?? ''}
onChange={(e) => updateField('database_id', e.target.value)}
placeholder="abc123def456..."
className={inputClass}
/>
<p className="mt-1 text-xs text-gray-600">
Open the database as a full page, then copy the ID from the URL: notion.so/<strong>DATABASE_ID</strong>?v=...
<br />
<span className="text-yellow-400/70">Tip:</span> Add a &quot;Name&quot; (title), &quot;Status&quot; (select), and &quot;Agent&quot; (text) property to your database for best results.
</p>
</div>
</div>
)
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/components/shared/ChannelSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 CrewForm

import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3 } from 'lucide-react'
import { Globe, MessageSquare, Hash, Send, Users, CheckSquare, Radio, Columns3, BookOpen } from 'lucide-react'
import { useOutputRoutes } from '@/hooks/useChannels'
import { cn } from '@/lib/utils'

Expand All @@ -13,6 +13,7 @@ const DESTINATION_ICONS: Record<string, typeof Globe> = {
teams: Users,
asana: CheckSquare,
trello: Columns3,
notion: BookOpen,
}

const DESTINATION_COLORS: Record<string, { text: string; bg: string }> = {
Expand All @@ -23,6 +24,7 @@ const DESTINATION_COLORS: Record<string, { text: string; bg: string }> = {
teams: { text: 'text-blue-400', bg: 'bg-blue-500/10' },
asana: { text: 'text-rose-400', bg: 'bg-rose-500/10' },
trello: { text: 'text-teal-400', bg: 'bg-teal-500/10' },
notion: { text: 'text-gray-300', bg: 'bg-gray-400/10' },
}

interface OutputRouteSelectorProps {
Expand Down
4 changes: 2 additions & 2 deletions src/db/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface OutputRoute {
id: string
workspace_id: string
name: string
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello'
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion'
config: Record<string, unknown>
events: string[]
is_active: boolean
Expand All @@ -34,7 +34,7 @@ export interface WebhookLog {
export interface CreateRouteInput {
workspace_id: string
name: string
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello'
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion'
config: Record<string, unknown>
events: string[]
is_active?: boolean
Expand Down
180 changes: 179 additions & 1 deletion task-runner/src/webhookDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface OutputRoute {
id: string;
workspace_id: string;
name: string;
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello';
destination_type: 'http' | 'slack' | 'discord' | 'telegram' | 'teams' | 'asana' | 'trello' | 'notion';
config: Record<string, unknown>;
events: string[];
is_active: boolean;
Expand Down Expand Up @@ -378,6 +378,8 @@ async function deliver(
return deliverAsana(route, payload);
case 'trello':
return deliverTrello(route, payload);
case 'notion':
return deliverNotion(route, payload);
default:
throw new Error(`Unknown destination type: ${route.destination_type}`);
}
Expand Down Expand Up @@ -1022,6 +1024,182 @@ async function deliverTrello(
return { ok: resp.ok, statusCode: resp.status };
}

// ── Notion ──────────────────────────────────────────────────────────────────

/**
* Deliver a webhook payload to Notion by creating a page in a database.
*
* Config fields:
* - api_key: Notion Internal Integration Token (secret_xxx)
* - database_id: Target database ID (32 hex chars, optionally with dashes)
*
* Creates a page with:
* - Title property: task title with status emoji
* - Status property (select): completed / failed / started
* - Agent property (rich_text): agent name
* - Page body: full result as paragraph blocks
*/
async function deliverNotion(
route: OutputRoute,
payload: WebhookPayload,
): Promise<{ ok: boolean; statusCode: number }> {
const apiKey = route.config.api_key as string;
const databaseId = route.config.database_id as string;

if (!apiKey || !databaseId) {
throw new Error('Notion integration requires api_key and database_id');
}

const emoji = payload.status === 'completed' ? 'βœ…' : '❌';
const output = payload.result_full || payload.error || 'No output';

// Build page content blocks from the result (Notion max block size is 2000 chars)
const contentBlocks = buildNotionBlocks(output);

// Properties β€” we use common property names that most Notion databases will have.
// Unknown properties are silently ignored by the API.
const properties: Record<string, unknown> = {
Name: {
title: [
{
text: {
content: `${emoji} ${payload.task_title}`.substring(0, 2000),
},
},
],
},
};

// Optional select property for status
properties.Status = {
select: { name: payload.status === 'completed' ? 'Done' : payload.status === 'failed' ? 'Failed' : 'In Progress' },
};

// Optional rich_text property for agent
properties.Agent = {
rich_text: [
{
text: {
content: payload.agent_name.substring(0, 2000),
},
},
],
};

// Optional rich_text property for event type
properties.Event = {
rich_text: [
{
text: {
content: payload.event,
},
},
],
};

const body = {
parent: {
type: 'database_id',
database_id: databaseId,
},
properties,
children: contentBlocks,
};

const resp = await fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Notion-Version': '2022-06-28',
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});

if (!resp.ok) {
const errBody = await resp.text().catch(() => '');
console.error(`[Notion] API error ${resp.status}: ${errBody.substring(0, 500)}`);
}

return { ok: resp.ok, statusCode: resp.status };
}

/**
* Convert a text result into Notion block children.
* Splits by paragraphs, respects the 2000-char limit per text block,
* and preserves markdown headings as Notion heading blocks.
*/
function buildNotionBlocks(text: string): Record<string, unknown>[] {
const blocks: Record<string, unknown>[] = [];
const lines = text.split('\n');
let currentParagraph = '';

function flushParagraph() {
if (!currentParagraph.trim()) return;
const content = currentParagraph.trim();
for (let i = 0; i < content.length; i += 2000) {
blocks.push({
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [
{
type: 'text',
text: { content: content.substring(i, i + 2000) },
},
],
},
});
}
currentParagraph = '';
}

for (const line of lines) {
const h1Match = /^#\s+(.+)$/.exec(line);
const h2Match = /^##\s+(.+)$/.exec(line);
const h3Match = /^###\s+(.+)$/.exec(line);

if (h1Match) {
flushParagraph();
blocks.push({
object: 'block',
type: 'heading_1',
heading_1: {
rich_text: [{ type: 'text', text: { content: h1Match[1].substring(0, 2000) } }],
},
});
} else if (h2Match) {
flushParagraph();
blocks.push({
object: 'block',
type: 'heading_2',
heading_2: {
rich_text: [{ type: 'text', text: { content: h2Match[1].substring(0, 2000) } }],
},
});
} else if (h3Match) {
flushParagraph();
blocks.push({
object: 'block',
type: 'heading_3',
heading_3: {
rich_text: [{ type: 'text', text: { content: h3Match[1].substring(0, 2000) } }],
},
});
} else if (line.trim() === '') {
flushParagraph();
} else {
currentParagraph += (currentParagraph ? '\n' : '') + line;
}
}

flushParagraph();

// Notion API limits children to 100 blocks per request
return blocks.slice(0, 100);
}

// ─── Logging ────────────────────────────────────────────────────────────────

async function logDelivery(
Expand Down
Loading