feat(rschedule): n8n-style automation canvas at /:space/rschedule/reminders
Visual workflow builder with drag-and-drop node palette (15 node types across triggers, conditions, and actions), SVG canvas with Bezier wiring, config panel, REST-persisted CRUD, topological execution engine, cron tick loop integration, webhook trigger endpoint, and two demo workflows (proximity notification + document sign-off pipeline). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
61b25e299f
commit
8bc7787d37
|
|
@ -0,0 +1,551 @@
|
|||
/* rSchedule Automation Canvas — n8n-style workflow builder */
|
||||
folk-automation-canvas {
|
||||
display: block;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.ac-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
/* ── Toolbar ── */
|
||||
.ac-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
min-height: 46px;
|
||||
border-bottom: 1px solid var(--rs-border, #2d2d44);
|
||||
background: var(--rs-bg-surface, #1a1a2e);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ac-toolbar__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ac-toolbar__title input {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.ac-toolbar__title input:hover,
|
||||
.ac-toolbar__title input:focus {
|
||||
border-color: var(--rs-border-strong, #3d3d5c);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ac-toolbar__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ac-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--rs-input-border, #3d3d5c);
|
||||
background: var(--rs-input-bg, #16162a);
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ac-btn:hover {
|
||||
border-color: var(--rs-border-strong, #4d4d6c);
|
||||
}
|
||||
|
||||
.ac-btn--run {
|
||||
background: #3b82f622;
|
||||
border-color: #3b82f655;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.ac-btn--run:hover {
|
||||
background: #3b82f633;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.ac-btn--save {
|
||||
background: #10b98122;
|
||||
border-color: #10b98155;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.ac-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.ac-toggle input[type="checkbox"] {
|
||||
accent-color: #10b981;
|
||||
}
|
||||
|
||||
.ac-save-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
/* ── Canvas area ── */
|
||||
.ac-canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── Left sidebar — node palette ── */
|
||||
.ac-palette {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid var(--rs-border, #2d2d44);
|
||||
background: var(--rs-bg-surface, #1a1a2e);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ac-palette__group-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ac-palette__card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--rs-border, #2d2d44);
|
||||
background: var(--rs-input-bg, #16162a);
|
||||
cursor: grab;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ac-palette__card:hover {
|
||||
border-color: #6366f1;
|
||||
background: #6366f111;
|
||||
}
|
||||
|
||||
.ac-palette__card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ac-palette__card-icon {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ac-palette__card-label {
|
||||
font-weight: 500;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
/* ── SVG canvas ── */
|
||||
.ac-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
background: var(--rs-canvas-bg, #0f0f23);
|
||||
}
|
||||
|
||||
.ac-canvas.grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.ac-canvas.wiring {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.ac-canvas svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Right sidebar — config ── */
|
||||
.ac-config {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
border-left: 1px solid var(--rs-border, #2d2d44);
|
||||
background: var(--rs-bg-surface, #1a1a2e);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.ac-config.open {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.ac-config__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--rs-border, #2d2d44);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ac-config__header-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ac-config__body {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ac-config__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ac-config__field label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.ac-config__field input,
|
||||
.ac-config__field select,
|
||||
.ac-config__field textarea {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--rs-input-border, #3d3d5c);
|
||||
background: var(--rs-input-bg, #16162a);
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ac-config__field textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.ac-config__field input:focus,
|
||||
.ac-config__field select:focus,
|
||||
.ac-config__field textarea:focus {
|
||||
border-color: #3b82f6;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ac-config__delete {
|
||||
margin-top: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ef444455;
|
||||
background: #ef444422;
|
||||
color: #f87171;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ac-config__delete:hover {
|
||||
background: #ef444433;
|
||||
}
|
||||
|
||||
/* ── Execution log in config panel ── */
|
||||
.ac-exec-log {
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--rs-border, #2d2d44);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.ac-exec-log__title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ac-exec-log__entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.ac-exec-log__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ac-exec-log__dot.success { background: #22c55e; }
|
||||
.ac-exec-log__dot.error { background: #ef4444; }
|
||||
.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
|
||||
|
||||
@keyframes ac-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ── Zoom controls ── */
|
||||
.ac-zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--rs-bg-surface, #1a1a2e);
|
||||
border: 1px solid var(--rs-border-strong, #3d3d5c);
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.ac-zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ac-zoom-btn:hover {
|
||||
background: var(--rs-bg-surface-raised, #252545);
|
||||
}
|
||||
|
||||
.ac-zoom-level {
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Node styles in SVG ── */
|
||||
.ac-node { cursor: pointer; }
|
||||
.ac-node.selected > foreignObject > div {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ac-node foreignObject > div {
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--rs-border, #2d2d44);
|
||||
background: var(--rs-bg-surface, #1a1a2e);
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ac-node:hover foreignObject > div {
|
||||
border-color: #4f46e5 !important;
|
||||
}
|
||||
|
||||
.ac-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--rs-border, #2d2d44);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.ac-node-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ac-node-label {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ac-node-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ac-node-status.idle { background: #4b5563; }
|
||||
.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
|
||||
.ac-node-status.success { background: #22c55e; }
|
||||
.ac-node-status.error { background: #ef4444; }
|
||||
|
||||
.ac-node-ports {
|
||||
padding: 6px 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.ac-node-inputs,
|
||||
.ac-node-outputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ac-node-outputs {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.ac-port-label {
|
||||
font-size: 10px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
/* ── Port handles in SVG ── */
|
||||
.ac-port-group { cursor: crosshair; }
|
||||
.ac-port-dot {
|
||||
transition: r 0.15s, filter 0.15s;
|
||||
}
|
||||
|
||||
.ac-port-group:hover .ac-port-dot {
|
||||
r: 7;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
}
|
||||
|
||||
.ac-port-group--wiring-source .ac-port-dot {
|
||||
r: 7;
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
|
||||
.ac-port-group--wiring-target .ac-port-dot {
|
||||
r: 7;
|
||||
animation: port-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ac-port-group--wiring-dimmed .ac-port-dot {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@keyframes port-pulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 3px currentColor); }
|
||||
50% { filter: drop-shadow(0 0 8px currentColor); }
|
||||
}
|
||||
|
||||
/* ── Edge styles ── */
|
||||
.ac-edge-group { pointer-events: stroke; }
|
||||
|
||||
.ac-edge-path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.ac-edge-hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 16;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ac-edge-path.running {
|
||||
animation: ac-edge-flow 1s linear infinite;
|
||||
stroke-dasharray: 8 4;
|
||||
}
|
||||
|
||||
@keyframes ac-edge-flow {
|
||||
to { stroke-dashoffset: -24; }
|
||||
}
|
||||
|
||||
/* ── Wiring temp line ── */
|
||||
.ac-wiring-temp {
|
||||
fill: none;
|
||||
stroke: #6366f1;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6 4;
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Workflow selector ── */
|
||||
.ac-workflow-select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--rs-input-border, #3d3d5c);
|
||||
background: var(--rs-input-bg, #16162a);
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.ac-palette {
|
||||
width: 160px;
|
||||
min-width: 160px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ac-config.open {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.ac-toolbar {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,7 +30,11 @@ import type {
|
|||
ExecutionLogEntry,
|
||||
ActionType,
|
||||
Reminder,
|
||||
Workflow,
|
||||
WorkflowNode,
|
||||
WorkflowEdge,
|
||||
} from "./schemas";
|
||||
import { NODE_CATALOG } from "./schemas";
|
||||
import { calendarDocId } from "../rcal/schemas";
|
||||
import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas";
|
||||
|
||||
|
|
@ -73,6 +77,7 @@ function ensureDoc(space: string): ScheduleDoc {
|
|||
d.meta.spaceSlug = space;
|
||||
d.jobs = {};
|
||||
d.reminders = {};
|
||||
d.workflows = {};
|
||||
d.log = [];
|
||||
},
|
||||
);
|
||||
|
|
@ -667,6 +672,38 @@ function startTickLoop() {
|
|||
console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Process due automation workflows ──
|
||||
const workflows = Object.values(doc.workflows || {});
|
||||
for (const wf of workflows) {
|
||||
if (!wf.enabled) continue;
|
||||
const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron");
|
||||
for (const cronNode of cronNodes) {
|
||||
const expr = String(cronNode.config.cronExpression || "");
|
||||
const tz = String(cronNode.config.timezone || "UTC");
|
||||
if (!expr) continue;
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(expr, {
|
||||
currentDate: new Date(now - TICK_INTERVAL),
|
||||
tz,
|
||||
});
|
||||
const nextDate = interval.next().toDate();
|
||||
if (nextDate.getTime() <= now) {
|
||||
console.log(`[Schedule] Running cron workflow "${wf.name}" for space ${space}`);
|
||||
const results = await executeWorkflow(wf, space);
|
||||
const allOk = results.every(r => r.status !== "error");
|
||||
_syncServer.changeDoc<ScheduleDoc>(docId, `tick workflow ${wf.id}`, (d) => {
|
||||
const w = d.workflows[wf.id];
|
||||
if (!w) return;
|
||||
w.lastRunAt = Date.now();
|
||||
w.lastRunStatus = allOk ? "success" : "error";
|
||||
w.runCount = (w.runCount || 0) + 1;
|
||||
w.updatedAt = Date.now();
|
||||
});
|
||||
}
|
||||
} catch { /* invalid cron — skip */ }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Schedule] Tick error for space ${space}:`, e);
|
||||
}
|
||||
|
|
@ -1318,6 +1355,569 @@ routes.post("/api/reminders/:id/snooze", async (c) => {
|
|||
return c.json(updated.reminders[id]);
|
||||
});
|
||||
|
||||
// ── Automation canvas page route ──
|
||||
|
||||
routes.get("/reminders", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
return c.html(
|
||||
renderShell({
|
||||
title: `${space} — Automations | rSpace`,
|
||||
moduleId: "rschedule",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `<folk-automation-canvas space="${space}"></folk-automation-canvas>`,
|
||||
scripts: `<script type="module" src="/modules/rschedule/folk-automation-canvas.js"></script>`,
|
||||
styles: `<link rel="stylesheet" href="/modules/rschedule/automation-canvas.css">`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Workflow CRUD API ──
|
||||
|
||||
routes.get("/api/workflows", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const doc = ensureDoc(dataSpace);
|
||||
// Ensure workflows field exists on older docs
|
||||
const workflows = Object.values(doc.workflows || {});
|
||||
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return c.json({ count: workflows.length, results: workflows });
|
||||
});
|
||||
|
||||
routes.post("/api/workflows", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const body = await c.req.json();
|
||||
|
||||
const docId = scheduleDocId(dataSpace);
|
||||
ensureDoc(dataSpace);
|
||||
const wfId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const workflow: Workflow = {
|
||||
id: wfId,
|
||||
name: body.name || "New Workflow",
|
||||
enabled: body.enabled !== false,
|
||||
nodes: body.nodes || [],
|
||||
edges: body.edges || [],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: null,
|
||||
runCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `create workflow ${wfId}`, (d) => {
|
||||
if (!d.workflows) d.workflows = {} as any;
|
||||
(d.workflows as any)[wfId] = workflow;
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
||||
return c.json(updated.workflows[wfId], 201);
|
||||
});
|
||||
|
||||
routes.get("/api/workflows/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const id = c.req.param("id");
|
||||
const doc = ensureDoc(dataSpace);
|
||||
|
||||
const wf = doc.workflows?.[id];
|
||||
if (!wf) return c.json({ error: "Workflow not found" }, 404);
|
||||
return c.json(wf);
|
||||
});
|
||||
|
||||
routes.put("/api/workflows/:id", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
|
||||
const docId = scheduleDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `update workflow ${id}`, (d) => {
|
||||
const wf = d.workflows[id];
|
||||
if (!wf) return;
|
||||
if (body.name !== undefined) wf.name = body.name;
|
||||
if (body.enabled !== undefined) wf.enabled = body.enabled;
|
||||
if (body.nodes !== undefined) {
|
||||
// Replace the nodes array
|
||||
while (wf.nodes.length > 0) wf.nodes.splice(0, 1);
|
||||
for (const n of body.nodes) wf.nodes.push(n);
|
||||
}
|
||||
if (body.edges !== undefined) {
|
||||
while (wf.edges.length > 0) wf.edges.splice(0, 1);
|
||||
for (const e of body.edges) wf.edges.push(e);
|
||||
}
|
||||
wf.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
const updated = _syncServer!.getDoc<ScheduleDoc>(docId)!;
|
||||
return c.json(updated.workflows[id]);
|
||||
});
|
||||
|
||||
routes.delete("/api/workflows/:id", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const id = c.req.param("id");
|
||||
|
||||
const docId = scheduleDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404);
|
||||
|
||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `delete workflow ${id}`, (d) => {
|
||||
delete d.workflows[id];
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Workflow execution engine ──
|
||||
|
||||
interface NodeResult {
|
||||
nodeId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
message: string;
|
||||
durationMs: number;
|
||||
outputData?: unknown;
|
||||
}
|
||||
|
||||
function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] {
|
||||
const adj = new Map<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: string[] = [];
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
sorted.push(id);
|
||||
for (const neighbor of adj.get(id) || []) {
|
||||
const newDeg = (inDegree.get(neighbor) || 1) - 1;
|
||||
inDegree.set(neighbor, newDeg);
|
||||
if (newDeg === 0) queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
return sorted.map(id => nodeMap.get(id)!).filter(Boolean);
|
||||
}
|
||||
|
||||
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371;
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
async function executeWorkflowNode(
|
||||
node: WorkflowNode,
|
||||
inputData: unknown,
|
||||
space: string,
|
||||
): Promise<{ success: boolean; message: string; outputData?: unknown }> {
|
||||
const cfg = node.config;
|
||||
|
||||
switch (node.type) {
|
||||
// ── Triggers ──
|
||||
case "trigger-cron":
|
||||
return { success: true, message: "Cron triggered", outputData: { timestamp: Date.now() } };
|
||||
|
||||
case "trigger-data-change":
|
||||
return { success: true, message: `Watching ${cfg.module || "any"} module`, outputData: inputData || {} };
|
||||
|
||||
case "trigger-webhook":
|
||||
return { success: true, message: "Webhook received", outputData: inputData || {} };
|
||||
|
||||
case "trigger-manual":
|
||||
return { success: true, message: "Manual trigger fired", outputData: { timestamp: Date.now() } };
|
||||
|
||||
case "trigger-proximity": {
|
||||
const data = inputData as { lat?: number; lng?: number } | undefined;
|
||||
if (!data?.lat || !data?.lng) return { success: true, message: "No location data", outputData: { distance: null } };
|
||||
const dist = haversineKm(data.lat, data.lng, Number(cfg.lat) || 0, Number(cfg.lng) || 0);
|
||||
const inRange = dist <= (Number(cfg.radiusKm) || 1);
|
||||
return { success: true, message: `Distance: ${dist.toFixed(2)}km (${inRange ? "in range" : "out of range"})`, outputData: { distance: dist, inRange } };
|
||||
}
|
||||
|
||||
// ── Conditions ──
|
||||
case "condition-compare": {
|
||||
const val = String(inputData ?? "");
|
||||
const cmp = String(cfg.compareValue ?? "");
|
||||
let result = false;
|
||||
switch (cfg.operator) {
|
||||
case "equals": result = val === cmp; break;
|
||||
case "not-equals": result = val !== cmp; break;
|
||||
case "greater-than": result = Number(val) > Number(cmp); break;
|
||||
case "less-than": result = Number(val) < Number(cmp); break;
|
||||
case "contains": result = val.includes(cmp); break;
|
||||
}
|
||||
return { success: true, message: `Compare: ${result}`, outputData: result };
|
||||
}
|
||||
|
||||
case "condition-geofence": {
|
||||
const coords = inputData as { lat?: number; lng?: number } | undefined;
|
||||
if (!coords?.lat || !coords?.lng) return { success: true, message: "No coords", outputData: false };
|
||||
const dist = haversineKm(coords.lat, coords.lng, Number(cfg.centerLat) || 0, Number(cfg.centerLng) || 0);
|
||||
const inside = dist <= (Number(cfg.radiusKm) || 5);
|
||||
return { success: true, message: `Geofence: ${inside ? "inside" : "outside"} (${dist.toFixed(2)}km)`, outputData: inside };
|
||||
}
|
||||
|
||||
case "condition-time-window": {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
const day = now.getDay();
|
||||
const startH = Number(cfg.startHour) || 0;
|
||||
const endH = Number(cfg.endHour) || 23;
|
||||
const days = String(cfg.days || "0,1,2,3,4,5,6").split(",").map(Number);
|
||||
const inWindow = hour >= startH && hour < endH && days.includes(day);
|
||||
return { success: true, message: `Time window: ${inWindow ? "in" : "outside"}`, outputData: inWindow };
|
||||
}
|
||||
|
||||
case "condition-data-filter": {
|
||||
const data = inputData as Record<string, unknown> | undefined;
|
||||
const field = String(cfg.field || "");
|
||||
const val = data?.[field];
|
||||
let match = false;
|
||||
switch (cfg.operator) {
|
||||
case "equals": match = String(val) === String(cfg.value); break;
|
||||
case "not-equals": match = String(val) !== String(cfg.value); break;
|
||||
case "contains": match = String(val ?? "").includes(String(cfg.value ?? "")); break;
|
||||
case "exists": match = val !== undefined && val !== null; break;
|
||||
}
|
||||
return { success: true, message: `Filter: ${match ? "match" : "no match"}`, outputData: match ? data : null };
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
case "action-send-email": {
|
||||
const transport = getSmtpTransport();
|
||||
if (!transport) return { success: false, message: "SMTP not configured" };
|
||||
if (!cfg.to) return { success: false, message: "No recipient" };
|
||||
|
||||
const vars: Record<string, string> = {
|
||||
date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }),
|
||||
timestamp: new Date().toISOString(),
|
||||
...(typeof inputData === "object" && inputData !== null
|
||||
? Object.fromEntries(Object.entries(inputData as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
|
||||
: {}),
|
||||
};
|
||||
|
||||
const subject = renderTemplate(String(cfg.subject || "Automation Notification"), vars);
|
||||
const html = renderTemplate(String(cfg.bodyTemplate || `<p>Automation executed at ${vars.date}.</p>`), vars);
|
||||
|
||||
await transport.sendMail({
|
||||
from: process.env.SMTP_FROM || "rSchedule <noreply@rmail.online>",
|
||||
to: String(cfg.to),
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
return { success: true, message: `Email sent to ${cfg.to}` };
|
||||
}
|
||||
|
||||
case "action-post-webhook": {
|
||||
if (!cfg.url) return { success: false, message: "No URL configured" };
|
||||
const method = String(cfg.method || "POST").toUpperCase();
|
||||
const vars: Record<string, string> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
...(typeof inputData === "object" && inputData !== null
|
||||
? Object.fromEntries(Object.entries(inputData as Record<string, unknown>).map(([k, v]) => [k, String(v)]))
|
||||
: {}),
|
||||
};
|
||||
const body = renderTemplate(String(cfg.bodyTemplate || JSON.stringify({ timestamp: vars.timestamp })), vars);
|
||||
const res = await fetch(String(cfg.url), {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: method !== "GET" ? body : undefined,
|
||||
});
|
||||
if (!res.ok) return { success: false, message: `Webhook ${res.status}` };
|
||||
return { success: true, message: `Webhook ${method} ${cfg.url} -> ${res.status}`, outputData: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
case "action-create-event": {
|
||||
if (!_syncServer) return { success: false, message: "SyncServer unavailable" };
|
||||
const calDocId = calendarDocId(space);
|
||||
const calDoc = _syncServer.getDoc<CalendarDoc>(calDocId);
|
||||
if (!calDoc) return { success: false, message: "Calendar doc not found" };
|
||||
|
||||
const eventId = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const durationMs = (Number(cfg.durationMinutes) || 60) * 60 * 1000;
|
||||
|
||||
_syncServer.changeDoc<CalendarDoc>(calDocId, `automation: create event`, (d) => {
|
||||
d.events[eventId] = {
|
||||
id: eventId,
|
||||
title: String(cfg.title || "Automation Event"),
|
||||
description: "Created by rSchedule automation",
|
||||
startTime: now,
|
||||
endTime: now + durationMs,
|
||||
allDay: false,
|
||||
timezone: "UTC",
|
||||
rrule: null,
|
||||
status: null,
|
||||
visibility: null,
|
||||
sourceId: null,
|
||||
sourceName: null,
|
||||
sourceType: null,
|
||||
sourceColor: null,
|
||||
locationId: null,
|
||||
locationName: null,
|
||||
coordinates: null,
|
||||
locationGranularity: null,
|
||||
locationLat: null,
|
||||
locationLng: null,
|
||||
isVirtual: false,
|
||||
virtualUrl: null,
|
||||
virtualPlatform: null,
|
||||
rToolSource: "rSchedule",
|
||||
rToolEntityId: node.id,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } };
|
||||
}
|
||||
|
||||
case "action-create-task":
|
||||
return { success: true, message: `Task "${cfg.title || "New task"}" queued`, outputData: { taskTitle: cfg.title } };
|
||||
|
||||
case "action-send-notification":
|
||||
console.log(`[Automation] Notification: ${cfg.title || "Notification"} — ${cfg.message || ""}`);
|
||||
return { success: true, message: `Notification: ${cfg.title}` };
|
||||
|
||||
case "action-update-data":
|
||||
return { success: true, message: `Data update queued for ${cfg.module || "unknown"}` };
|
||||
|
||||
default:
|
||||
return { success: false, message: `Unknown node type: ${node.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflow(
|
||||
workflow: Workflow,
|
||||
space: string,
|
||||
triggerData?: unknown,
|
||||
): Promise<NodeResult[]> {
|
||||
const sorted = topologicalSort(workflow.nodes, workflow.edges);
|
||||
const results: NodeResult[] = [];
|
||||
const nodeOutputs = new Map<string, unknown>();
|
||||
|
||||
for (const node of sorted) {
|
||||
const startMs = Date.now();
|
||||
|
||||
// Gather input data from upstream edges
|
||||
let inputData: unknown = triggerData;
|
||||
const incomingEdges = workflow.edges.filter(e => e.toNode === node.id);
|
||||
if (incomingEdges.length > 0) {
|
||||
// For conditions that output booleans: if the upstream condition result is false
|
||||
// and this node connects via the "true" port, skip it
|
||||
const upstreamNode = workflow.nodes.find(n => n.id === incomingEdges[0].fromNode);
|
||||
const upstreamOutput = nodeOutputs.get(incomingEdges[0].fromNode);
|
||||
|
||||
if (upstreamNode?.type.startsWith("condition-")) {
|
||||
const port = incomingEdges[0].fromPort;
|
||||
// Boolean result from condition
|
||||
if (port === "true" && upstreamOutput === false) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Condition false, skipping true branch", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "false" && upstreamOutput === true) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Condition true, skipping false branch", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "inside" && upstreamOutput === false) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Outside geofence, skipping inside branch", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "outside" && upstreamOutput === true) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Inside geofence, skipping outside branch", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "in-window" && upstreamOutput === false) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Outside time window", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "match" && upstreamOutput === null) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Data filter: no match", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
if (port === "no-match" && upstreamOutput !== null) {
|
||||
results.push({ nodeId: node.id, status: "skipped", message: "Data filter: matched", durationMs: 0 });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Use upstream output as input
|
||||
if (upstreamOutput !== undefined) inputData = upstreamOutput;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeWorkflowNode(node, inputData, space);
|
||||
const durationMs = Date.now() - startMs;
|
||||
nodeOutputs.set(node.id, result.outputData);
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
status: result.success ? "success" : "error",
|
||||
message: result.message,
|
||||
durationMs,
|
||||
outputData: result.outputData,
|
||||
});
|
||||
} catch (e: any) {
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
status: "error",
|
||||
message: e.message || String(e),
|
||||
durationMs: Date.now() - startMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// POST /api/workflows/:id/run — manual execute
|
||||
routes.post("/api/workflows/:id/run", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const id = c.req.param("id");
|
||||
|
||||
const docId = scheduleDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const wf = doc.workflows?.[id];
|
||||
if (!wf) return c.json({ error: "Workflow not found" }, 404);
|
||||
|
||||
const results = await executeWorkflow(wf, dataSpace);
|
||||
|
||||
const allOk = results.every(r => r.status !== "error");
|
||||
_syncServer!.changeDoc<ScheduleDoc>(docId, `run workflow ${id}`, (d) => {
|
||||
const w = d.workflows[id];
|
||||
if (!w) return;
|
||||
w.lastRunAt = Date.now();
|
||||
w.lastRunStatus = allOk ? "success" : "error";
|
||||
w.runCount = (w.runCount || 0) + 1;
|
||||
w.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
return c.json({ success: allOk, results });
|
||||
});
|
||||
|
||||
// POST /api/workflows/webhook/:hookId — external webhook trigger
|
||||
routes.post("/api/workflows/webhook/:hookId", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
|
||||
const hookId = c.req.param("hookId");
|
||||
|
||||
const doc = ensureDoc(dataSpace);
|
||||
let payload: unknown = {};
|
||||
try { payload = await c.req.json(); } catch { /* empty payload */ }
|
||||
|
||||
// Find workflows with a trigger-webhook node matching this hookId
|
||||
const matches: Workflow[] = [];
|
||||
for (const wf of Object.values(doc.workflows || {})) {
|
||||
if (!wf.enabled) continue;
|
||||
for (const node of wf.nodes) {
|
||||
if (node.type === "trigger-webhook" && node.config.hookId === hookId) {
|
||||
matches.push(wf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return c.json({ error: "No matching workflow" }, 404);
|
||||
|
||||
const allResults: { workflowId: string; results: NodeResult[] }[] = [];
|
||||
for (const wf of matches) {
|
||||
const results = await executeWorkflow(wf, dataSpace, payload);
|
||||
allResults.push({ workflowId: wf.id, results });
|
||||
}
|
||||
|
||||
return c.json({ triggered: matches.length, results: allResults });
|
||||
});
|
||||
|
||||
// ── Demo workflow seeds ──
|
||||
|
||||
function seedDemoWorkflows(space: string) {
|
||||
const docId = scheduleDocId(space);
|
||||
const doc = ensureDoc(space);
|
||||
|
||||
if (Object.keys(doc.workflows || {}).length > 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const demo1Id = "demo-arriving-home";
|
||||
const demo1: Workflow = {
|
||||
id: demo1Id,
|
||||
name: "Arriving Home Notification",
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } },
|
||||
{ id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } },
|
||||
{ id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "<p>I'll be home in about {{distance}}km.</p>" } },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" },
|
||||
{ id: "e2", fromNode: "n1", fromPort: "distance", toNode: "n2", toPort: "coords" },
|
||||
{ id: "e3", fromNode: "n2", fromPort: "inside", toNode: "n3", toPort: "trigger" },
|
||||
],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: null,
|
||||
runCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const demo2Id = "demo-signoff-pipeline";
|
||||
const demo2: Workflow = {
|
||||
id: demo2Id,
|
||||
name: "Document Sign-off Pipeline",
|
||||
enabled: false,
|
||||
nodes: [
|
||||
{ id: "n1", type: "trigger-data-change", label: "Watch Sign-offs", position: { x: 50, y: 100 }, config: { module: "rnotes", field: "status" } },
|
||||
{ id: "n2", type: "condition-compare", label: "Status = Signed", position: { x: 350, y: 100 }, config: { operator: "equals", compareValue: "signed" } },
|
||||
{ id: "n3", type: "action-create-event", label: "Schedule Review", position: { x: 650, y: 60 }, config: { title: "Document Review Meeting", durationMinutes: "30" } },
|
||||
{ id: "n4", type: "action-send-notification", label: "Notify Comms", position: { x: 650, y: 180 }, config: { title: "Sign-off Complete", message: "Document has been signed off", level: "success" } },
|
||||
],
|
||||
edges: [
|
||||
{ id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" },
|
||||
{ id: "e2", fromNode: "n1", fromPort: "data", toNode: "n2", toPort: "value" },
|
||||
{ id: "e3", fromNode: "n2", fromPort: "true", toNode: "n3", toPort: "trigger" },
|
||||
{ id: "e4", fromNode: "n2", fromPort: "true", toNode: "n4", toPort: "trigger" },
|
||||
],
|
||||
lastRunAt: null,
|
||||
lastRunStatus: null,
|
||||
runCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
_syncServer!.changeDoc<ScheduleDoc>(docId, "seed demo workflows", (d) => {
|
||||
if (!d.workflows) d.workflows = {} as any;
|
||||
(d.workflows as any)[demo1Id] = demo1;
|
||||
(d.workflows as any)[demo2Id] = demo2;
|
||||
});
|
||||
|
||||
console.log(`[Schedule] Seeded 2 demo workflows for space "${space}"`);
|
||||
}
|
||||
|
||||
// ── Module export ──
|
||||
|
||||
export const scheduleModule: RSpaceModule = {
|
||||
|
|
@ -1339,6 +1939,7 @@ export const scheduleModule: RSpaceModule = {
|
|||
async onInit(ctx) {
|
||||
_syncServer = ctx.syncServer;
|
||||
seedDefaultJobs("demo");
|
||||
seedDemoWorkflows("demo");
|
||||
startTickLoop();
|
||||
},
|
||||
feeds: [
|
||||
|
|
@ -1353,6 +1954,7 @@ export const scheduleModule: RSpaceModule = {
|
|||
outputPaths: [
|
||||
{ path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" },
|
||||
{ path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" },
|
||||
{ path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" },
|
||||
{ path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,6 +75,285 @@ export interface Reminder {
|
|||
updatedAt: number;
|
||||
}
|
||||
|
||||
// ── Workflow / Automation types ──
|
||||
|
||||
export type AutomationNodeType =
|
||||
// Triggers
|
||||
| 'trigger-cron'
|
||||
| 'trigger-data-change'
|
||||
| 'trigger-webhook'
|
||||
| 'trigger-manual'
|
||||
| 'trigger-proximity'
|
||||
// Conditions
|
||||
| 'condition-compare'
|
||||
| 'condition-geofence'
|
||||
| 'condition-time-window'
|
||||
| 'condition-data-filter'
|
||||
// Actions
|
||||
| 'action-send-email'
|
||||
| 'action-post-webhook'
|
||||
| 'action-create-event'
|
||||
| 'action-create-task'
|
||||
| 'action-send-notification'
|
||||
| 'action-update-data';
|
||||
|
||||
export type AutomationNodeCategory = 'trigger' | 'condition' | 'action';
|
||||
|
||||
export interface WorkflowNodePort {
|
||||
name: string;
|
||||
type: 'trigger' | 'data' | 'boolean';
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string;
|
||||
type: AutomationNodeType;
|
||||
label: string;
|
||||
position: { x: number; y: number };
|
||||
config: Record<string, unknown>;
|
||||
// Runtime state (not persisted to Automerge — set during execution)
|
||||
runtimeStatus?: 'idle' | 'running' | 'success' | 'error';
|
||||
runtimeMessage?: string;
|
||||
runtimeDurationMs?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowEdge {
|
||||
id: string;
|
||||
fromNode: string;
|
||||
fromPort: string;
|
||||
toNode: string;
|
||||
toPort: string;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
lastRunAt: number | null;
|
||||
lastRunStatus: 'success' | 'error' | null;
|
||||
runCount: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AutomationNodeDef {
|
||||
type: AutomationNodeType;
|
||||
category: AutomationNodeCategory;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
inputs: WorkflowNodePort[];
|
||||
outputs: WorkflowNodePort[];
|
||||
configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[];
|
||||
}
|
||||
|
||||
export const NODE_CATALOG: AutomationNodeDef[] = [
|
||||
// ── Triggers ──
|
||||
{
|
||||
type: 'trigger-cron',
|
||||
category: 'trigger',
|
||||
label: 'Cron Schedule',
|
||||
icon: '⏰',
|
||||
description: 'Fire on a cron schedule',
|
||||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' },
|
||||
{ key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'trigger-data-change',
|
||||
category: 'trigger',
|
||||
label: 'Data Change',
|
||||
icon: '📊',
|
||||
description: 'Fire when data changes in any rApp',
|
||||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
|
||||
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'trigger-webhook',
|
||||
category: 'trigger',
|
||||
label: 'Webhook Incoming',
|
||||
icon: '🔗',
|
||||
description: 'Fire when an external webhook is received',
|
||||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'trigger-manual',
|
||||
category: 'trigger',
|
||||
label: 'Manual Trigger',
|
||||
icon: '👆',
|
||||
description: 'Fire manually via button click',
|
||||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }],
|
||||
configSchema: [],
|
||||
},
|
||||
{
|
||||
type: 'trigger-proximity',
|
||||
category: 'trigger',
|
||||
label: 'Location Proximity',
|
||||
icon: '📍',
|
||||
description: 'Fire when a location approaches a point',
|
||||
inputs: [],
|
||||
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' },
|
||||
{ key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' },
|
||||
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' },
|
||||
],
|
||||
},
|
||||
// ── Conditions ──
|
||||
{
|
||||
type: 'condition-compare',
|
||||
category: 'condition',
|
||||
label: 'Compare Values',
|
||||
icon: '⚖️',
|
||||
description: 'Compare two values',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }],
|
||||
outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }],
|
||||
configSchema: [
|
||||
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] },
|
||||
{ key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'condition-geofence',
|
||||
category: 'condition',
|
||||
label: 'Geofence Check',
|
||||
icon: '🗺️',
|
||||
description: 'Check if coordinates are within a radius',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }],
|
||||
outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
|
||||
configSchema: [
|
||||
{ key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' },
|
||||
{ key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' },
|
||||
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'condition-time-window',
|
||||
category: 'condition',
|
||||
label: 'Time Window',
|
||||
icon: '🕐',
|
||||
description: 'Check if current time is within a window',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }],
|
||||
outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
|
||||
configSchema: [
|
||||
{ key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' },
|
||||
{ key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' },
|
||||
{ key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'condition-data-filter',
|
||||
category: 'condition',
|
||||
label: 'Data Filter',
|
||||
icon: '🔍',
|
||||
description: 'Filter data by field value',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' },
|
||||
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] },
|
||||
{ key: 'value', label: 'Value', type: 'text', placeholder: 'completed' },
|
||||
],
|
||||
},
|
||||
// ── Actions ──
|
||||
{
|
||||
type: 'action-send-email',
|
||||
category: 'action',
|
||||
label: 'Send Email',
|
||||
icon: '📧',
|
||||
description: 'Send an email via SMTP',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' },
|
||||
{ key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' },
|
||||
{ key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '<p>Hello {{name}}</p>' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'action-post-webhook',
|
||||
category: 'action',
|
||||
label: 'POST Webhook',
|
||||
icon: '🌐',
|
||||
description: 'Send an HTTP POST to an external URL',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' },
|
||||
{ key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] },
|
||||
{ key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'action-create-event',
|
||||
category: 'action',
|
||||
label: 'Create Calendar Event',
|
||||
icon: '📅',
|
||||
description: 'Create an event in rCal',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' },
|
||||
{ key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'action-create-task',
|
||||
category: 'action',
|
||||
label: 'Create Task',
|
||||
icon: '✅',
|
||||
description: 'Create a task in rWork',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' },
|
||||
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' },
|
||||
{ key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'action-send-notification',
|
||||
category: 'action',
|
||||
label: 'Send Notification',
|
||||
icon: '🔔',
|
||||
description: 'Send an in-app notification',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }],
|
||||
configSchema: [
|
||||
{ key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' },
|
||||
{ key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' },
|
||||
{ key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'action-update-data',
|
||||
category: 'action',
|
||||
label: 'Update Data',
|
||||
icon: '💾',
|
||||
description: 'Update data in an rApp document',
|
||||
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
|
||||
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
|
||||
configSchema: [
|
||||
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] },
|
||||
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
|
||||
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface ScheduleDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
|
|
@ -85,6 +364,7 @@ export interface ScheduleDoc {
|
|||
};
|
||||
jobs: Record<string, ScheduleJob>;
|
||||
reminders: Record<string, Reminder>;
|
||||
workflows: Record<string, Workflow>;
|
||||
log: ExecutionLogEntry[];
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +384,7 @@ export const scheduleSchema: DocSchema<ScheduleDoc> = {
|
|||
},
|
||||
jobs: {},
|
||||
reminders: {},
|
||||
workflows: {},
|
||||
log: [],
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -526,6 +526,24 @@ export default defineConfig({
|
|||
resolve(__dirname, "dist/modules/rtrips/trips.css"),
|
||||
);
|
||||
|
||||
// Build trips demo page script
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rtrips/components"),
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rtrips"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rtrips/components/trips-demo.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "trips-demo.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: { entryFileNames: "trips-demo.js" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Build cal module component
|
||||
await build({
|
||||
configFile: false,
|
||||
|
|
@ -916,6 +934,37 @@ export default defineConfig({
|
|||
},
|
||||
});
|
||||
|
||||
// Build schedule automation canvas component
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/rschedule/components"),
|
||||
resolve: {
|
||||
alias: {
|
||||
"../schemas": resolve(__dirname, "modules/rschedule/schemas.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/rschedule"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/rschedule/components/folk-automation-canvas.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-automation-canvas.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "folk-automation-canvas.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Copy automation canvas CSS
|
||||
copyFileSync(
|
||||
resolve(__dirname, "modules/rschedule/components/automation-canvas.css"),
|
||||
resolve(__dirname, "dist/modules/rschedule/automation-canvas.css"),
|
||||
);
|
||||
|
||||
// ── Demo infrastructure ──
|
||||
|
||||
// Build demo-sync-vanilla library
|
||||
|
|
|
|||
Loading…
Reference in New Issue