diff --git a/modules/rschedule/components/automation-canvas.css b/modules/rschedule/components/automation-canvas.css new file mode 100644 index 0000000..ab0f850 --- /dev/null +++ b/modules/rschedule/components/automation-canvas.css @@ -0,0 +1,551 @@ +/* rSchedule Automation Canvas — n8n-style workflow builder */ +folk-automation-canvas { + display: block; + height: calc(100vh - 60px); +} + +.ac-root { + display: flex; + flex-direction: column; + height: 100%; + font-family: system-ui, -apple-system, sans-serif; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── Toolbar ── */ +.ac-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + min-height: 46px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + z-index: 10; +} + +.ac-toolbar__title { + font-size: 15px; + font-weight: 600; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.ac-toolbar__title input { + background: transparent; + border: 1px solid transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 15px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + width: 200px; +} + +.ac-toolbar__title input:hover, +.ac-toolbar__title input:focus { + border-color: var(--rs-border-strong, #3d3d5c); + outline: none; +} + +.ac-toolbar__actions { + display: flex; + gap: 6px; + align-items: center; +} + +.ac-btn { + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} + +.ac-btn:hover { + border-color: var(--rs-border-strong, #4d4d6c); +} + +.ac-btn--run { + background: #3b82f622; + border-color: #3b82f655; + color: #60a5fa; +} + +.ac-btn--run:hover { + background: #3b82f633; + border-color: #3b82f6; +} + +.ac-btn--save { + background: #10b98122; + border-color: #10b98155; + color: #34d399; +} + +.ac-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-toggle input[type="checkbox"] { + accent-color: #10b981; +} + +.ac-save-indicator { + font-size: 11px; + color: var(--rs-text-muted, #64748b); +} + +/* ── Canvas area ── */ +.ac-canvas-area { + flex: 1; + display: flex; + overflow: hidden; + position: relative; +} + +/* ── Left sidebar — node palette ── */ +.ac-palette { + width: 200px; + min-width: 200px; + border-right: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ac-palette__group-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 4px; +} + +.ac-palette__card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-input-bg, #16162a); + cursor: grab; + font-size: 12px; + transition: border-color 0.15s, background 0.15s; + margin-bottom: 4px; +} + +.ac-palette__card:hover { + border-color: #6366f1; + background: #6366f111; +} + +.ac-palette__card:active { + cursor: grabbing; +} + +.ac-palette__card-icon { + font-size: 16px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.ac-palette__card-label { + font-weight: 500; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── SVG canvas ── */ +.ac-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background: var(--rs-canvas-bg, #0f0f23); +} + +.ac-canvas.grabbing { + cursor: grabbing; +} + +.ac-canvas.wiring { + cursor: crosshair; +} + +.ac-canvas svg { + display: block; + width: 100%; + height: 100%; +} + +/* ── Right sidebar — config ── */ +.ac-config { + width: 0; + overflow: hidden; + border-left: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + display: flex; + flex-direction: column; + transition: width 0.2s ease; +} + +.ac-config.open { + width: 280px; + min-width: 280px; +} + +.ac-config__header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + font-weight: 600; + font-size: 13px; +} + +.ac-config__header-close { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 16px; + cursor: pointer; + margin-left: auto; + padding: 2px; +} + +.ac-config__body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; +} + +.ac-config__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-config__field label { + font-size: 11px; + font-weight: 500; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-config__field input, +.ac-config__field select, +.ac-config__field textarea { + width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-family: inherit; + box-sizing: border-box; +} + +.ac-config__field textarea { + resize: vertical; + min-height: 60px; +} + +.ac-config__field input:focus, +.ac-config__field select:focus, +.ac-config__field textarea:focus { + border-color: #3b82f6; + outline: none; +} + +.ac-config__delete { + margin-top: 12px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #ef444455; + background: #ef444422; + color: #f87171; + font-size: 12px; + cursor: pointer; +} + +.ac-config__delete:hover { + background: #ef444433; +} + +/* ── Execution log in config panel ── */ +.ac-exec-log { + margin-top: 12px; + border-top: 1px solid var(--rs-border, #2d2d44); + padding-top: 12px; +} + +.ac-exec-log__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 8px; +} + +.ac-exec-log__entry { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-exec-log__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-exec-log__dot.success { background: #22c55e; } +.ac-exec-log__dot.error { background: #ef4444; } +.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; } + +@keyframes ac-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Zoom controls ── */ +.ac-zoom-controls { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 4px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border-strong, #3d3d5c); + border-radius: 8px; + padding: 4px 6px; + z-index: 5; +} + +.ac-zoom-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.ac-zoom-btn:hover { + background: var(--rs-bg-surface-raised, #252545); +} + +.ac-zoom-level { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + min-width: 36px; + text-align: center; +} + +/* ── Node styles in SVG ── */ +.ac-node { cursor: pointer; } +.ac-node.selected > foreignObject > div { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +.ac-node foreignObject > div { + border-radius: 10px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow: hidden; + font-size: 12px; + transition: border-color 0.15s; +} + +.ac-node:hover foreignObject > div { + border-color: #4f46e5 !important; +} + +.ac-node-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + cursor: move; +} + +.ac-node-icon { + font-size: 14px; +} + +.ac-node-label { + font-weight: 600; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ac-node-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-node-status.idle { background: #4b5563; } +.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; } +.ac-node-status.success { background: #22c55e; } +.ac-node-status.error { background: #ef4444; } + +.ac-node-ports { + padding: 6px 10px; + display: flex; + justify-content: space-between; + min-height: 28px; +} + +.ac-node-inputs, +.ac-node-outputs { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-node-outputs { + align-items: flex-end; +} + +.ac-port-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Port handles in SVG ── */ +.ac-port-group { cursor: crosshair; } +.ac-port-dot { + transition: r 0.15s, filter 0.15s; +} + +.ac-port-group:hover .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 4px currentColor); +} + +.ac-port-group--wiring-source .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 6px currentColor); +} + +.ac-port-group--wiring-target .ac-port-dot { + r: 7; + animation: port-pulse 0.8s ease-in-out infinite; +} + +.ac-port-group--wiring-dimmed .ac-port-dot { + opacity: 0.2; +} + +@keyframes port-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px currentColor); } + 50% { filter: drop-shadow(0 0 8px currentColor); } +} + +/* ── Edge styles ── */ +.ac-edge-group { pointer-events: stroke; } + +.ac-edge-path { + fill: none; + stroke-width: 2; +} + +.ac-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 16; + cursor: pointer; +} + +.ac-edge-path.running { + animation: ac-edge-flow 1s linear infinite; + stroke-dasharray: 8 4; +} + +@keyframes ac-edge-flow { + to { stroke-dashoffset: -24; } +} + +/* ── Wiring temp line ── */ +.ac-wiring-temp { + fill: none; + stroke: #6366f1; + stroke-width: 2; + stroke-dasharray: 6 4; + opacity: 0.7; + pointer-events: none; +} + +/* ── Workflow selector ── */ +.ac-workflow-select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .ac-palette { + width: 160px; + min-width: 160px; + padding: 8px; + } + + .ac-config.open { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + z-index: 20; + min-width: unset; + } + + .ac-toolbar { + flex-wrap: wrap; + padding: 8px 12px; + } +} diff --git a/modules/rschedule/components/folk-automation-canvas.ts b/modules/rschedule/components/folk-automation-canvas.ts new file mode 100644 index 0000000..76d9d45 --- /dev/null +++ b/modules/rschedule/components/folk-automation-canvas.ts @@ -0,0 +1,1007 @@ +/** + * — n8n-style automation workflow builder for rSchedule. + * + * Renders workflow nodes (triggers, conditions, actions) on an SVG canvas + * with ports, Bezier wiring, node palette, config panel, and REST persistence. + * + * Attributes: + * space — space slug (default "demo") + */ + +import { NODE_CATALOG } from '../schemas'; +import type { AutomationNodeDef, AutomationNodeCategory, WorkflowNode, WorkflowEdge, Workflow } from '../schemas'; + +// ── Constants ── + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 80; +const PORT_RADIUS = 5; + +const CATEGORY_COLORS: Record = { + trigger: '#3b82f6', + condition: '#f59e0b', + action: '#10b981', +}; + +const PORT_COLORS: Record = { + trigger: '#ef4444', + data: '#3b82f6', + boolean: '#f59e0b', +}; + +// ── Helpers ── + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; +} + +function getNodeDef(type: string): AutomationNodeDef | undefined { + return NODE_CATALOG.find(n => n.type === type); +} + +function getPortX(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number { + return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH; +} + +function getPortY(node: WorkflowNode, portName: string, direction: 'input' | 'output'): number { + const def = getNodeDef(node.type); + if (!def) return node.position.y + NODE_HEIGHT / 2; + const ports = direction === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + if (idx === -1) return node.position.y + NODE_HEIGHT / 2; + const spacing = NODE_HEIGHT / (ports.length + 1); + return node.position.y + spacing * (idx + 1); +} + +function bezierPath(x1: number, y1: number, x2: number, y2: number): string { + const dx = Math.abs(x2 - x1) * 0.5; + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; +} + +// ── Component ── + +class FolkAutomationCanvas extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + + private get basePath() { + const host = window.location.hostname; + if (host.endsWith('.rspace.online')) return '/rschedule/'; + return `/${this.space}/rschedule/`; + } + + // Data + private workflows: Workflow[] = []; + private currentWorkflowId = ''; + private nodes: WorkflowNode[] = []; + private edges: WorkflowEdge[] = []; + private workflowName = 'New Workflow'; + private workflowEnabled = true; + + // Canvas state + private canvasZoom = 1; + private canvasPanX = 0; + private canvasPanY = 0; + + // Interaction + private isPanning = false; + private panStartX = 0; + private panStartY = 0; + private panStartPanX = 0; + private panStartPanY = 0; + private draggingNodeId: string | null = null; + private dragStartX = 0; + private dragStartY = 0; + private dragNodeStartX = 0; + private dragNodeStartY = 0; + + // Selection & config + private selectedNodeId: string | null = null; + private configOpen = false; + + // Wiring + private wiringActive = false; + private wiringSourceNodeId: string | null = null; + private wiringSourcePortName: string | null = null; + private wiringSourceDir: 'input' | 'output' | null = null; + private wiringPointerX = 0; + private wiringPointerY = 0; + + // Persistence + private saveTimer: ReturnType | null = null; + private saveIndicator = ''; + + // Execution log + private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = []; + + // Bound listeners + private _boundPointerMove: ((e: PointerEvent) => void) | null = null; + private _boundPointerUp: ((e: PointerEvent) => void) | null = null; + private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.space = this.getAttribute('space') || 'demo'; + this.initData(); + } + + disconnectedCallback() { + if (this.saveTimer) clearTimeout(this.saveTimer); + if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); + if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); + if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + } + + // ── Data init ── + + private async initData() { + try { + const res = await fetch(`${this.basePath}api/workflows`); + if (res.ok) { + const data = await res.json(); + this.workflows = data.results || []; + if (this.workflows.length > 0) { + this.loadWorkflow(this.workflows[0]); + } + } + } catch { + console.warn('[AutomationCanvas] Failed to load workflows'); + } + this.render(); + requestAnimationFrame(() => this.fitView()); + } + + private loadWorkflow(wf: Workflow) { + this.currentWorkflowId = wf.id; + this.workflowName = wf.name; + this.workflowEnabled = wf.enabled; + this.nodes = wf.nodes.map(n => ({ ...n, position: { ...n.position } })); + this.edges = wf.edges.map(e => ({ ...e })); + this.selectedNodeId = null; + this.configOpen = false; + this.execLog = []; + } + + // ── Persistence ── + + private scheduleSave() { + this.saveIndicator = 'Saving...'; + this.updateSaveIndicator(); + if (this.saveTimer) clearTimeout(this.saveTimer); + this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500); + } + + private async executeSave() { + if (!this.currentWorkflowId) return; + try { + await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: this.workflowName, + enabled: this.workflowEnabled, + nodes: this.nodes, + edges: this.edges, + }), + }); + this.saveIndicator = 'Saved'; + } catch { + this.saveIndicator = 'Save failed'; + } + this.updateSaveIndicator(); + setTimeout(() => { this.saveIndicator = ''; this.updateSaveIndicator(); }, 2000); + } + + private async createWorkflow() { + try { + const res = await fetch(`${this.basePath}api/workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Workflow' }), + }); + if (res.ok) { + const wf = await res.json(); + this.workflows.push(wf); + this.loadWorkflow(wf); + this.render(); + requestAnimationFrame(() => this.fitView()); + } + } catch { + console.error('[AutomationCanvas] Failed to create workflow'); + } + } + + private async deleteWorkflow() { + if (!this.currentWorkflowId) return; + try { + await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}`, { method: 'DELETE' }); + this.workflows = this.workflows.filter(w => w.id !== this.currentWorkflowId); + if (this.workflows.length > 0) { + this.loadWorkflow(this.workflows[0]); + } else { + this.currentWorkflowId = ''; + this.nodes = []; + this.edges = []; + this.workflowName = ''; + } + this.render(); + } catch { + console.error('[AutomationCanvas] Failed to delete workflow'); + } + } + + // ── Canvas transform ── + + private updateCanvasTransform() { + const g = this.shadow.getElementById('canvas-transform'); + if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + this.updateZoomDisplay(); + } + + private updateZoomDisplay() { + const el = this.shadow.getElementById('zoom-level'); + if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; + } + + private updateSaveIndicator() { + const el = this.shadow.getElementById('save-indicator'); + if (el) el.textContent = this.saveIndicator; + } + + private fitView() { + const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null; + if (!svg || this.nodes.length === 0) return; + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of this.nodes) { + minX = Math.min(minX, n.position.x); + minY = Math.min(minY, n.position.y); + maxX = Math.max(maxX, n.position.x + NODE_WIDTH); + maxY = Math.max(maxY, n.position.y + NODE_HEIGHT); + } + + const pad = 60; + const contentW = maxX - minX + pad * 2; + const contentH = maxY - minY + pad * 2; + const scaleX = rect.width / contentW; + const scaleY = rect.height / contentH; + this.canvasZoom = Math.min(scaleX, scaleY, 1.5); + this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom; + this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom; + this.updateCanvasTransform(); + } + + private zoomAt(screenX: number, screenY: number, factor: number) { + const oldZoom = this.canvasZoom; + const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); + this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); + this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); + this.canvasZoom = newZoom; + this.updateCanvasTransform(); + } + + // ── Rendering ── + + private render() { + const paletteGroups = ['trigger', 'condition', 'action'] as AutomationNodeCategory[]; + + this.shadow.innerHTML = ` + +
+
+
+ Automations + + +
+
+
+ + +
+ ${this.saveIndicator} + + + +
+
+ +
+
+ ${paletteGroups.map(cat => ` +
+
${cat}s
+ ${NODE_CATALOG.filter(n => n.category === cat).map(n => ` +
+ ${n.icon} + ${esc(n.label)} +
+ `).join('')} +
+ `).join('')} +
+ +
+ + + ${this.renderAllEdges()} + + ${this.renderAllNodes()} + + +
+ + ${Math.round(this.canvasZoom * 100)}% + + +
+
+ +
+ ${this.renderConfigPanel()} +
+
+
+ `; + + this.attachEventListeners(); + } + + private renderAllNodes(): string { + return this.nodes.map(node => this.renderNode(node)).join(''); + } + + private renderNode(node: WorkflowNode): string { + const def = getNodeDef(node.type); + if (!def) return ''; + const catColor = CATEGORY_COLORS[def.category]; + const status = node.runtimeStatus || 'idle'; + const isSelected = node.id === this.selectedNodeId; + + // Ports + let portsHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + const x = node.position.x; + const color = PORT_COLORS[inp.type] || '#6b7280'; + portsHtml += ` + + + + `; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + const x = node.position.x + NODE_WIDTH; + const color = PORT_COLORS[out.type] || '#6b7280'; + portsHtml += ` + + + + `; + } + + // Input port labels + let portLabelHtml = ''; + for (const inp of def.inputs) { + const y = getPortY(node, inp.name, 'input'); + portLabelHtml += `${inp.name}`; + } + for (const out of def.outputs) { + const y = getPortY(node, out.name, 'output'); + portLabelHtml += `${out.name}`; + } + + return ` + + +
+
+ ${def.icon} + ${esc(node.label)} + +
+
+
+ ${def.inputs.map(p => `${p.name}`).join('')} +
+
+ ${def.outputs.map(p => `${p.name}`).join('')} +
+
+
+
+ ${portsHtml} + ${portLabelHtml} +
`; + } + + private renderAllEdges(): string { + return this.edges.map(edge => { + const fromNode = this.nodes.find(n => n.id === edge.fromNode); + const toNode = this.nodes.find(n => n.id === edge.toNode); + if (!fromNode || !toNode) return ''; + + const x1 = getPortX(fromNode, edge.fromPort, 'output'); + const y1 = getPortY(fromNode, edge.fromPort, 'output'); + const x2 = getPortX(toNode, edge.toPort, 'input'); + const y2 = getPortY(toNode, edge.toPort, 'input'); + + const fromDef = getNodeDef(fromNode.type); + const outPort = fromDef?.outputs.find(p => p.name === edge.fromPort); + const color = outPort ? (PORT_COLORS[outPort.type] || '#6b7280') : '#6b7280'; + const d = bezierPath(x1, y1, x2, y2); + + return ` + + + + `; + }).join(''); + } + + private renderConfigPanel(): string { + if (!this.selectedNodeId) { + return ` +
+ No node selected + +
+
+

Click a node to configure it.

+
`; + } + + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (!node) return ''; + const def = getNodeDef(node.type); + if (!def) return ''; + + const fieldsHtml = def.configSchema.map(field => { + const val = node.config[field.key] ?? ''; + if (field.type === 'select') { + const options = (field.options || []).map(o => + `` + ).join(''); + return ` +
+ + +
`; + } + if (field.type === 'textarea') { + return ` +
+ + +
`; + } + return ` +
+ + +
`; + }).join(''); + + const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => ` +
+ + ${esc(e.message)} (${e.durationMs}ms) +
+ `).join(''); + + return ` +
+ ${def.icon} ${esc(node.label)} + +
+
+
+ + +
+ ${fieldsHtml} + ${logHtml ? `
Execution Log
${logHtml}
` : ''} + +
`; + } + + // ── Redraw helpers ── + + private drawCanvasContent() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + const nodeLayer = this.shadow.getElementById('node-layer'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!edgeLayer || !nodeLayer) return; + edgeLayer.innerHTML = this.renderAllEdges(); + nodeLayer.innerHTML = this.renderAllNodes(); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private redrawEdges() { + const edgeLayer = this.shadow.getElementById('edge-layer'); + if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); + } + + private updateNodePosition(node: WorkflowNode) { + const nodeLayer = this.shadow.getElementById('node-layer'); + if (!nodeLayer) return; + const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; + if (!g) return; + const fo = g.querySelector('foreignObject'); + if (fo) { + fo.setAttribute('x', String(node.position.x)); + fo.setAttribute('y', String(node.position.y)); + } + // Update port circle positions + const def = getNodeDef(node.type); + if (!def) return; + const portGroups = g.querySelectorAll('.ac-port-group'); + portGroups.forEach(pg => { + const portName = (pg as HTMLElement).dataset.portName!; + const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output'; + const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH; + const ports = dir === 'input' ? def.inputs : def.outputs; + const idx = ports.findIndex(p => p.name === portName); + const spacing = NODE_HEIGHT / (ports.length + 1); + const y = node.position.y + spacing * (idx + 1); + pg.querySelectorAll('circle').forEach(c => { + c.setAttribute('cx', String(x)); + c.setAttribute('cy', String(y)); + }); + }); + // Update port labels + const labels = g.querySelectorAll('text'); + let labelIdx = 0; + for (const inp of def.inputs) { + if (labels[labelIdx]) { + const y = getPortY(node, inp.name, 'input'); + labels[labelIdx].setAttribute('x', String(node.position.x + 14)); + labels[labelIdx].setAttribute('y', String(y + 4)); + } + labelIdx++; + } + for (const out of def.outputs) { + if (labels[labelIdx]) { + const y = getPortY(node, out.name, 'output'); + labels[labelIdx].setAttribute('x', String(node.position.x + NODE_WIDTH - 14)); + labels[labelIdx].setAttribute('y', String(y + 4)); + } + labelIdx++; + } + } + + private refreshConfigPanel() { + const panel = this.shadow.getElementById('config-panel'); + if (!panel) return; + panel.className = `ac-config ${this.configOpen ? 'open' : ''}`; + panel.innerHTML = this.renderConfigPanel(); + this.attachConfigListeners(); + } + + // ── Node operations ── + + private addNode(type: string, x: number, y: number) { + const def = getNodeDef(type); + if (!def) return; + const id = `n-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node: WorkflowNode = { + id, + type: def.type, + label: def.label, + position: { x, y }, + config: {}, + }; + this.nodes.push(node); + this.drawCanvasContent(); + this.selectNode(id); + this.scheduleSave(); + } + + private deleteNode(nodeId: string) { + this.nodes = this.nodes.filter(n => n.id !== nodeId); + this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId); + if (this.selectedNodeId === nodeId) { + this.selectedNodeId = null; + this.configOpen = false; + } + this.drawCanvasContent(); + this.refreshConfigPanel(); + this.scheduleSave(); + } + + private selectNode(nodeId: string) { + this.selectedNodeId = nodeId; + this.configOpen = true; + // Update selection in SVG + const nodeLayer = this.shadow.getElementById('node-layer'); + if (nodeLayer) { + nodeLayer.querySelectorAll('.ac-node').forEach(g => { + g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId); + }); + } + this.refreshConfigPanel(); + } + + // ── Wiring ── + + private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') { + // Only start wiring from output ports + if (dir !== 'output') return; + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortName = portName; + this.wiringSourceDir = dir; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.add('wiring'); + } + + private cancelWiring() { + this.wiringActive = false; + this.wiringSourceNodeId = null; + this.wiringSourcePortName = null; + this.wiringSourceDir = null; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.remove('wiring'); + const wireLayer = this.shadow.getElementById('wire-layer'); + if (wireLayer) wireLayer.innerHTML = ''; + } + + private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') { + if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; } + if (targetDir !== 'input') { this.cancelWiring(); return; } + if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; } + + // Check for duplicate edges + const exists = this.edges.some(e => + e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName && + e.toNode === targetNodeId && e.toPort === targetPortName + ); + if (exists) { this.cancelWiring(); return; } + + const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.edges.push({ + id: edgeId, + fromNode: this.wiringSourceNodeId, + fromPort: this.wiringSourcePortName, + toNode: targetNodeId, + toPort: targetPortName, + }); + + this.cancelWiring(); + this.drawCanvasContent(); + this.scheduleSave(); + } + + private updateWiringTempLine() { + const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null; + const wireLayer = this.shadow.getElementById('wire-layer'); + if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return; + + const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId); + if (!sourceNode) return; + + const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output'); + const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output'); + + const rect = svg.getBoundingClientRect(); + const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; + const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; + + const d = bezierPath(x1, y1, x2, y2); + wireLayer.innerHTML = ``; + } + + // ── Execution ── + + private async runWorkflow() { + if (!this.currentWorkflowId) return; + // Reset runtime statuses + for (const n of this.nodes) { + n.runtimeStatus = 'running'; + } + this.drawCanvasContent(); + + try { + const res = await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}/run`, { method: 'POST' }); + const data = await res.json(); + this.execLog = data.results || []; + + // Update node statuses + for (const n of this.nodes) { + const logEntry = this.execLog.find(e => e.nodeId === n.id); + n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle'; + n.runtimeMessage = logEntry?.message; + } + } catch { + for (const n of this.nodes) { + n.runtimeStatus = 'error'; + } + } + + this.drawCanvasContent(); + if (this.selectedNodeId) this.refreshConfigPanel(); + + // Reset after 5s + setTimeout(() => { + for (const n of this.nodes) { + n.runtimeStatus = 'idle'; + n.runtimeMessage = undefined; + } + this.drawCanvasContent(); + }, 5000); + } + + // ── Event listeners ── + + private attachEventListeners() { + const canvas = this.shadow.getElementById('ac-canvas')!; + const svg = this.shadow.getElementById('ac-svg')!; + const palette = this.shadow.getElementById('palette')!; + + // Toolbar + this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => { + this.workflowName = (e.target as HTMLInputElement).value; + this.scheduleSave(); + }); + + this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => { + this.workflowEnabled = (e.target as HTMLInputElement).checked; + this.scheduleSave(); + }); + + this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow()); + this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow()); + this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow()); + + this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => { + const id = (e.target as HTMLSelectElement).value; + const wf = this.workflows.find(w => w.id === id); + if (wf) { + this.loadWorkflow(wf); + this.drawCanvasContent(); + this.refreshConfigPanel(); + requestAnimationFrame(() => this.fitView()); + } + }); + + // Zoom controls + this.shadow.getElementById('zoom-in')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 1.2); + }); + this.shadow.getElementById('zoom-out')?.addEventListener('click', () => { + const rect = svg.getBoundingClientRect(); + this.zoomAt(rect.width / 2, rect.height / 2, 0.8); + }); + this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); + + // Canvas mouse wheel + canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + const rect = svg.getBoundingClientRect(); + const factor = e.deltaY < 0 ? 1.1 : 0.9; + this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); + }, { passive: false }); + + // Palette drag + palette.querySelectorAll('.ac-palette__card').forEach(card => { + card.addEventListener('dragstart', (e: Event) => { + const de = e as DragEvent; + const type = (card as HTMLElement).dataset.nodeType!; + de.dataTransfer?.setData('text/plain', type); + }); + }); + + // Canvas drop + canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); }); + canvas.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + const type = e.dataTransfer?.getData('text/plain'); + if (!type) return; + const rect = svg.getBoundingClientRect(); + const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; + const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; + this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); + }); + + // SVG pointer events + svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); + + // Global move/up + this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e); + this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); + this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + document.addEventListener('pointermove', this._boundPointerMove); + document.addEventListener('pointerup', this._boundPointerUp); + document.addEventListener('keydown', this._boundKeyDown); + + // Config panel + this.attachConfigListeners(); + } + + private attachConfigListeners() { + this.shadow.getElementById('config-close')?.addEventListener('click', () => { + this.configOpen = false; + this.selectedNodeId = null; + const panel = this.shadow.getElementById('config-panel'); + if (panel) panel.className = 'ac-config'; + this.drawCanvasContent(); + }); + + this.shadow.getElementById('config-label')?.addEventListener('input', (e) => { + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.label = (e.target as HTMLInputElement).value; + this.drawCanvasContent(); + this.scheduleSave(); + } + }); + + this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => { + if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); + }); + + // Config field inputs + const configPanel = this.shadow.getElementById('config-panel'); + if (configPanel) { + configPanel.querySelectorAll('[data-config-key]').forEach(el => { + el.addEventListener('input', (e) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.config[key] = (e.target as HTMLInputElement).value; + this.scheduleSave(); + } + }); + el.addEventListener('change', (e) => { + const key = (el as HTMLElement).dataset.configKey!; + const node = this.nodes.find(n => n.id === this.selectedNodeId); + if (node) { + node.config[key] = (e.target as HTMLSelectElement).value; + this.scheduleSave(); + } + }); + }); + } + } + + private handlePointerDown(e: PointerEvent) { + const svg = this.shadow.getElementById('ac-svg') as unknown as SVGSVGElement; + const target = e.target as Element; + + // Port click — start/complete wiring + const portGroup = target.closest('.ac-port-group') as SVGElement | null; + if (portGroup) { + e.stopPropagation(); + const nodeId = portGroup.dataset.nodeId!; + const portName = portGroup.dataset.portName!; + const dir = portGroup.dataset.portDir as 'input' | 'output'; + + if (this.wiringActive) { + this.completeWiring(nodeId, portName, dir); + } else { + this.enterWiring(nodeId, portName, dir); + } + return; + } + + // Edge click — delete + const edgeGroup = target.closest('.ac-edge-group') as SVGElement | null; + if (edgeGroup) { + e.stopPropagation(); + const edgeId = edgeGroup.dataset.edgeId!; + this.edges = this.edges.filter(ed => ed.id !== edgeId); + this.redrawEdges(); + this.scheduleSave(); + return; + } + + // Node click — select + start drag + const nodeGroup = target.closest('.ac-node') as SVGElement | null; + if (nodeGroup) { + e.stopPropagation(); + if (this.wiringActive) { + this.cancelWiring(); + return; + } + const nodeId = nodeGroup.dataset.nodeId!; + this.selectNode(nodeId); + + const node = this.nodes.find(n => n.id === nodeId); + if (node) { + this.draggingNodeId = nodeId; + this.dragStartX = e.clientX; + this.dragStartY = e.clientY; + this.dragNodeStartX = node.position.x; + this.dragNodeStartY = node.position.y; + } + return; + } + + // Canvas click — pan or deselect + if (this.wiringActive) { + this.cancelWiring(); + return; + } + + this.isPanning = true; + this.panStartX = e.clientX; + this.panStartY = e.clientY; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.add('grabbing'); + + // Deselect + if (this.selectedNodeId) { + this.selectedNodeId = null; + this.configOpen = false; + this.drawCanvasContent(); + this.refreshConfigPanel(); + } + } + + private handlePointerMove(e: PointerEvent) { + if (this.wiringActive) { + this.wiringPointerX = e.clientX; + this.wiringPointerY = e.clientY; + this.updateWiringTempLine(); + return; + } + + if (this.draggingNodeId) { + const node = this.nodes.find(n => n.id === this.draggingNodeId); + if (node) { + const dx = (e.clientX - this.dragStartX) / this.canvasZoom; + const dy = (e.clientY - this.dragStartY) / this.canvasZoom; + node.position.x = this.dragNodeStartX + dx; + node.position.y = this.dragNodeStartY + dy; + this.updateNodePosition(node); + this.redrawEdges(); + } + return; + } + + if (this.isPanning) { + this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); + this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); + this.updateCanvasTransform(); + } + } + + private handlePointerUp(_e: PointerEvent) { + if (this.draggingNodeId) { + this.draggingNodeId = null; + this.scheduleSave(); + } + if (this.isPanning) { + this.isPanning = false; + const canvas = this.shadow.getElementById('ac-canvas'); + if (canvas) canvas.classList.remove('grabbing'); + } + } + + private handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + if (this.wiringActive) this.cancelWiring(); + } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) { + // Don't delete if focused on an input + if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return; + this.deleteNode(this.selectedNodeId); + } + } +} + +customElements.define('folk-automation-canvas', FolkAutomationCanvas); diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index 6c37a09..632e1d1 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -30,7 +30,11 @@ import type { ExecutionLogEntry, ActionType, Reminder, + Workflow, + WorkflowNode, + WorkflowEdge, } from "./schemas"; +import { NODE_CATALOG } from "./schemas"; import { calendarDocId } from "../rcal/schemas"; import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas"; @@ -73,6 +77,7 @@ function ensureDoc(space: string): ScheduleDoc { d.meta.spaceSlug = space; d.jobs = {}; d.reminders = {}; + d.workflows = {}; d.log = []; }, ); @@ -667,6 +672,38 @@ function startTickLoop() { console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e); } } + + // ── Process due automation workflows ── + const workflows = Object.values(doc.workflows || {}); + for (const wf of workflows) { + if (!wf.enabled) continue; + const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron"); + for (const cronNode of cronNodes) { + const expr = String(cronNode.config.cronExpression || ""); + const tz = String(cronNode.config.timezone || "UTC"); + if (!expr) continue; + try { + const interval = CronExpressionParser.parse(expr, { + currentDate: new Date(now - TICK_INTERVAL), + tz, + }); + const nextDate = interval.next().toDate(); + if (nextDate.getTime() <= now) { + console.log(`[Schedule] Running cron workflow "${wf.name}" for space ${space}`); + const results = await executeWorkflow(wf, space); + const allOk = results.every(r => r.status !== "error"); + _syncServer.changeDoc(docId, `tick workflow ${wf.id}`, (d) => { + const w = d.workflows[wf.id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + } + } catch { /* invalid cron — skip */ } + } + } } catch (e) { console.error(`[Schedule] Tick error for space ${space}:`, e); } @@ -1318,6 +1355,569 @@ routes.post("/api/reminders/:id/snooze", async (c) => { return c.json(updated.reminders[id]); }); +// ── Automation canvas page route ── + +routes.get("/reminders", (c) => { + const space = c.req.param("space") || "demo"; + return c.html( + renderShell({ + title: `${space} — Automations | rSpace`, + moduleId: "rschedule", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + }), + ); +}); + +// ── Workflow CRUD API ── + +routes.get("/api/workflows", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); + // Ensure workflows field exists on older docs + const workflows = Object.values(doc.workflows || {}); + workflows.sort((a, b) => a.name.localeCompare(b.name)); + return c.json({ count: workflows.length, results: workflows }); +}); + +routes.post("/api/workflows", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const body = await c.req.json(); + + const docId = scheduleDocId(dataSpace); + ensureDoc(dataSpace); + const wfId = crypto.randomUUID(); + const now = Date.now(); + + const workflow: Workflow = { + id: wfId, + name: body.name || "New Workflow", + enabled: body.enabled !== false, + nodes: body.nodes || [], + edges: body.edges || [], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(docId, `create workflow ${wfId}`, (d) => { + if (!d.workflows) d.workflows = {} as any; + (d.workflows as any)[wfId] = workflow; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.workflows[wfId], 201); +}); + +routes.get("/api/workflows/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + return c.json(wf); +}); + +routes.put("/api/workflows/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `update workflow ${id}`, (d) => { + const wf = d.workflows[id]; + if (!wf) return; + if (body.name !== undefined) wf.name = body.name; + if (body.enabled !== undefined) wf.enabled = body.enabled; + if (body.nodes !== undefined) { + // Replace the nodes array + while (wf.nodes.length > 0) wf.nodes.splice(0, 1); + for (const n of body.nodes) wf.nodes.push(n); + } + if (body.edges !== undefined) { + while (wf.edges.length > 0) wf.edges.splice(0, 1); + for (const e of body.edges) wf.edges.push(e); + } + wf.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.workflows[id]); +}); + +routes.delete("/api/workflows/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `delete workflow ${id}`, (d) => { + delete d.workflows[id]; + }); + + return c.json({ ok: true }); +}); + +// ── Workflow execution engine ── + +interface NodeResult { + nodeId: string; + status: "success" | "error" | "skipped"; + message: string; + durationMs: number; + outputData?: unknown; +} + +function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] { + const adj = new Map(); + const inDegree = new Map(); + + for (const n of nodes) { + adj.set(n.id, []); + inDegree.set(n.id, 0); + } + + for (const e of edges) { + adj.get(e.fromNode)?.push(e.toNode); + inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1); + } + + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const sorted: string[] = []; + while (queue.length > 0) { + const id = queue.shift()!; + sorted.push(id); + for (const neighbor of adj.get(id) || []) { + const newDeg = (inDegree.get(neighbor) || 1) - 1; + inDegree.set(neighbor, newDeg); + if (newDeg === 0) queue.push(neighbor); + } + } + + const nodeMap = new Map(nodes.map(n => [n.id, n])); + return sorted.map(id => nodeMap.get(id)!).filter(Boolean); +} + +function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +async function executeWorkflowNode( + node: WorkflowNode, + inputData: unknown, + space: string, +): Promise<{ success: boolean; message: string; outputData?: unknown }> { + const cfg = node.config; + + switch (node.type) { + // ── Triggers ── + case "trigger-cron": + return { success: true, message: "Cron triggered", outputData: { timestamp: Date.now() } }; + + case "trigger-data-change": + return { success: true, message: `Watching ${cfg.module || "any"} module`, outputData: inputData || {} }; + + case "trigger-webhook": + return { success: true, message: "Webhook received", outputData: inputData || {} }; + + case "trigger-manual": + return { success: true, message: "Manual trigger fired", outputData: { timestamp: Date.now() } }; + + case "trigger-proximity": { + const data = inputData as { lat?: number; lng?: number } | undefined; + if (!data?.lat || !data?.lng) return { success: true, message: "No location data", outputData: { distance: null } }; + const dist = haversineKm(data.lat, data.lng, Number(cfg.lat) || 0, Number(cfg.lng) || 0); + const inRange = dist <= (Number(cfg.radiusKm) || 1); + return { success: true, message: `Distance: ${dist.toFixed(2)}km (${inRange ? "in range" : "out of range"})`, outputData: { distance: dist, inRange } }; + } + + // ── Conditions ── + case "condition-compare": { + const val = String(inputData ?? ""); + const cmp = String(cfg.compareValue ?? ""); + let result = false; + switch (cfg.operator) { + case "equals": result = val === cmp; break; + case "not-equals": result = val !== cmp; break; + case "greater-than": result = Number(val) > Number(cmp); break; + case "less-than": result = Number(val) < Number(cmp); break; + case "contains": result = val.includes(cmp); break; + } + return { success: true, message: `Compare: ${result}`, outputData: result }; + } + + case "condition-geofence": { + const coords = inputData as { lat?: number; lng?: number } | undefined; + if (!coords?.lat || !coords?.lng) return { success: true, message: "No coords", outputData: false }; + const dist = haversineKm(coords.lat, coords.lng, Number(cfg.centerLat) || 0, Number(cfg.centerLng) || 0); + const inside = dist <= (Number(cfg.radiusKm) || 5); + return { success: true, message: `Geofence: ${inside ? "inside" : "outside"} (${dist.toFixed(2)}km)`, outputData: inside }; + } + + case "condition-time-window": { + const now = new Date(); + const hour = now.getHours(); + const day = now.getDay(); + const startH = Number(cfg.startHour) || 0; + const endH = Number(cfg.endHour) || 23; + const days = String(cfg.days || "0,1,2,3,4,5,6").split(",").map(Number); + const inWindow = hour >= startH && hour < endH && days.includes(day); + return { success: true, message: `Time window: ${inWindow ? "in" : "outside"}`, outputData: inWindow }; + } + + case "condition-data-filter": { + const data = inputData as Record | undefined; + const field = String(cfg.field || ""); + const val = data?.[field]; + let match = false; + switch (cfg.operator) { + case "equals": match = String(val) === String(cfg.value); break; + case "not-equals": match = String(val) !== String(cfg.value); break; + case "contains": match = String(val ?? "").includes(String(cfg.value ?? "")); break; + case "exists": match = val !== undefined && val !== null; break; + } + return { success: true, message: `Filter: ${match ? "match" : "no match"}`, outputData: match ? data : null }; + } + + // ── Actions ── + case "action-send-email": { + const transport = getSmtpTransport(); + if (!transport) return { success: false, message: "SMTP not configured" }; + if (!cfg.to) return { success: false, message: "No recipient" }; + + const vars: Record = { + date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }), + timestamp: new Date().toISOString(), + ...(typeof inputData === "object" && inputData !== null + ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) + : {}), + }; + + const subject = renderTemplate(String(cfg.subject || "Automation Notification"), vars); + const html = renderTemplate(String(cfg.bodyTemplate || `

Automation executed at ${vars.date}.

`), vars); + + await transport.sendMail({ + from: process.env.SMTP_FROM || "rSchedule ", + to: String(cfg.to), + subject, + html, + }); + return { success: true, message: `Email sent to ${cfg.to}` }; + } + + case "action-post-webhook": { + if (!cfg.url) return { success: false, message: "No URL configured" }; + const method = String(cfg.method || "POST").toUpperCase(); + const vars: Record = { + timestamp: new Date().toISOString(), + ...(typeof inputData === "object" && inputData !== null + ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) + : {}), + }; + const body = renderTemplate(String(cfg.bodyTemplate || JSON.stringify({ timestamp: vars.timestamp })), vars); + const res = await fetch(String(cfg.url), { + method, + headers: { "Content-Type": "application/json" }, + body: method !== "GET" ? body : undefined, + }); + if (!res.ok) return { success: false, message: `Webhook ${res.status}` }; + return { success: true, message: `Webhook ${method} ${cfg.url} -> ${res.status}`, outputData: await res.json().catch(() => null) }; + } + + case "action-create-event": { + if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; + const calDocId = calendarDocId(space); + const calDoc = _syncServer.getDoc(calDocId); + if (!calDoc) return { success: false, message: "Calendar doc not found" }; + + const eventId = crypto.randomUUID(); + const now = Date.now(); + const durationMs = (Number(cfg.durationMinutes) || 60) * 60 * 1000; + + _syncServer.changeDoc(calDocId, `automation: create event`, (d) => { + d.events[eventId] = { + id: eventId, + title: String(cfg.title || "Automation Event"), + description: "Created by rSchedule automation", + startTime: now, + endTime: now + durationMs, + allDay: false, + timezone: "UTC", + rrule: null, + status: null, + visibility: null, + sourceId: null, + sourceName: null, + sourceType: null, + sourceColor: null, + locationId: null, + locationName: null, + coordinates: null, + locationGranularity: null, + locationLat: null, + locationLng: null, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: "rSchedule", + rToolEntityId: node.id, + attendees: [], + attendeeCount: 0, + metadata: null, + createdAt: now, + updatedAt: now, + }; + }); + return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } }; + } + + case "action-create-task": + return { success: true, message: `Task "${cfg.title || "New task"}" queued`, outputData: { taskTitle: cfg.title } }; + + case "action-send-notification": + console.log(`[Automation] Notification: ${cfg.title || "Notification"} — ${cfg.message || ""}`); + return { success: true, message: `Notification: ${cfg.title}` }; + + case "action-update-data": + return { success: true, message: `Data update queued for ${cfg.module || "unknown"}` }; + + default: + return { success: false, message: `Unknown node type: ${node.type}` }; + } +} + +async function executeWorkflow( + workflow: Workflow, + space: string, + triggerData?: unknown, +): Promise { + const sorted = topologicalSort(workflow.nodes, workflow.edges); + const results: NodeResult[] = []; + const nodeOutputs = new Map(); + + for (const node of sorted) { + const startMs = Date.now(); + + // Gather input data from upstream edges + let inputData: unknown = triggerData; + const incomingEdges = workflow.edges.filter(e => e.toNode === node.id); + if (incomingEdges.length > 0) { + // For conditions that output booleans: if the upstream condition result is false + // and this node connects via the "true" port, skip it + const upstreamNode = workflow.nodes.find(n => n.id === incomingEdges[0].fromNode); + const upstreamOutput = nodeOutputs.get(incomingEdges[0].fromNode); + + if (upstreamNode?.type.startsWith("condition-")) { + const port = incomingEdges[0].fromPort; + // Boolean result from condition + if (port === "true" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Condition false, skipping true branch", durationMs: 0 }); + continue; + } + if (port === "false" && upstreamOutput === true) { + results.push({ nodeId: node.id, status: "skipped", message: "Condition true, skipping false branch", durationMs: 0 }); + continue; + } + if (port === "inside" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Outside geofence, skipping inside branch", durationMs: 0 }); + continue; + } + if (port === "outside" && upstreamOutput === true) { + results.push({ nodeId: node.id, status: "skipped", message: "Inside geofence, skipping outside branch", durationMs: 0 }); + continue; + } + if (port === "in-window" && upstreamOutput === false) { + results.push({ nodeId: node.id, status: "skipped", message: "Outside time window", durationMs: 0 }); + continue; + } + if (port === "match" && upstreamOutput === null) { + results.push({ nodeId: node.id, status: "skipped", message: "Data filter: no match", durationMs: 0 }); + continue; + } + if (port === "no-match" && upstreamOutput !== null) { + results.push({ nodeId: node.id, status: "skipped", message: "Data filter: matched", durationMs: 0 }); + continue; + } + } + + // Use upstream output as input + if (upstreamOutput !== undefined) inputData = upstreamOutput; + } + + try { + const result = await executeWorkflowNode(node, inputData, space); + const durationMs = Date.now() - startMs; + nodeOutputs.set(node.id, result.outputData); + results.push({ + nodeId: node.id, + status: result.success ? "success" : "error", + message: result.message, + durationMs, + outputData: result.outputData, + }); + } catch (e: any) { + results.push({ + nodeId: node.id, + status: "error", + message: e.message || String(e), + durationMs: Date.now() - startMs, + }); + } + } + + return results; +} + +// POST /api/workflows/:id/run — manual execute +routes.post("/api/workflows/:id/run", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + + const results = await executeWorkflow(wf, dataSpace); + + const allOk = results.every(r => r.status !== "error"); + _syncServer!.changeDoc(docId, `run workflow ${id}`, (d) => { + const w = d.workflows[id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + + return c.json({ success: allOk, results }); +}); + +// POST /api/workflows/webhook/:hookId — external webhook trigger +routes.post("/api/workflows/webhook/:hookId", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const hookId = c.req.param("hookId"); + + const doc = ensureDoc(dataSpace); + let payload: unknown = {}; + try { payload = await c.req.json(); } catch { /* empty payload */ } + + // Find workflows with a trigger-webhook node matching this hookId + const matches: Workflow[] = []; + for (const wf of Object.values(doc.workflows || {})) { + if (!wf.enabled) continue; + for (const node of wf.nodes) { + if (node.type === "trigger-webhook" && node.config.hookId === hookId) { + matches.push(wf); + break; + } + } + } + + if (matches.length === 0) return c.json({ error: "No matching workflow" }, 404); + + const allResults: { workflowId: string; results: NodeResult[] }[] = []; + for (const wf of matches) { + const results = await executeWorkflow(wf, dataSpace, payload); + allResults.push({ workflowId: wf.id, results }); + } + + return c.json({ triggered: matches.length, results: allResults }); +}); + +// ── Demo workflow seeds ── + +function seedDemoWorkflows(space: string) { + const docId = scheduleDocId(space); + const doc = ensureDoc(space); + + if (Object.keys(doc.workflows || {}).length > 0) return; + + const now = Date.now(); + + const demo1Id = "demo-arriving-home"; + const demo1: Workflow = { + id: demo1Id, + name: "Arriving Home Notification", + enabled: false, + nodes: [ + { id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } }, + { id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } }, + { id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "

I'll be home in about {{distance}}km.

" } }, + ], + edges: [ + { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, + { id: "e2", fromNode: "n1", fromPort: "distance", toNode: "n2", toPort: "coords" }, + { id: "e3", fromNode: "n2", fromPort: "inside", toNode: "n3", toPort: "trigger" }, + ], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + const demo2Id = "demo-signoff-pipeline"; + const demo2: Workflow = { + id: demo2Id, + name: "Document Sign-off Pipeline", + enabled: false, + nodes: [ + { id: "n1", type: "trigger-data-change", label: "Watch Sign-offs", position: { x: 50, y: 100 }, config: { module: "rnotes", field: "status" } }, + { id: "n2", type: "condition-compare", label: "Status = Signed", position: { x: 350, y: 100 }, config: { operator: "equals", compareValue: "signed" } }, + { id: "n3", type: "action-create-event", label: "Schedule Review", position: { x: 650, y: 60 }, config: { title: "Document Review Meeting", durationMinutes: "30" } }, + { id: "n4", type: "action-send-notification", label: "Notify Comms", position: { x: 650, y: 180 }, config: { title: "Sign-off Complete", message: "Document has been signed off", level: "success" } }, + ], + edges: [ + { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, + { id: "e2", fromNode: "n1", fromPort: "data", toNode: "n2", toPort: "value" }, + { id: "e3", fromNode: "n2", fromPort: "true", toNode: "n3", toPort: "trigger" }, + { id: "e4", fromNode: "n2", fromPort: "true", toNode: "n4", toPort: "trigger" }, + ], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(docId, "seed demo workflows", (d) => { + if (!d.workflows) d.workflows = {} as any; + (d.workflows as any)[demo1Id] = demo1; + (d.workflows as any)[demo2Id] = demo2; + }); + + console.log(`[Schedule] Seeded 2 demo workflows for space "${space}"`); +} + // ── Module export ── export const scheduleModule: RSpaceModule = { @@ -1339,6 +1939,7 @@ export const scheduleModule: RSpaceModule = { async onInit(ctx) { _syncServer = ctx.syncServer; seedDefaultJobs("demo"); + seedDemoWorkflows("demo"); startTickLoop(); }, feeds: [ @@ -1353,6 +1954,7 @@ export const scheduleModule: RSpaceModule = { outputPaths: [ { path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" }, { path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" }, + { path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" }, { path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" }, ], }; diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts index 627b4c8..649a2f2 100644 --- a/modules/rschedule/schemas.ts +++ b/modules/rschedule/schemas.ts @@ -75,6 +75,285 @@ export interface Reminder { updatedAt: number; } +// ── Workflow / Automation types ── + +export type AutomationNodeType = + // Triggers + | 'trigger-cron' + | 'trigger-data-change' + | 'trigger-webhook' + | 'trigger-manual' + | 'trigger-proximity' + // Conditions + | 'condition-compare' + | 'condition-geofence' + | 'condition-time-window' + | 'condition-data-filter' + // Actions + | 'action-send-email' + | 'action-post-webhook' + | 'action-create-event' + | 'action-create-task' + | 'action-send-notification' + | 'action-update-data'; + +export type AutomationNodeCategory = 'trigger' | 'condition' | 'action'; + +export interface WorkflowNodePort { + name: string; + type: 'trigger' | 'data' | 'boolean'; +} + +export interface WorkflowNode { + id: string; + type: AutomationNodeType; + label: string; + position: { x: number; y: number }; + config: Record; + // Runtime state (not persisted to Automerge — set during execution) + runtimeStatus?: 'idle' | 'running' | 'success' | 'error'; + runtimeMessage?: string; + runtimeDurationMs?: number; +} + +export interface WorkflowEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +export interface Workflow { + id: string; + name: string; + enabled: boolean; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + lastRunAt: number | null; + lastRunStatus: 'success' | 'error' | null; + runCount: number; + createdAt: number; + updatedAt: number; +} + +export interface AutomationNodeDef { + type: AutomationNodeType; + category: AutomationNodeCategory; + label: string; + icon: string; + description: string; + inputs: WorkflowNodePort[]; + outputs: WorkflowNodePort[]; + configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[]; +} + +export const NODE_CATALOG: AutomationNodeDef[] = [ + // ── Triggers ── + { + type: 'trigger-cron', + category: 'trigger', + label: 'Cron Schedule', + icon: '⏰', + description: 'Fire on a cron schedule', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }], + configSchema: [ + { key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' }, + { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' }, + ], + }, + { + type: 'trigger-data-change', + category: 'trigger', + label: 'Data Change', + icon: '📊', + description: 'Fire when data changes in any rApp', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] }, + { key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' }, + ], + }, + { + type: 'trigger-webhook', + category: 'trigger', + label: 'Webhook Incoming', + icon: '🔗', + description: 'Fire when an external webhook is received', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }], + configSchema: [ + { key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' }, + ], + }, + { + type: 'trigger-manual', + category: 'trigger', + label: 'Manual Trigger', + icon: '👆', + description: 'Fire manually via button click', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }], + configSchema: [], + }, + { + type: 'trigger-proximity', + category: 'trigger', + label: 'Location Proximity', + icon: '📍', + description: 'Fire when a location approaches a point', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }], + configSchema: [ + { key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' }, + { key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' }, + ], + }, + // ── Conditions ── + { + type: 'condition-compare', + category: 'condition', + label: 'Compare Values', + icon: '⚖️', + description: 'Compare two values', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }], + outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }], + configSchema: [ + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] }, + { key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' }, + ], + }, + { + type: 'condition-geofence', + category: 'condition', + label: 'Geofence Check', + icon: '🗺️', + description: 'Check if coordinates are within a radius', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }], + outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' }, + { key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' }, + ], + }, + { + type: 'condition-time-window', + category: 'condition', + label: 'Time Window', + icon: '🕐', + description: 'Check if current time is within a window', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' }, + { key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' }, + { key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' }, + ], + }, + { + type: 'condition-data-filter', + category: 'condition', + label: 'Data Filter', + icon: '🔍', + description: 'Filter data by field value', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }], + configSchema: [ + { key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' }, + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] }, + { key: 'value', label: 'Value', type: 'text', placeholder: 'completed' }, + ], + }, + // ── Actions ── + { + type: 'action-send-email', + category: 'action', + label: 'Send Email', + icon: '📧', + description: 'Send an email via SMTP', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' }, + { key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' }, + { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '

Hello {{name}}

' }, + ], + }, + { + type: 'action-post-webhook', + category: 'action', + label: 'POST Webhook', + icon: '🌐', + description: 'Send an HTTP POST to an external URL', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }], + configSchema: [ + { key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' }, + { key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] }, + { key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' }, + ], + }, + { + type: 'action-create-event', + category: 'action', + label: 'Create Calendar Event', + icon: '📅', + description: 'Create an event in rCal', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }], + configSchema: [ + { key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' }, + { key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' }, + ], + }, + { + type: 'action-create-task', + category: 'action', + label: 'Create Task', + icon: '✅', + description: 'Create a task in rWork', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }], + configSchema: [ + { key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' }, + { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' }, + { key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] }, + ], + }, + { + type: 'action-send-notification', + category: 'action', + label: 'Send Notification', + icon: '🔔', + description: 'Send an in-app notification', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }], + configSchema: [ + { key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' }, + { key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' }, + { key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] }, + ], + }, + { + type: 'action-update-data', + category: 'action', + label: 'Update Data', + icon: '💾', + description: 'Update data in an rApp document', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] }, + { key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] }, + { key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' }, + ], + }, +]; + export interface ScheduleDoc { meta: { module: string; @@ -85,6 +364,7 @@ export interface ScheduleDoc { }; jobs: Record; reminders: Record; + workflows: Record; log: ExecutionLogEntry[]; } @@ -104,6 +384,7 @@ export const scheduleSchema: DocSchema = { }, jobs: {}, reminders: {}, + workflows: {}, log: [], }), }; diff --git a/vite.config.ts b/vite.config.ts index 7c2abd8..2aeebc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -526,6 +526,24 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rtrips/trips.css"), ); + // Build trips demo page script + await build({ + configFile: false, + root: resolve(__dirname, "modules/rtrips/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rtrips"), + lib: { + entry: resolve(__dirname, "modules/rtrips/components/trips-demo.ts"), + formats: ["es"], + fileName: () => "trips-demo.js", + }, + rollupOptions: { + output: { entryFileNames: "trips-demo.js" }, + }, + }, + }); + // Build cal module component await build({ configFile: false, @@ -916,6 +934,37 @@ export default defineConfig({ }, }); + // Build schedule automation canvas component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rschedule/components"), + resolve: { + alias: { + "../schemas": resolve(__dirname, "modules/rschedule/schemas.ts"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rschedule"), + lib: { + entry: resolve(__dirname, "modules/rschedule/components/folk-automation-canvas.ts"), + formats: ["es"], + fileName: () => "folk-automation-canvas.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-automation-canvas.js", + }, + }, + }, + }); + + // Copy automation canvas CSS + copyFileSync( + resolve(__dirname, "modules/rschedule/components/automation-canvas.css"), + resolve(__dirname, "dist/modules/rschedule/automation-canvas.css"), + ); + // ── Demo infrastructure ── // Build demo-sync-vanilla library