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:
parent
44dff1991f
commit
347ba73942
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue