From 347ba73942ee61b7a5313bebddef2eb565cc3728 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 21:29:09 -0700 Subject: [PATCH] feat(rsocials): replace campaign planner with n8n-style workflow builder Add folk-campaign-workflow component with SVG canvas, node palette (12 nodes across 4 categories: triggers, delays, conditions, actions), Bezier wiring, config panel, drag-and-drop, pan/zoom, and REST auto-save. Includes 7 API endpoints for CRUD + stub execution, SocialsDoc v3 migration, demo workflow seeding, and local-first client methods. Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/campaign-data.ts | 86 +- .../rsocials/components/campaign-workflow.css | 517 +++++++++ .../components/folk-campaign-workflow.ts | 1003 +++++++++++++++++ modules/rsocials/local-first-client.ts | 45 +- modules/rsocials/mod.ts | 214 +++- modules/rsocials/schemas.ts | 244 +++- vite.config.ts | 31 + 7 files changed, 2129 insertions(+), 11 deletions(-) create mode 100644 modules/rsocials/components/campaign-workflow.css create mode 100644 modules/rsocials/components/folk-campaign-workflow.ts diff --git a/modules/rsocials/campaign-data.ts b/modules/rsocials/campaign-data.ts index 3cca41d..f0500d0 100644 --- a/modules/rsocials/campaign-data.ts +++ b/modules/rsocials/campaign-data.ts @@ -6,7 +6,7 @@ * Also exports buildDemoCampaignFlow() for the canvas planner. */ -import type { CampaignFlow, CampaignPlannerNode, CampaignEdge } from './schemas'; +import type { CampaignFlow, CampaignPlannerNode, CampaignEdge, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge } from './schemas'; export interface CampaignPost { id: string; @@ -303,3 +303,87 @@ export function buildDemoCampaignFlow(): CampaignFlow { createdBy: null, }; } + +// ── Build demo campaign workflow (n8n-style) ── +// Campaign Start → Post to X → Wait 2h → Cross-Post (LinkedIn + Threads) → Engagement Check → Send Newsletter + +export function buildDemoCampaignWorkflow(): CampaignWorkflow { + const nodes: CampaignWorkflowNode[] = [ + { + id: 'cw-start', + type: 'campaign-start', + label: 'Campaign Start', + position: { x: 50, y: 120 }, + config: { description: 'MycoFi Earth launch sequence' }, + }, + { + id: 'cw-post-x', + type: 'post-to-platform', + label: 'Post to X', + position: { x: 340, y: 100 }, + config: { + platform: 'X', + content: 'Something is growing in the mycelium... \uD83C\uDF44\n\nMycoFi Earth launches today. A regenerative finance platform modeled on mycelial networks.\n\nNo VCs. No whales. Just communities funding what matters.', + hashtags: '#MycoFi #RegenFinance #Web3', + }, + }, + { + id: 'cw-wait', + type: 'wait-duration', + label: 'Wait 2 Hours', + position: { x: 630, y: 120 }, + config: { amount: 2, unit: 'hours' }, + }, + { + id: 'cw-crosspost', + type: 'cross-post', + label: 'Cross-Post', + position: { x: 920, y: 80 }, + config: { + platforms: 'LinkedIn, Threads', + content: 'MycoFi Earth is live — a regenerative finance platform where funding flows like nutrients through a mycelial network.', + adaptPerPlatform: 'yes', + }, + }, + { + id: 'cw-engage', + type: 'engagement-check', + label: 'Engagement Check', + position: { x: 1210, y: 60 }, + config: { metric: 'likes', threshold: 50 }, + }, + { + id: 'cw-newsletter', + type: 'send-newsletter', + label: 'Send Newsletter', + position: { x: 1500, y: 40 }, + config: { + subject: 'MycoFi Earth is Live!', + listId: '1', + bodyTemplate: '

MycoFi Earth Launch

The regenerative finance platform is now live. Join the first funding circle.

', + }, + }, + ]; + + const edges: CampaignWorkflowEdge[] = [ + { id: 'cwe-1', fromNode: 'cw-start', fromPort: 'trigger', toNode: 'cw-post-x', toPort: 'trigger' }, + { id: 'cwe-2', fromNode: 'cw-post-x', fromPort: 'done', toNode: 'cw-wait', toPort: 'trigger' }, + { id: 'cwe-3', fromNode: 'cw-wait', fromPort: 'done', toNode: 'cw-crosspost', toPort: 'trigger' }, + { id: 'cwe-4', fromNode: 'cw-crosspost', fromPort: 'done', toNode: 'cw-engage', toPort: 'trigger' }, + { id: 'cwe-5', fromNode: 'cw-engage', fromPort: 'above', toNode: 'cw-newsletter', toPort: 'trigger' }, + ]; + + const now = Date.now(); + return { + id: 'demo-mycofi-workflow', + name: 'MycoFi Launch Workflow', + enabled: true, + nodes, + edges, + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; +} diff --git a/modules/rsocials/components/campaign-workflow.css b/modules/rsocials/components/campaign-workflow.css new file mode 100644 index 0000000..09c4950 --- /dev/null +++ b/modules/rsocials/components/campaign-workflow.css @@ -0,0 +1,517 @@ +/* rSocials Campaign Workflow — n8n-style workflow builder */ +folk-campaign-workflow { + display: block; + height: calc(100vh - 60px); +} + +.cw-root { + display: flex; + flex-direction: column; + height: 100%; + font-family: system-ui, -apple-system, sans-serif; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── Toolbar ── */ +.cw-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; +} + +.cw-toolbar__title { + font-size: 15px; + font-weight: 600; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.cw-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; +} + +.cw-toolbar__title input:hover, +.cw-toolbar__title input:focus { + border-color: var(--rs-border-strong, #3d3d5c); + outline: none; +} + +.cw-toolbar__actions { + display: flex; + gap: 6px; + align-items: center; +} + +.cw-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; +} + +.cw-btn:hover { + border-color: var(--rs-border-strong, #4d4d6c); +} + +.cw-btn--run { + background: #3b82f622; + border-color: #3b82f655; + color: #60a5fa; +} + +.cw-btn--run:hover { + background: #3b82f633; + border-color: #3b82f6; +} + +.cw-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-muted, #94a3b8); +} + +.cw-toggle input[type="checkbox"] { + accent-color: #10b981; +} + +.cw-save-indicator { + font-size: 11px; + color: var(--rs-text-muted, #64748b); +} + +/* ── Canvas area ── */ +.cw-canvas-area { + flex: 1; + display: flex; + overflow: hidden; + position: relative; +} + +/* ── Left sidebar — node palette ── */ +.cw-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; +} + +.cw-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; +} + +.cw-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; +} + +.cw-palette__card:hover { + border-color: #6366f1; + background: #6366f111; +} + +.cw-palette__card:active { + cursor: grabbing; +} + +.cw-palette__card-icon { + font-size: 16px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.cw-palette__card-label { + font-weight: 500; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── SVG canvas ── */ +.cw-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background: var(--rs-canvas-bg, #0f0f23); +} + +.cw-canvas.grabbing { + cursor: grabbing; +} + +.cw-canvas.wiring { + cursor: crosshair; +} + +.cw-canvas svg { + display: block; + width: 100%; + height: 100%; +} + +/* ── Right sidebar — config ── */ +.cw-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; +} + +.cw-config.open { + width: 280px; + min-width: 280px; +} + +.cw-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; +} + +.cw-config__header-close { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 16px; + cursor: pointer; + margin-left: auto; + padding: 2px; +} + +.cw-config__body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; +} + +.cw-config__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cw-config__field label { + font-size: 11px; + font-weight: 500; + color: var(--rs-text-muted, #94a3b8); +} + +.cw-config__field input, +.cw-config__field select, +.cw-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; +} + +.cw-config__field textarea { + resize: vertical; + min-height: 60px; +} + +.cw-config__field input:focus, +.cw-config__field select:focus, +.cw-config__field textarea:focus { + border-color: #3b82f6; + outline: none; +} + +.cw-config__delete { + margin-top: 12px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #ef444455; + background: #ef444422; + color: #f87171; + font-size: 12px; + cursor: pointer; +} + +.cw-config__delete:hover { + background: #ef444433; +} + +/* ── Execution log in config panel ── */ +.cw-exec-log { + margin-top: 12px; + border-top: 1px solid var(--rs-border, #2d2d44); + padding-top: 12px; +} + +.cw-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; +} + +.cw-exec-log__entry { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); +} + +.cw-exec-log__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.cw-exec-log__dot.success { background: #22c55e; } +.cw-exec-log__dot.error { background: #ef4444; } +.cw-exec-log__dot.running { background: #3b82f6; animation: cw-pulse 1s infinite; } + +@keyframes cw-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Zoom controls ── */ +.cw-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; +} + +.cw-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; +} + +.cw-zoom-btn:hover { + background: var(--rs-bg-surface-raised, #252545); +} + +.cw-zoom-level { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + min-width: 36px; + text-align: center; +} + +/* ── Node styles in SVG ── */ +.cw-node { cursor: pointer; } +.cw-node.selected > foreignObject > div { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +.cw-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; +} + +.cw-node:hover foreignObject > div { + border-color: #4f46e5 !important; +} + +.cw-node-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + cursor: move; +} + +.cw-node-icon { + font-size: 14px; +} + +.cw-node-label { + font-weight: 600; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cw-node-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.cw-node-status.idle { background: #4b5563; } +.cw-node-status.running { background: #3b82f6; animation: cw-pulse 1s infinite; } +.cw-node-status.success { background: #22c55e; } +.cw-node-status.error { background: #ef4444; } + +.cw-node-ports { + padding: 6px 10px; + display: flex; + justify-content: space-between; + min-height: 28px; +} + +.cw-node-inputs, +.cw-node-outputs { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cw-node-outputs { + align-items: flex-end; +} + +.cw-port-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Port handles in SVG ── */ +.cw-port-group { cursor: crosshair; } +.cw-port-dot { + transition: r 0.15s, filter 0.15s; +} + +.cw-port-group:hover .cw-port-dot { + r: 7; + filter: drop-shadow(0 0 4px currentColor); +} + +/* ── Edge styles ── */ +.cw-edge-group { pointer-events: stroke; } + +.cw-edge-path { + fill: none; + stroke-width: 2; +} + +.cw-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 16; + cursor: pointer; +} + +/* ── Wiring temp line ── */ +.cw-wiring-temp { + fill: none; + stroke: #6366f1; + stroke-width: 2; + stroke-dasharray: 6 4; + opacity: 0.7; + pointer-events: none; +} + +/* ── Workflow selector ── */ +.cw-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) { + .cw-palette { + width: 160px; + min-width: 160px; + padding: 8px; + } + + .cw-config.open { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + z-index: 20; + min-width: unset; + } + + .cw-toolbar { + flex-wrap: wrap; + padding: 8px 12px; + } +} diff --git a/modules/rsocials/components/folk-campaign-workflow.ts b/modules/rsocials/components/folk-campaign-workflow.ts new file mode 100644 index 0000000..41d027e --- /dev/null +++ b/modules/rsocials/components/folk-campaign-workflow.ts @@ -0,0 +1,1003 @@ +/** + * — n8n-style campaign workflow builder for rSocials. + * + * Renders social-media-specific workflow nodes (triggers, delays, conditions, + * actions) on an SVG canvas with ports, Bezier wiring, node palette, config + * panel, and REST persistence. + * + * Attributes: + * space — space slug (default "demo") + */ + +import { CAMPAIGN_NODE_CATALOG } from '../schemas'; +import type { + CampaignWorkflowNodeDef, + CampaignWorkflowNodeCategory, + CampaignWorkflowNode, + CampaignWorkflowEdge, + CampaignWorkflow, +} from '../schemas'; + +// ── Constants ── + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 80; +const PORT_RADIUS = 5; + +const CATEGORY_COLORS: Record = { + trigger: '#3b82f6', + delay: '#a855f7', + 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): CampaignWorkflowNodeDef | undefined { + return CAMPAIGN_NODE_CATALOG.find(n => n.type === type); +} + +function getPortX(node: CampaignWorkflowNode, _portName: string, direction: 'input' | 'output'): number { + return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH; +} + +function getPortY(node: CampaignWorkflowNode, 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 FolkCampaignWorkflow extends HTMLElement { + private shadow: ShadowRoot; + private space = ''; + + private get basePath() { + const host = window.location.hostname; + if (host.endsWith('.rspace.online')) return '/rsocials/'; + return `/${this.space}/rsocials/`; + } + + // Data + private workflows: CampaignWorkflow[] = []; + private currentWorkflowId = ''; + private nodes: CampaignWorkflowNode[] = []; + private edges: CampaignWorkflowEdge[] = []; + private workflowName = 'New Campaign 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/campaign-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('[CampaignWorkflow] Failed to load workflows'); + } + this.render(); + requestAnimationFrame(() => this.fitView()); + } + + private loadWorkflow(wf: CampaignWorkflow) { + 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/campaign-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/campaign-workflows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'New Campaign Workflow' }), + }); + if (res.ok) { + const wf = await res.json(); + this.workflows.push(wf); + this.loadWorkflow(wf); + this.render(); + requestAnimationFrame(() => this.fitView()); + } + } catch { + console.error('[CampaignWorkflow] Failed to create workflow'); + } + } + + private async deleteWorkflow() { + if (!this.currentWorkflowId) return; + try { + await fetch(`${this.basePath}api/campaign-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('[CampaignWorkflow] 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('cw-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: CampaignWorkflowNodeCategory[] = ['trigger', 'delay', 'condition', 'action']; + + this.shadow.innerHTML = ` + +
+
+
+ Campaigns + + +
+
+
+ + +
+ ${this.saveIndicator} + + + +
+
+ +
+
+ ${paletteGroups.map(cat => ` +
+
${cat}s
+ ${CAMPAIGN_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: CampaignWorkflowNode): 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 += ` + + + + `; + } + + // 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: CampaignWorkflowNode) { + 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)); + } + const def = getNodeDef(node.type); + if (!def) return; + const portGroups = g.querySelectorAll('.cw-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)); + }); + }); + 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 = `cw-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 = `cn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const node: CampaignWorkflowNode = { + 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; + const nodeLayer = this.shadow.getElementById('node-layer'); + if (nodeLayer) { + nodeLayer.querySelectorAll('.cw-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') { + if (dir !== 'output') return; + this.wiringActive = true; + this.wiringSourceNodeId = nodeId; + this.wiringSourcePortName = portName; + this.wiringSourceDir = dir; + const canvas = this.shadow.getElementById('cw-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('cw-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; } + + 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 = `ce-${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('cw-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; + for (const n of this.nodes) { + n.runtimeStatus = 'running'; + } + this.drawCanvasContent(); + + try { + const res = await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}/run`, { method: 'POST' }); + const data = await res.json(); + this.execLog = data.results || []; + + 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(); + + 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('cw-canvas')!; + const svg = this.shadow.getElementById('cw-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('.cw-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 = 'cw-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); + }); + + 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 target = e.target as Element; + + // Port click — start/complete wiring + const portGroup = target.closest('.cw-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('.cw-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('.cw-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('cw-canvas'); + if (canvas) canvas.classList.add('grabbing'); + + 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('cw-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) { + if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return; + this.deleteNode(this.selectedNodeId); + } + } +} + +customElements.define('folk-campaign-workflow', FolkCampaignWorkflow); diff --git a/modules/rsocials/local-first-client.ts b/modules/rsocials/local-first-client.ts index 10ba601..cd378e3 100644 --- a/modules/rsocials/local-first-client.ts +++ b/modules/rsocials/local-first-client.ts @@ -11,7 +11,7 @@ import { EncryptedDocStore } from '../../shared/local-first/storage'; import { DocSyncManager } from '../../shared/local-first/sync'; import { DocCrypto } from '../../shared/local-first/crypto'; import { socialsSchema, socialsDocId } from './schemas'; -import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge } from './schemas'; +import type { SocialsDoc, ThreadData, Campaign, CampaignFlow, CampaignPlannerNode, CampaignEdge, CampaignWorkflow, CampaignWorkflowNode, CampaignWorkflowEdge } from './schemas'; export class SocialsLocalFirstClient { #space: string; @@ -193,6 +193,49 @@ export class SocialsLocalFirstClient { }); } + // ── Campaign workflow reads ── + + listCampaignWorkflows(): CampaignWorkflow[] { + const doc = this.getDoc(); + if (!doc?.campaignWorkflows) return []; + return Object.values(doc.campaignWorkflows).sort((a, b) => b.updatedAt - a.updatedAt); + } + + getCampaignWorkflow(id: string): CampaignWorkflow | undefined { + const doc = this.getDoc(); + return doc?.campaignWorkflows?.[id]; + } + + // ── Campaign workflow writes ── + + saveCampaignWorkflow(wf: CampaignWorkflow): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Save campaign workflow ${wf.name || wf.id}`, (d) => { + if (!d.campaignWorkflows) d.campaignWorkflows = {} as any; + wf.updatedAt = Date.now(); + if (!wf.createdAt) wf.createdAt = Date.now(); + d.campaignWorkflows[wf.id] = wf; + }); + } + + updateCampaignWorkflowNodesEdges(wfId: string, nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Update campaign workflow nodes ${wfId}`, (d) => { + if (d.campaignWorkflows?.[wfId]) { + d.campaignWorkflows[wfId].nodes = nodes; + d.campaignWorkflows[wfId].edges = edges; + d.campaignWorkflows[wfId].updatedAt = Date.now(); + } + }); + } + + deleteCampaignWorkflow(id: string): void { + const docId = socialsDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Delete campaign workflow ${id}`, (d) => { + if (d.campaignWorkflows?.[id]) delete d.campaignWorkflows[id]; + }); + } + // ── Events ── onChange(cb: (doc: SocialsDoc) => void): () => void { diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index fad2969..8a28daf 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -18,8 +18,8 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; -import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow } from "./campaign-data"; -import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData } from "./schemas"; +import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge } from "./schemas"; import { generateImageFromPrompt, downloadAndSaveImage, @@ -54,6 +54,7 @@ function ensureDoc(space: string): SocialsDoc { d.campaigns = {}; d.campaignFlows = {}; d.activeFlowId = ''; + d.campaignWorkflows = {}; }); _syncServer!.setDoc(docId, doc); } @@ -130,6 +131,16 @@ function seedTemplateSocials(space: string): void { }); } + // Seed campaign workflow if empty + if (Object.keys(doc.campaignWorkflows || {}).length === 0) { + const docId = socialsDocId(space); + const wf = buildDemoCampaignWorkflow(); + _syncServer.changeDoc(docId, "seed campaign workflow", (d) => { + if (!d.campaignWorkflows) d.campaignWorkflows = {} as any; + d.campaignWorkflows[wf.id] = wf; + }); + } + // Seed a sample thread if empty if (Object.keys(doc.threads || {}).length === 0) { const docId = socialsDocId(space); @@ -504,6 +515,197 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => { return c.json(data, res.status as any); }); +// ── Campaign Workflow CRUD API ── + +routes.get("/api/campaign-workflows", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); + const workflows = Object.values(doc.campaignWorkflows || {}); + workflows.sort((a, b) => a.name.localeCompare(b.name)); + return c.json({ count: workflows.length, results: workflows }); +}); + +routes.post("/api/campaign-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 = socialsDocId(dataSpace); + ensureDoc(dataSpace); + const wfId = crypto.randomUUID(); + const now = Date.now(); + + const workflow: CampaignWorkflow = { + id: wfId, + name: body.name || "New Campaign 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 campaign workflow ${wfId}`, (d) => { + if (!d.campaignWorkflows) d.campaignWorkflows = {} as any; + (d.campaignWorkflows as any)[wfId] = workflow; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.campaignWorkflows[wfId], 201); +}); + +routes.get("/api/campaign-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.campaignWorkflows?.[id]; + if (!wf) return c.json({ error: "Campaign workflow not found" }, 404); + return c.json(wf); +}); + +routes.put("/api/campaign-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 = socialsDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `update campaign workflow ${id}`, (d) => { + const wf = d.campaignWorkflows[id]; + if (!wf) return; + if (body.name !== undefined) wf.name = body.name; + if (body.enabled !== undefined) wf.enabled = body.enabled; + if (body.nodes !== undefined) { + 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.campaignWorkflows[id]); +}); + +routes.delete("/api/campaign-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 = socialsDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404); + + _syncServer!.changeDoc(docId, `delete campaign workflow ${id}`, (d) => { + delete d.campaignWorkflows[id]; + }); + + return c.json({ ok: true }); +}); + +// POST /api/campaign-workflows/:id/run — manual execute (stub) +routes.post("/api/campaign-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 doc = ensureDoc(dataSpace); + const wf = doc.campaignWorkflows?.[id]; + if (!wf) return c.json({ error: "Campaign workflow not found" }, 404); + + // Stub execution — topological walk, each node returns stub success + const results: { nodeId: string; status: string; message: string; durationMs: number }[] = []; + const sorted = topologicalSortCampaign(wf.nodes, wf.edges); + + for (const node of sorted) { + const start = Date.now(); + results.push({ + nodeId: node.id, + status: 'success', + message: `[stub] ${node.label} executed`, + durationMs: Date.now() - start, + }); + } + + // Update run metadata + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `run campaign workflow ${id}`, (d) => { + const w = d.campaignWorkflows[id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = 'success'; + w.runCount = (w.runCount || 0) + 1; + }); + + return c.json({ results }); +}); + +// POST /api/campaign-workflows/webhook/:hookId — external webhook trigger +routes.post("/api/campaign-workflows/webhook/:hookId", async (c) => { + const hookId = c.req.param("hookId"); + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + + const doc = ensureDoc(dataSpace); + // Find workflow containing a webhook-trigger node with this hookId + for (const wf of Object.values(doc.campaignWorkflows || {})) { + if (!wf.enabled) continue; + const hookNode = wf.nodes.find(n => + n.type === 'webhook-trigger' && n.config.hookId === hookId + ); + if (hookNode) { + return c.json({ ok: true, workflowId: wf.id, message: 'Webhook received (stub — execution not implemented yet)' }); + } + } + return c.json({ error: "No matching webhook trigger found" }, 404); +}); + +function topologicalSortCampaign(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): CampaignWorkflowNode[] { + 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: CampaignWorkflowNode[] = []; + while (queue.length > 0) { + const id = queue.shift()!; + const node = nodes.find(n => n.id === id); + if (node) sorted.push(node); + for (const next of adj.get(id) || []) { + const d = (inDegree.get(next) || 1) - 1; + inDegree.set(next, d); + if (d === 0) queue.push(next); + } + } + + return sorted; +} + // ── Page routes (inject web components) ── routes.get("/campaign", (c) => { @@ -620,14 +822,14 @@ routes.get("/threads", (c) => { routes.get("/campaigns", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ - title: `Campaign Planner — rSocials | rSpace`, + title: `Campaign Workflows — rSocials | rSpace`, moduleId: "rsocials", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", - body: ``, - styles: ``, - scripts: ``, + body: ``, + styles: ``, + scripts: ``, })); }); diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index 82ef91a..8592c4e 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -120,6 +120,241 @@ export interface CampaignFlow { createdBy: string | null; } +// ── Campaign workflow (n8n-style) types ── + +export type CampaignWorkflowNodeType = + // Triggers + | 'campaign-start' + | 'schedule-trigger' + | 'webhook-trigger' + // Delays + | 'wait-duration' + | 'wait-approval' + // Conditions + | 'engagement-check' + | 'time-window' + // Actions + | 'post-to-platform' + | 'cross-post' + | 'publish-thread' + | 'send-newsletter' + | 'post-webhook'; + +export type CampaignWorkflowNodeCategory = 'trigger' | 'delay' | 'condition' | 'action'; + +export interface CampaignWorkflowNodePort { + name: string; + type: 'trigger' | 'data' | 'boolean'; +} + +export interface CampaignWorkflowNode { + id: string; + type: CampaignWorkflowNodeType; + label: string; + position: { x: number; y: number }; + config: Record; + runtimeStatus?: 'idle' | 'running' | 'success' | 'error'; + runtimeMessage?: string; + runtimeDurationMs?: number; +} + +export interface CampaignWorkflowEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +export interface CampaignWorkflow { + id: string; + name: string; + enabled: boolean; + nodes: CampaignWorkflowNode[]; + edges: CampaignWorkflowEdge[]; + lastRunAt: number | null; + lastRunStatus: 'success' | 'error' | null; + runCount: number; + createdAt: number; + updatedAt: number; +} + +export interface CampaignWorkflowNodeDef { + type: CampaignWorkflowNodeType; + category: CampaignWorkflowNodeCategory; + label: string; + icon: string; + description: string; + inputs: CampaignWorkflowNodePort[]; + outputs: CampaignWorkflowNodePort[]; + configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[]; +} + +export const CAMPAIGN_NODE_CATALOG: CampaignWorkflowNodeDef[] = [ + // ── Triggers ── + { + type: 'campaign-start', + category: 'trigger', + label: 'Campaign Start', + icon: '\u25B6', + description: 'Manual kick-off for this campaign workflow', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }], + configSchema: [ + { key: 'description', label: 'Description', type: 'text', placeholder: 'Launch campaign' }, + ], + }, + { + type: 'schedule-trigger', + category: 'trigger', + label: 'Schedule Trigger', + icon: '\u23F0', + 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 * * 1' }, + { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' }, + ], + }, + { + type: 'webhook-trigger', + category: 'trigger', + label: 'Webhook Trigger', + icon: '\uD83D\uDD17', + 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' }, + ], + }, + // ── Delays ── + { + type: 'wait-duration', + category: 'delay', + label: 'Wait Duration', + icon: '\u23F3', + description: 'Wait for a specified amount of time', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'done', type: 'trigger' }], + configSchema: [ + { key: 'amount', label: 'Amount', type: 'number', placeholder: '2' }, + { key: 'unit', label: 'Unit', type: 'select', options: ['minutes', 'hours', 'days'] }, + ], + }, + { + type: 'wait-approval', + category: 'delay', + label: 'Wait for Approval', + icon: '\u270B', + description: 'Pause until a team member approves', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'approved', type: 'trigger' }, { name: 'rejected', type: 'trigger' }], + configSchema: [ + { key: 'approver', label: 'Approver', type: 'text', placeholder: 'Team lead' }, + { key: 'message', label: 'Approval Message', type: 'textarea', placeholder: 'Please review...' }, + ], + }, + // ── Conditions ── + { + type: 'engagement-check', + category: 'condition', + label: 'Engagement Check', + icon: '\uD83D\uDCC8', + description: 'Check if engagement exceeds a threshold', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'metrics', type: 'data' }], + outputs: [{ name: 'above', type: 'trigger' }, { name: 'below', type: 'trigger' }], + configSchema: [ + { key: 'metric', label: 'Metric', type: 'select', options: ['likes', 'retweets', 'replies', 'impressions', 'clicks'] }, + { key: 'threshold', label: 'Threshold', type: 'number', placeholder: '100' }, + ], + }, + { + type: 'time-window', + category: 'condition', + label: 'Time Window', + icon: '\uD83D\uDD50', + description: 'Check if current time is within posting hours', + 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 (1=Mon)', type: 'text', placeholder: '1,2,3,4,5' }, + ], + }, + // ── Actions ── + { + type: 'post-to-platform', + category: 'action', + label: 'Post to Platform', + icon: '\uD83D\uDCE4', + description: 'Publish a post to a social platform', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'postId', type: 'data' }], + configSchema: [ + { key: 'platform', label: 'Platform', type: 'select', options: ['X', 'LinkedIn', 'Instagram', 'Threads', 'Bluesky', 'YouTube'] }, + { key: 'content', label: 'Post Content', type: 'textarea', placeholder: 'Your post text...' }, + { key: 'hashtags', label: 'Hashtags', type: 'text', placeholder: '#launch #campaign' }, + ], + }, + { + type: 'cross-post', + category: 'action', + label: 'Cross-Post', + icon: '\uD83D\uDCE1', + description: 'Broadcast to multiple platforms at once', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'results', type: 'data' }], + configSchema: [ + { key: 'platforms', label: 'Platforms (comma-sep)', type: 'text', placeholder: 'X, LinkedIn, Threads' }, + { key: 'content', label: 'Post Content', type: 'textarea', placeholder: 'Shared content...' }, + { key: 'adaptPerPlatform', label: 'Adapt per platform', type: 'select', options: ['yes', 'no'] }, + ], + }, + { + type: 'publish-thread', + category: 'action', + label: 'Publish Thread', + icon: '\uD83E\uDDF5', + description: 'Publish a multi-tweet thread', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'threadId', type: 'data' }], + configSchema: [ + { key: 'platform', label: 'Platform', type: 'select', options: ['X', 'Bluesky', 'Threads'] }, + { key: 'threadContent', label: 'Thread (--- between tweets)', type: 'textarea', placeholder: 'Tweet 1\n---\nTweet 2\n---\nTweet 3' }, + ], + }, + { + type: 'send-newsletter', + category: 'action', + label: 'Send Newsletter', + icon: '\uD83D\uDCE7', + description: 'Send an email campaign via Listmonk', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'content', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'campaignId', type: 'data' }], + configSchema: [ + { key: 'subject', label: 'Email Subject', type: 'text', placeholder: 'Newsletter: Campaign Update' }, + { key: 'listId', label: 'List ID', type: 'text', placeholder: '1' }, + { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '

Hello {{name}}

' }, + ], + }, + { + type: 'post-webhook', + category: 'action', + label: 'POST Webhook', + icon: '\uD83C\uDF10', + 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: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "campaign_step"}' }, + ], + }, +]; + // ── Document root ── export interface SocialsDoc { @@ -134,6 +369,7 @@ export interface SocialsDoc { campaigns: Record; campaignFlows: Record; activeFlowId: string; + campaignWorkflows: Record; } // ── Schema registration ── @@ -141,12 +377,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { module: 'socials', collection: 'data', - version: 2, + version: 3, init: (): SocialsDoc => ({ meta: { module: 'socials', collection: 'data', - version: 2, + version: 3, spaceSlug: '', createdAt: Date.now(), }, @@ -154,11 +390,13 @@ export const socialsSchema: DocSchema = { campaigns: {}, campaignFlows: {}, activeFlowId: '', + campaignWorkflows: {}, }), migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => { if (!doc.campaignFlows) (doc as any).campaignFlows = {}; if (!doc.activeFlowId) (doc as any).activeFlowId = ''; - if (doc.meta) doc.meta.version = 2; + if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {}; + if (doc.meta) doc.meta.version = 3; return doc; }, }; diff --git a/vite.config.ts b/vite.config.ts index 380e4e1..7835943 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -819,6 +819,37 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rsocials/campaign-planner.css"), ); + // Build campaign workflow builder component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rsocials/components"), + resolve: { + alias: { + "../schemas": resolve(__dirname, "modules/rsocials/schemas.ts"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rsocials"), + lib: { + entry: resolve(__dirname, "modules/rsocials/components/folk-campaign-workflow.ts"), + formats: ["es"], + fileName: () => "folk-campaign-workflow.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-campaign-workflow.js", + }, + }, + }, + }); + + // Copy campaign workflow CSS + copyFileSync( + resolve(__dirname, "modules/rsocials/components/campaign-workflow.css"), + resolve(__dirname, "dist/modules/rsocials/campaign-workflow.css"), + ); + // Build newsletter manager component await build({ configFile: false,