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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 21:29:09 -07:00
parent 44dff1991f
commit 347ba73942
7 changed files with 2129 additions and 11 deletions

View File

@ -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: '<h1>MycoFi Earth Launch</h1><p>The regenerative finance platform is now live. Join the first funding circle.</p>',
},
},
];
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,
};
}

View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(docId, `Delete campaign workflow ${id}`, (d) => {
if (d.campaignWorkflows?.[id]) delete d.campaignWorkflows[id];
});
}
// ── Events ──
onChange(cb: (doc: SocialsDoc) => void): () => void {

View File

@ -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<SocialsDoc>(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<SocialsDoc>(docId, `create campaign workflow ${wfId}`, (d) => {
if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
(d.campaignWorkflows as any)[wfId] = workflow;
});
const updated = _syncServer!.getDoc<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<string, string[]>();
const inDegree = new Map<string, number>();
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: `<folk-campaign-planner space="${escapeHtml(space)}"></folk-campaign-planner>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css"><link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`,
body: `<folk-campaign-workflow space="${escapeHtml(space)}"></folk-campaign-workflow>`,
styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-workflow.css">`,
scripts: `<script type="module" src="/modules/rsocials/folk-campaign-workflow.js"></script>`,
}));
});

View File

@ -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<string, unknown>;
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: '<p>Hello {{name}}</p>' },
],
},
{
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<string, Campaign>;
campaignFlows: Record<string, CampaignFlow>;
activeFlowId: string;
campaignWorkflows: Record<string, CampaignWorkflow>;
}
// ── Schema registration ──
@ -141,12 +377,12 @@ export interface SocialsDoc {
export const socialsSchema: DocSchema<SocialsDoc> = {
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<SocialsDoc> = {
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;
},
};

View File

@ -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,