diff --git a/packages/server/next.config.ts b/packages/server/next.config.ts index 386853b..72cc281 100644 --- a/packages/server/next.config.ts +++ b/packages/server/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from 'next' const nextConfig: NextConfig = { - transpilePackages: ['@openthreads/core', '@openthreads/storage-mongodb'], + transpilePackages: ['@openthreads/core', '@openthreads/storage-mongodb', '@xyflow/react'], // Allow the mongodb package to run in the Node.js runtime (not Edge) serverExternalPackages: ['mongodb'], // Ant Design 5 requires the emotion/CSS-in-JS layer; keep it in-bundle. diff --git a/packages/server/src/app/api/routes/test/route.ts b/packages/server/src/app/api/routes/test/route.ts new file mode 100644 index 0000000..3f179c5 --- /dev/null +++ b/packages/server/src/app/api/routes/test/route.ts @@ -0,0 +1,33 @@ +/** + * POST /api/routes/test — Test which routes match given criteria + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { findMatchingRoutes } from '@/lib/db'; +import { verifyManagementAuth } from '@/lib/auth'; +import type { RouteCriteria } from '@openthreads/core'; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest): Promise { + const auth = verifyManagementAuth(request); + if (!auth.valid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + try { + const criteria = body as Partial; + const routes = await findMatchingRoutes(criteria); + return NextResponse.json({ matchingRouteIds: routes.map((r) => r.id), routes }); + } catch (err) { + console.error('[routes/test] error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/packages/server/src/app/api/settings/route.ts b/packages/server/src/app/api/settings/route.ts new file mode 100644 index 0000000..e56a397 --- /dev/null +++ b/packages/server/src/app/api/settings/route.ts @@ -0,0 +1,48 @@ +/** + * GET /api/settings — Get global settings + * PUT /api/settings — Update global settings + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getSettings, updateSettings } from '@/lib/db'; +import { verifyManagementAuth } from '@/lib/auth'; +import type { AppSettings } from '@/lib/db'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest): Promise { + const auth = verifyManagementAuth(request); + if (!auth.valid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const settings = await getSettings(); + return NextResponse.json({ settings }); + } catch (err) { + console.error('[settings] get error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest): Promise { + const auth = verifyManagementAuth(request); + if (!auth.valid) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + try { + const settings = await updateSettings(body as Partial); + return NextResponse.json({ settings }); + } catch (err) { + console.error('[settings] update error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/packages/server/src/app/api/threads/route.ts b/packages/server/src/app/api/threads/route.ts index 401f1c6..95ff6a2 100644 --- a/packages/server/src/app/api/threads/route.ts +++ b/packages/server/src/app/api/threads/route.ts @@ -1,9 +1,9 @@ /** - * GET /api/threads — List threads, filterable by channelId and targetId + * GET /api/threads — List threads, optionally filtered by channelId, targetId, and search */ import { NextRequest, NextResponse } from 'next/server'; -import { listThreadsByChannel } from '@/lib/db'; +import { listThreads } from '@/lib/db'; import { verifyManagementAuth } from '@/lib/auth'; export const runtime = 'nodejs'; @@ -14,18 +14,14 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const channelId = request.nextUrl.searchParams.get('channelId'); + const channelId = request.nextUrl.searchParams.get('channelId') ?? undefined; const targetId = request.nextUrl.searchParams.get('targetId') ?? undefined; - - if (!channelId) { - return NextResponse.json( - { error: 'Missing required query param: channelId' }, - { status: 400 }, - ); - } + const search = request.nextUrl.searchParams.get('search') ?? undefined; + const limitParam = request.nextUrl.searchParams.get('limit'); + const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 100; try { - const threads = await listThreadsByChannel(channelId, targetId); + const threads = await listThreads({ channelId, targetId, search, limit }); return NextResponse.json({ threads }); } catch (err) { console.error('[threads] list error:', err); diff --git a/packages/server/src/app/dashboard/channels/page.tsx b/packages/server/src/app/dashboard/channels/page.tsx new file mode 100644 index 0000000..2160e0c --- /dev/null +++ b/packages/server/src/app/dashboard/channels/page.tsx @@ -0,0 +1,541 @@ +'use client'; + +import { + CheckCircleOutlined, + CopyOutlined, + DeleteOutlined, + EditOutlined, + EyeInvisibleOutlined, + EyeOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Col, + Form, + Input, + message, + Modal, + Popconfirm, + Row, + Select, + Space, + Steps, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { useCallback, useEffect, useState } from 'react'; +import { channelApi } from '@/lib/api-client'; +import type { Channel } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +const PLATFORMS = [ + { value: 'slack', label: 'Slack' }, + { value: 'discord', label: 'Discord' }, + { value: 'telegram', label: 'Telegram' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'teams', label: 'Microsoft Teams' }, + { value: 'google-chat', label: 'Google Chat' }, +]; + +const PLATFORM_HINTS: Record = { + slack: 'e.g. vault:slack/bot-token or the environment variable name holding your Slack bot token', + discord: + 'e.g. vault:discord/bot-token or the environment variable name for your Discord bot token', + telegram: + 'e.g. vault:telegram/bot-token or the environment variable for your Telegram Bot API token', + whatsapp: 'e.g. vault:whatsapp/session or Baileys session reference', + teams: 'e.g. vault:teams/app-credentials', + 'google-chat': 'e.g. vault:google-chat/service-account', +}; + +function MaskedApiKey({ apiKey }: { apiKey: string }) { + const [visible, setVisible] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const masked = apiKey.slice(0, 10) + '•'.repeat(Math.max(0, apiKey.length - 10)); + + const copy = () => { + navigator.clipboard.writeText(apiKey).then(() => { + messageApi.success('API key copied'); + }); + }; + + return ( + <> + {contextHolder} + + + {visible ? apiKey : masked} + + + + + )} + {testResult === 'success' && ( + + + Credentials reference looks valid + + + )} + {testResult === 'error' && ( + + Test failed — check your credentials reference + + + )} + + )} + + +
+ + {step !== 'test' && ( + + )} +
+ + + ); +} + +function EditChannelModal({ + channel, + onClose, + onUpdated, +}: { + channel: Channel; + onClose: () => void; + onUpdated: (channel: Channel) => void; +}) { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + useEffect(() => { + form.setFieldsValue({ + credentialsRef: channel.credentialsRef, + metadata: channel.metadata ? JSON.stringify(channel.metadata, null, 2) : '', + }); + }, [channel, form]); + + const handleSave = async () => { + setSaving(true); + try { + const values = form.getFieldsValue() as { credentialsRef: string; metadata: string }; + let metadata: Record | undefined; + if (values.metadata) { + try { + metadata = JSON.parse(values.metadata) as Record; + } catch { + messageApi.error('Metadata must be valid JSON'); + setSaving(false); + return; + } + } + const updated = await channelApi.update(channel.id, { + credentialsRef: values.credentialsRef, + ...(metadata !== undefined ? { metadata } : {}), + }); + onUpdated(updated); + onClose(); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Update failed'); + } finally { + setSaving(false); + } + }; + + return ( + <> + {contextHolder} + +
+ + {channel.platform} + + + + + + + +
+
+ + ); +} + +export default function ChannelsPage() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [wizardOpen, setWizardOpen] = useState(false); + const [editChannel, setEditChannel] = useState(null); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(() => { + setLoading(true); + channelApi + .list() + .then(setChannels) + .catch(() => messageApi.error('Failed to load channels')) + .finally(() => setLoading(false)); + }, [messageApi]); + + useEffect(() => { + load(); + }, [load]); + + const handleDelete = async (id: string) => { + try { + await channelApi.delete(id); + messageApi.success('Channel deleted'); + setChannels((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + render: (id: string) => {id}, + }, + { + title: 'Platform', + dataIndex: 'platform', + key: 'platform', + render: (p: string) => {p}, + }, + { + title: 'Credentials Ref', + dataIndex: 'credentialsRef', + key: 'credentialsRef', + render: (ref: string) => ( + + {ref} + + ), + }, + { + title: 'API Key', + dataIndex: 'apiKey', + key: 'apiKey', + render: (key: string) => , + }, + { + title: 'Status', + key: 'status', + render: () => Unknown} />, + }, + { + title: 'Actions', + key: 'actions', + render: (_: unknown, record: Channel) => ( + + + handleDelete(record.id)} + okText="Delete" + okButtonProps={{ danger: true }} + > + + + + ), + }, + ]; + + return ( + <> + {contextHolder} + + + + Channels + + + + + + + + + + + + setWizardOpen(false)} + onCreated={(channel) => { + messageApi.success(`Channel "${channel.id}" created`); + setChannels((prev) => [...prev, channel]); + }} + /> + + {editChannel && ( + setEditChannel(null)} + onUpdated={(updated) => { + messageApi.success('Channel updated'); + setChannels((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + }} + /> + )} + + ); +} diff --git a/packages/server/src/app/dashboard/layout.tsx b/packages/server/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..a6d3074 --- /dev/null +++ b/packages/server/src/app/dashboard/layout.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { + ApiOutlined, + BranchesOutlined, + MessageOutlined, + SettingOutlined, + DashboardOutlined, +} from '@ant-design/icons'; +import { ConfigProvider, Layout, Menu, Typography, theme } from 'antd'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +const { Sider, Content } = Layout; +const { Text } = Typography; + +const NAV_ITEMS = [ + { + key: '/dashboard', + icon: , + label: Overview, + exact: true, + }, + { + key: '/dashboard/channels', + icon: , + label: Channels, + }, + { + key: '/dashboard/routes', + icon: , + label: Routes, + }, + { + key: '/dashboard/threads', + icon: , + label: Threads, + }, + { + key: '/dashboard/settings', + icon: , + label: Settings, + }, +]; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + const selectedKey = + NAV_ITEMS.find((item) => + item.exact + ? pathname === item.key + : pathname.startsWith(item.key) && item.key !== '/dashboard', + )?.key ?? (pathname === '/dashboard' ? '/dashboard' : ''); + + return ( + + + +
+ + OpenThreads + +
+ ({ key, icon, label }))} + /> + + + + {children} + + + + + ); +} diff --git a/packages/server/src/app/dashboard/page.tsx b/packages/server/src/app/dashboard/page.tsx new file mode 100644 index 0000000..99fa196 --- /dev/null +++ b/packages/server/src/app/dashboard/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { ApiOutlined, BranchesOutlined, MessageOutlined } from '@ant-design/icons'; +import { Card, Col, Row, Statistic, Typography } from 'antd'; +import { useEffect, useState } from 'react'; +import { channelApi, routeApi, threadApi } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +export default function DashboardOverview() { + const [channelCount, setChannelCount] = useState(null); + const [routeCount, setRouteCount] = useState(null); + const [threadCount, setThreadCount] = useState(null); + + useEffect(() => { + channelApi.list().then((c) => setChannelCount(c.length)).catch(() => setChannelCount(0)); + routeApi.list().then((r) => setRouteCount(r.length)).catch(() => setRouteCount(0)); + threadApi + .list({ limit: 1000 }) + .then((t) => setThreadCount(t.length)) + .catch(() => setThreadCount(0)); + }, []); + + return ( +
+ + Overview + + OpenThreads management dashboard + + +
+ + } + /> + + + + + } + /> + + + + + } + /> + + + + + ); +} diff --git a/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx b/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx new file mode 100644 index 0000000..c3d0ddf --- /dev/null +++ b/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx @@ -0,0 +1,336 @@ +'use client'; + +/** + * ReactFlow canvas for visualizing routes. + * Channel nodes → Route nodes → Recipient nodes + * + * This file is dynamically imported (no SSR) from the routes page. + */ + +import { + Background, + BackgroundVariant, + Controls, + Handle, + MiniMap, + Panel, + Position, + ReactFlow, + addEdge, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, + type NodeProps, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { Tag, Tooltip } from 'antd'; +import { useCallback, useEffect } from 'react'; +import type { Channel, Recipient, Route } from '@/lib/api-client'; + +// ─── Node data types ────────────────────────────────────────────────────────── + +interface ChannelNodeData extends Record { + channel: Channel; +} + +interface RecipientNodeData extends Record { + recipient: Recipient; +} + +interface RouteNodeData extends Record { + route: Route; + highlighted: boolean; + onEdit: (route: Route) => void; +} + +// ─── Custom node components ─────────────────────────────────────────────────── + +function ChannelNode({ data }: NodeProps) { + const { channel } = data as ChannelNodeData; + return ( +
+
CHANNEL
+
{channel.id}
+ + {channel.platform} + + +
+ ); +} + +function RecipientNode({ data }: NodeProps) { + const { recipient } = data as RecipientNodeData; + const shortUrl = recipient.webhookUrl.replace(/^https?:\/\//, '').slice(0, 28); + return ( +
+ +
RECIPIENT
+
{recipient.id}
+ +
{shortUrl}…
+
+
+ ); +} + +function RouteNode({ data }: NodeProps) { + const { route, highlighted, onEdit } = data as RouteNodeData; + const criteriaItems: string[] = []; + if (route.criteria.channelId) criteriaItems.push(`ch:${route.criteria.channelId}`); + if (route.criteria.isDm) criteriaItems.push('DM'); + if (route.criteria.isMention) criteriaItems.push('mention'); + if (route.criteria.senderId) criteriaItems.push(`from:${route.criteria.senderId}`); + if (criteriaItems.length === 0) criteriaItems.push('any'); + + return ( +
onEdit(route)} + style={{ + background: highlighted ? '#fffbe6' : '#fff7e6', + border: `2px solid ${highlighted ? '#52c41a' : '#fa8c16'}`, + borderRadius: 8, + padding: '10px 14px', + minWidth: 160, + cursor: 'pointer', + boxShadow: highlighted ? '0 0 0 3px rgba(82,196,26,0.3)' : undefined, + }} + > + +
+ ROUTE P{route.priority} +
+
{route.id}
+
+ {criteriaItems.map((item) => ( + + {item} + + ))} +
+ {!route.enabled && ( + + disabled + + )} + +
+ ); +} + +const NODE_TYPES = { + channel: ChannelNode, + recipient: RecipientNode, + route: RouteNode, +}; + +// ─── Layout helpers ─────────────────────────────────────────────────────────── + +const COL_X = { channel: 0, route: 320, recipient: 650 }; +const ROW_H = 140; +const PADDING_Y = 40; + +function buildGraph( + routes: Route[], + channels: Channel[], + recipients: Recipient[], + highlightedRouteIds: string[], + onEdit: (route: Route) => void, +): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + + channels.forEach((c, i) => { + nodes.push({ + id: `channel-${c.id}`, + type: 'channel', + position: { x: COL_X.channel, y: PADDING_Y + i * ROW_H }, + data: { channel: c }, + }); + }); + + recipients.forEach((r, i) => { + nodes.push({ + id: `recipient-${r.id}`, + type: 'recipient', + position: { x: COL_X.recipient, y: PADDING_Y + i * ROW_H }, + data: { recipient: r }, + }); + }); + + routes.forEach((route, i) => { + nodes.push({ + id: `route-${route.id}`, + type: 'route', + position: { x: COL_X.route, y: PADDING_Y + i * ROW_H }, + data: { + route, + highlighted: highlightedRouteIds.includes(route.id), + onEdit, + }, + }); + + // Edge: channel → route (if criteria.channelId is set) + if (route.criteria.channelId) { + const sourceId = `channel-${route.criteria.channelId}`; + if (nodes.some((n) => n.id === sourceId)) { + edges.push({ + id: `e-ch-${route.id}`, + source: sourceId, + target: `route-${route.id}`, + animated: highlightedRouteIds.includes(route.id), + style: { stroke: '#1677ff', strokeWidth: 2 }, + }); + } + } + + // Edge: route → recipient + const targetId = `recipient-${route.recipientId}`; + if (nodes.some((n) => n.id === targetId)) { + edges.push({ + id: `e-rt-${route.id}`, + source: `route-${route.id}`, + target: targetId, + animated: highlightedRouteIds.includes(route.id), + style: { stroke: '#52c41a', strokeWidth: 2 }, + }); + } + }); + + return { nodes, edges }; +} + +// ─── Main canvas component ─────────────────────────────────────────────────── + +interface RouteFlowCanvasProps { + routes: Route[]; + channels: Channel[]; + recipients: Recipient[]; + highlightedRouteIds: string[]; + onEditRoute: (route: Route) => void; + onCreateRoute: (defaults: Partial) => void; + onDeleteRoute: (id: string) => void; +} + +export default function RouteFlowCanvas({ + routes, + channels, + recipients, + highlightedRouteIds, + onEditRoute, + onCreateRoute, +}: RouteFlowCanvasProps) { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const onConnect = useCallback( + (params: Connection) => { + // Dragging from a channel node to a recipient node → open create drawer + if (params.source?.startsWith('channel-') && params.target?.startsWith('recipient-')) { + const channelId = params.source.replace('channel-', ''); + const recipientId = params.target.replace('recipient-', ''); + onCreateRoute({ + criteria: { channelId }, + recipientId, + priority: routes.length * 10 + 10, + }); + } + setEdges((eds) => addEdge(params, eds)); + }, + [routes, onCreateRoute, setEdges], + ); + + useEffect(() => { + const { nodes: n, edges: e } = buildGraph( + routes, + channels, + recipients, + highlightedRouteIds, + onEditRoute, + ); + setNodes(n); + setEdges(e); + }, [routes, channels, recipients, highlightedRouteIds, onEditRoute, setNodes, setEdges]); + + const isEmpty = routes.length === 0 && channels.length === 0 && recipients.length === 0; + + return ( +
+ + + + { + if (n.type === 'channel') return '#1677ff'; + if (n.type === 'recipient') return '#52c41a'; + return '#fa8c16'; + }} + /> + {isEmpty && ( + +
+ Add channels, recipients, and routes to see the flow visualization +
+
+ )} + {!isEmpty && routes.length === 0 && ( + +
+ Drag from a channel node to a recipient node to create a route +
+
+ )} +
+
+ ); +} diff --git a/packages/server/src/app/dashboard/routes/page.tsx b/packages/server/src/app/dashboard/routes/page.tsx new file mode 100644 index 0000000..8ca04c6 --- /dev/null +++ b/packages/server/src/app/dashboard/routes/page.tsx @@ -0,0 +1,520 @@ +'use client'; + +import { + DeleteOutlined, + EditOutlined, + ExperimentOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { + Alert, + Badge, + Button, + Card, + Checkbox, + Col, + Divider, + Drawer, + Form, + Input, + InputNumber, + message, + Popconfirm, + Row, + Select, + Space, + Tag, + Typography, +} from 'antd'; +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useState } from 'react'; +import { channelApi, recipientApi, routeApi } from '@/lib/api-client'; +import type { Channel, Recipient, Route, RouteCriteria, CreateRouteInput } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +// Lazy-load the ReactFlow editor to avoid SSR issues +const RouteFlowCanvas = dynamic(() => import('./RouteFlowCanvas'), { + ssr: false, + loading: () => ( +
+ Loading route editor… +
+ ), +}); + +const CRITERIA_LABELS: Record = { + channelId: 'Channel', + groupId: 'Group', + isDm: 'Direct Message', + nativeThreadId: 'Native Thread', + isMention: 'Mention', + senderId: 'Sender', + contentPattern: 'Content Pattern (regex)', +}; + +function criteriaToTags(criteria: RouteCriteria): string[] { + const tags: string[] = []; + if (criteria.channelId) tags.push(`channel:${criteria.channelId}`); + if (criteria.groupId) tags.push(`group:${criteria.groupId}`); + if (criteria.isDm) tags.push('DM'); + if (criteria.isMention) tags.push('mention'); + if (criteria.senderId) tags.push(`sender:${criteria.senderId}`); + if (criteria.contentPattern) tags.push(`pattern:${criteria.contentPattern}`); + if (tags.length === 0) tags.push('any'); + return tags; +} + +function RouteForm({ + initial, + channels, + recipients, + onSave, + onCancel, + saving, +}: { + initial?: Partial; + channels: Channel[]; + recipients: Recipient[]; + onSave: (values: CreateRouteInput) => void; + onCancel: () => void; + saving: boolean; +}) { + const [form] = Form.useForm(); + + useEffect(() => { + if (initial) { + form.setFieldsValue({ + id: initial.id, + priority: initial.priority ?? 10, + recipientId: initial.recipientId, + enabled: initial.enabled ?? true, + channelId: initial.criteria?.channelId, + groupId: initial.criteria?.groupId, + isDm: initial.criteria?.isDm, + isMention: initial.criteria?.isMention, + senderId: initial.criteria?.senderId, + contentPattern: initial.criteria?.contentPattern, + nativeThreadId: initial.criteria?.nativeThreadId, + }); + } else { + form.setFieldsValue({ priority: 10, enabled: true }); + } + }, [initial, form]); + + const handleFinish = (values: Record) => { + const criteria: RouteCriteria = {}; + if (values.channelId) criteria.channelId = values.channelId as string; + if (values.groupId) criteria.groupId = values.groupId as string; + if (values.isDm) criteria.isDm = true; + if (values.isMention) criteria.isMention = true; + if (values.senderId) criteria.senderId = values.senderId as string; + if (values.contentPattern) criteria.contentPattern = values.contentPattern as string; + if (values.nativeThreadId) criteria.nativeThreadId = values.nativeThreadId as string; + + onSave({ + id: values.id as string, + recipientId: values.recipientId as string, + priority: values.priority as number, + enabled: (values.enabled as boolean) ?? true, + criteria, + }); + }; + + return ( +
+ + + + + + + + + + ({ value: c.id, label: `${c.id} (${c.platform})` }))} + /> + + + + + + + +
+ + {CRITERIA_LABELS.isDm} + + + + + {CRITERIA_LABELS.isMention} + + + + + + + + + + + + + + + + +
+ + +
+ + ); +} + +function TestRoutePanel({ + open, + channels, + onClose, + onResult, +}: { + open: boolean; + channels: Channel[]; + onClose: () => void; + onResult: (matchingIds: string[]) => void; +}) { + const [form] = Form.useForm(); + const [testing, setTesting] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const handleTest = async () => { + setTesting(true); + try { + const values = form.getFieldsValue() as Record; + const criteria: Partial = {}; + if (values.channelId) criteria.channelId = values.channelId as string; + if (values.isDm) criteria.isDm = true; + if (values.isMention) criteria.isMention = true; + if (values.senderId) criteria.senderId = values.senderId as string; + + const result = await routeApi.test(criteria); + onResult(result.matchingRouteIds); + if (result.matchingRouteIds.length === 0) { + messageApi.info('No routes matched this message'); + } else { + messageApi.success( + `${result.matchingRouteIds.length} route(s) matched — highlighted in canvas`, + ); + } + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Test failed'); + } finally { + setTesting(false); + } + }; + + return ( + <> + {contextHolder} + } + > + Run Test + + } + > + +
+ + + + +
+ + ); +} + +export default function RoutesPage() { + const [routes, setRoutes] = useState([]); + const [channels, setChannels] = useState([]); + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(true); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editRoute, setEditRoute] = useState(null); + const [saving, setSaving] = useState(false); + const [testPanelOpen, setTestPanelOpen] = useState(false); + const [highlightedRouteIds, setHighlightedRouteIds] = useState([]); + const [newRouteDefaults, setNewRouteDefaults] = useState>({}); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(() => { + setLoading(true); + Promise.all([routeApi.list(), channelApi.list(), recipientApi.list()]) + .then(([r, c, rec]) => { + setRoutes(r); + setChannels(c); + setRecipients(rec); + }) + .catch(() => messageApi.error('Failed to load data')) + .finally(() => setLoading(false)); + }, [messageApi]); + + useEffect(() => { + load(); + }, [load]); + + const openCreateDrawer = (defaults?: Partial) => { + setEditRoute(null); + setNewRouteDefaults(defaults ?? {}); + setDrawerOpen(true); + }; + + const openEditDrawer = (route: Route) => { + setEditRoute(route); + setNewRouteDefaults({}); + setDrawerOpen(true); + }; + + const handleSave = async (values: CreateRouteInput) => { + setSaving(true); + try { + if (editRoute) { + const updated = await routeApi.update(editRoute.id, { + criteria: values.criteria, + recipientId: values.recipientId, + priority: values.priority, + enabled: values.enabled, + }); + setRoutes((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + messageApi.success('Route updated'); + } else { + const created = await routeApi.create(values); + setRoutes((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); + messageApi.success('Route created'); + } + setDrawerOpen(false); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + await routeApi.delete(id); + setRoutes((prev) => prev.filter((r) => r.id !== id)); + messageApi.success('Route deleted'); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + return ( + <> + {contextHolder} + +
+ + Routes + + + + + + + + + + + {/* ReactFlow Canvas */} + + openCreateDrawer(defaults)} + onDeleteRoute={handleDelete} + /> + + + {/* Route List (priority-ordered) */} + + {routes.length === 0 ? ( + + No routes defined. Create one using “New Route”. + + ) : ( + + {routes.map((route, index) => ( + + + + + + {route.id} + {!route.enabled && Disabled} + {criteriaToTags(route.criteria).map((tag) => ( + + {tag} + + ))} + + {route.recipientId} + + + + + + + + {channel.id} + + + {channel.platform} + + + + + + TTL: {ttlLabel(effectiveTtl)} + + {override?.tokenTtlSeconds !== undefined && ( + + override + + )} + + + + Trust: {effectiveTrust ? 'on' : 'off'} + + {override?.trustLayerEnabled !== undefined && ( + + override + + )} + + + + + + {hasOverride && ( + + )} + + + + {override?.tokenTtlSeconds !== undefined && ( + + + + Override TTL: + + + + + + + + + + + + + + + + + + + + + + Current effective settings + +
+ + Token TTL: {ttlLabel(settings.tokenTtlSeconds)} + +
+ + Trust Layer:{' '} + + {settings.trustLayerEnabled ? 'Enabled' : 'Disabled'} + + +
+
+ + + + + Environment variables + +
+ + REPLY_TOKEN_TTL — Token TTL (seconds, overrides DB setting) + +
+ + MANAGEMENT_API_KEY — Management API authentication key + +
+
+ + + + + {/* Per-Channel Overrides */} + + {channels.length === 0 ? ( + + No channels registered. Add channels first to configure per-channel overrides. + + ) : ( + <> + + Override global settings for individual channels. Changes take effect immediately. + + {channels.map((channel) => ( + + ))} + + )} + + + ); +} diff --git a/packages/server/src/app/dashboard/threads/[threadId]/page.tsx b/packages/server/src/app/dashboard/threads/[threadId]/page.tsx new file mode 100644 index 0000000..584986a --- /dev/null +++ b/packages/server/src/app/dashboard/threads/[threadId]/page.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { ArrowLeftOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Col, + Collapse, + Descriptions, + Row, + Space, + Spin, + Tag, + Timeline, + Typography, +} from 'antd'; +import { useRouter, useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { threadApi } from '@/lib/api-client'; +import type { Thread, Turn } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +function MessageView({ message }: { message: Record }) { + const isA2H = 'intent' in message; + + if (isA2H) { + const intent = message.intent as string; + const context = message.context as Record | undefined; + return ( +
+ + + + A2H Intent: + + {intent} + + {context && ( +
+            {JSON.stringify(context, null, 2)}
+          
+ )} +
+ ); + } + + const text = message.text as string | undefined; + return ( +
+ {text ?? JSON.stringify(message)} +
+ ); +} + +function TurnCard({ turn }: { turn: Turn }) { + const isInbound = turn.direction === 'inbound'; + const messages = Array.isArray(turn.message) ? turn.message : [turn.message]; + + return ( + + +
+ + + {isInbound ? '\u2190 inbound' : '\u2192 outbound'} + + + {turn.turnId} + + + + + + {new Date(turn.timestamp).toLocaleString()} + + + + +
+ {messages.map((msg, i) => ( + } /> + ))} +
+ + + Raw envelope + + ), + children: ( +
+                {JSON.stringify(turn, null, 2)}
+              
+ ), + }, + ]} + /> + + ); +} + +export default function ThreadDetailPage() { + const router = useRouter(); + const params = useParams<{ threadId: string }>(); + const threadId = params.threadId; + + const [thread, setThread] = useState(null); + const [turns, setTurns] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>(new Set()); + + useEffect(() => { + if (!threadId) return; + setLoading(true); + Promise.all([threadApi.get(threadId), threadApi.turns(threadId)]) + .then(([t, fetchedTurns]) => { + setThread(t); + setTurns(fetchedTurns); + }) + .catch(() => { + setThread(null); + setTurns([]); + }) + .finally(() => setLoading(false)); + }, [threadId]); + + const toggleExpand = (turnId: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(turnId)) next.delete(turnId); + else next.add(turnId); + return next; + }); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!thread) { + return ( +
+ + + Thread not found. + +
+ ); + } + + return ( + <> + +
+ + + + + Thread Detail + + + + + + + + + {thread.threadId} + + + + {thread.channelId} + + + + {thread.targetId} + + + + {thread.nativeThreadId ? ( + + {thread.nativeThreadId} + + ) : ( + virtual + )} + + + {new Date(thread.createdAt).toLocaleString()} + + + + + + + + + Turn Log{' '} + <Text type="secondary" style={{ fontWeight: 400, fontSize: 14 }}> + ({turns.length} turns, chronological) + </Text> + + + {turns.length === 0 ? ( + + No turns recorded for this thread. + + ) : ( + ({ + key: turn.turnId, + color: turn.direction === 'inbound' ? 'blue' : 'green', + label: ( + + {new Date(turn.timestamp).toLocaleTimeString()} + + ), + children: ( +
+
toggleExpand(turn.turnId)} + > + + + {turn.direction === 'inbound' ? '\u2190 inbound' : '\u2192 outbound'} + + + {turn.turnId} + + {expanded.has(turn.turnId) ? ( + + ) : ( + + )} + +
+ {expanded.has(turn.turnId) && ( +
+ +
+ )} + {!expanded.has(turn.turnId) && ( +
+ {(() => { + const msgs = Array.isArray(turn.message) + ? turn.message + : [turn.message]; + const first = msgs[0] as Record; + if ('intent' in first) return `[A2H: ${first.intent as string}]`; + return (first.text as string) ?? JSON.stringify(first).slice(0, 80); + })()} +
+ )} +
+ ), + }))} + /> + )} + + ); +} diff --git a/packages/server/src/app/dashboard/threads/page.tsx b/packages/server/src/app/dashboard/threads/page.tsx new file mode 100644 index 0000000..ac2a03e --- /dev/null +++ b/packages/server/src/app/dashboard/threads/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { SearchOutlined } from '@ant-design/icons'; +import { Button, Card, Col, Input, Row, Select, Space, Table, Tag, Typography } from 'antd'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { channelApi, threadApi } from '@/lib/api-client'; +import type { Channel, Thread } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +export default function ThreadsPage() { + const router = useRouter(); + const [threads, setThreads] = useState([]); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(false); + const [channelFilter, setChannelFilter] = useState(undefined); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + useEffect(() => { + channelApi + .list() + .then(setChannels) + .catch(() => {}); + }, []); + + const load = useCallback(() => { + setLoading(true); + threadApi + .list({ channelId: channelFilter, search: search || undefined, limit: 100 }) + .then(setThreads) + .catch(() => setThreads([])) + .finally(() => setLoading(false)); + }, [channelFilter, search]); + + useEffect(() => { + load(); + }, [load]); + + const handleSearch = () => { + setSearch(searchInput); + }; + + const columns = [ + { + title: 'Thread ID', + dataIndex: 'threadId', + key: 'threadId', + render: (id: string) => ( + + ), + }, + { + title: 'Channel', + dataIndex: 'channelId', + key: 'channelId', + render: (id: string) => {id}, + }, + { + title: 'Target', + dataIndex: 'targetId', + key: 'targetId', + render: (id: string) => ( + + {id} + + ), + }, + { + title: 'Native Thread', + dataIndex: 'nativeThreadId', + key: 'nativeThreadId', + render: (id: string | null) => + id ? ( + + {id} + + ) : ( + + virtual + + ), + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + render: (d: string | Date) => ( + + {new Date(d).toLocaleString()} + + ), + }, + { + title: '', + key: 'actions', + render: (_: unknown, record: Thread) => ( + + ), + }, + ]; + + return ( + <> + +
+ + Threads + + + + + + + setSearchInput(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 300 }} + suffix={ + + + + + +
({ + style: { cursor: 'pointer' }, + onClick: () => router.push(`/dashboard/threads/${record.threadId}`), + })} + /> + + + ); +} diff --git a/packages/server/src/app/page.tsx b/packages/server/src/app/page.tsx index 9d6c137..f889cb6 100644 --- a/packages/server/src/app/page.tsx +++ b/packages/server/src/app/page.tsx @@ -1,8 +1,5 @@ +import { redirect } from 'next/navigation'; + export default function Home() { - return ( -
-

OpenThreads

-

Unified communication channel abstraction with human-in-the-loop support.

-
- ) + redirect('/dashboard'); } diff --git a/packages/server/src/lib/api-client.ts b/packages/server/src/lib/api-client.ts new file mode 100644 index 0000000..7ed3919 --- /dev/null +++ b/packages/server/src/lib/api-client.ts @@ -0,0 +1,166 @@ +/** + * Client-side API helpers for the OpenThreads management dashboard. + * + * In development (no MANAGEMENT_API_KEY set), the server bypasses auth. + * In production, set NEXT_PUBLIC_MANAGEMENT_API_KEY to authenticate. + */ + +import type { Channel, CreateChannelInput } from '@openthreads/core'; +import type { Route, CreateRouteInput, RouteCriteria } from '@openthreads/core'; +import type { Recipient, CreateRecipientInput } from '@openthreads/core'; +import type { Thread } from '@openthreads/core'; +import type { Turn } from '@openthreads/core'; + +// ─── Settings types (mirrored from lib/db to avoid server-only import) ──────── + +export interface ChannelOverride { + tokenTtlSeconds?: number; + trustLayerEnabled?: boolean; +} + +export interface AppSettings { + tokenTtlSeconds: number; + trustLayerEnabled: boolean; + perChannelOverrides: Record; +} + +// Re-export core types for convenience in client components +export type { Channel, Route, RouteCriteria, Recipient, Thread, Turn }; +export type { CreateChannelInput, CreateRouteInput, CreateRecipientInput }; + +function buildHeaders(): HeadersInit { + const h: Record = { 'Content-Type': 'application/json' }; + const key = + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_MANAGEMENT_API_KEY ?? '') + : ''; + if (key) h['Authorization'] = `Bearer ${key}`; + return h; +} + +async function apiFetch(path: string, options?: RequestInit): Promise { + const res = await fetch(path, { + ...options, + headers: { ...buildHeaders(), ...(options?.headers ?? {}) }, + }); + if (res.status === 204) return undefined as T; + const data = await res.json(); + if (!res.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`); + return data as T; +} + +// ─── Channels ───────────────────────────────────────────────────────────────── + +export const channelApi = { + list: () => + apiFetch<{ channels: Channel[] }>('/api/channels').then((r) => r.channels), + + get: (id: string) => + apiFetch<{ channel: Channel }>(`/api/channels/${id}`).then((r) => r.channel), + + create: (input: Omit) => + apiFetch<{ channel: Channel }>('/api/channels', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.channel), + + update: (id: string, input: Partial) => + apiFetch<{ channel: Channel }>(`/api/channels/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.channel), + + delete: (id: string) => + apiFetch(`/api/channels/${id}`, { method: 'DELETE' }), +}; + +// ─── Recipients ─────────────────────────────────────────────────────────────── + +export const recipientApi = { + list: () => + apiFetch<{ recipients: Recipient[] }>('/api/recipients').then((r) => r.recipients), + + get: (id: string) => + apiFetch<{ recipient: Recipient }>(`/api/recipients/${id}`).then((r) => r.recipient), + + create: (input: CreateRecipientInput) => + apiFetch<{ recipient: Recipient }>('/api/recipients', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.recipient), + + update: (id: string, input: Partial) => + apiFetch<{ recipient: Recipient }>(`/api/recipients/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.recipient), + + delete: (id: string) => + apiFetch(`/api/recipients/${id}`, { method: 'DELETE' }), +}; + +// ─── Routes ─────────────────────────────────────────────────────────────────── + +export const routeApi = { + list: () => + apiFetch<{ routes: Route[] }>('/api/routes').then((r) => r.routes), + + get: (id: string) => + apiFetch<{ route: Route }>(`/api/routes/${id}`).then((r) => r.route), + + create: (input: CreateRouteInput) => + apiFetch<{ route: Route }>('/api/routes', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.route), + + update: (id: string, input: Partial) => + apiFetch<{ route: Route }>(`/api/routes/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.route), + + delete: (id: string) => + apiFetch(`/api/routes/${id}`, { method: 'DELETE' }), + + test: (criteria: Partial) => + apiFetch<{ matchingRouteIds: string[]; routes: Route[] }>('/api/routes/test', { + method: 'POST', + body: JSON.stringify(criteria), + }), +}; + +// ─── Threads ────────────────────────────────────────────────────────────────── + +export const threadApi = { + list: (params?: { channelId?: string; targetId?: string; search?: string; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.channelId) qs.set('channelId', params.channelId); + if (params?.targetId) qs.set('targetId', params.targetId); + if (params?.search) qs.set('search', params.search); + if (params?.limit) qs.set('limit', String(params.limit)); + const query = qs.toString() ? `?${qs.toString()}` : ''; + return apiFetch<{ threads: Thread[] }>(`/api/threads${query}`).then((r) => r.threads); + }, + + get: (threadId: string) => + apiFetch<{ thread: Thread }>(`/api/threads/${threadId}`).then((r) => r.thread), + + turns: (threadId: string) => + apiFetch<{ threadId: string; turns: Turn[] }>(`/api/threads/${threadId}/turns`).then( + (r) => r.turns, + ), +}; + +// ─── Settings ───────────────────────────────────────────────────────────────── + +export const settingsApi = { + get: () => + apiFetch<{ settings: AppSettings }>('/api/settings').then((r) => r.settings), + + update: (settings: Partial) => + apiFetch<{ settings: AppSettings }>('/api/settings', { + method: 'PUT', + body: JSON.stringify(settings), + }).then((r) => r.settings), +}; diff --git a/packages/server/src/lib/db.ts b/packages/server/src/lib/db.ts index f92bedd..c9f50e2 100644 --- a/packages/server/src/lib/db.ts +++ b/packages/server/src/lib/db.ts @@ -204,6 +204,30 @@ export async function listThreadsByChannel( return (await coll.find(query as Filter).sort({ createdAt: -1 }).toArray()) as Thread[]; } +export async function listThreads(options?: { + channelId?: string; + targetId?: string; + search?: string; + limit?: number; + skip?: number; +}): Promise { + const coll = await col('threads'); + const query: Record = {}; + if (options?.channelId) query['channelId'] = options.channelId; + if (options?.targetId) query['targetId'] = options.targetId; + if (options?.search) { + query['$or'] = [ + { threadId: { $regex: options.search, $options: 'i' } }, + { targetId: { $regex: options.search, $options: 'i' } }, + { channelId: { $regex: options.search, $options: 'i' } }, + ]; + } + let cursor = coll.find(query as Filter).sort({ createdAt: -1 }); + if (options?.skip) cursor = cursor.skip(options.skip); + if (options?.limit) cursor = cursor.limit(options.limit); + return (await cursor.toArray()) as Thread[]; +} + // ─── Turns ──────────────────────────────────────────────────────────────────── export async function createTurn(input: CreateTurnInput): Promise {