From dda77604334f07b6bea3b4f7ea912b1c29988c1f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 16 Apr 2026 17:04:15 -0400 Subject: [PATCH] infra(traefik): scope rate limit per CF-Connecting-IP, raise to 600/150 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous limits (avg 120/min, burst 30) had no sourceCriterion. Traefik default groups by request Host, so ALL users of rspace.online shared a single 120/min bucket — tripped almost immediately under normal load (repeated 429s in rpast, api/mi/models, etc). Scope per Cloudflare client IP (CF-Connecting-IP header) and raise to 600/min average with 150 burst — interactive use can spike above 120/min from one client easily (module loads + polling + autosave). --- docker-compose.yml | 10 +- lib/folk-shape.ts | 17 + .../components/folk-payments-dashboard.ts | 40 +- .../components/automation-canvas.css | 551 ----- .../components/folk-automation-canvas.ts | 1007 -------- .../components/folk-reminders-widget.ts | 293 --- .../rschedule/components/folk-schedule-app.ts | 1047 -------- modules/rschedule/components/schedule.css | 6 - modules/rschedule/landing.ts | 261 -- modules/rschedule/local-first-client.ts | 158 -- modules/rschedule/mod.ts | 2141 ----------------- modules/rschedule/schemas.ts | 415 ---- server/index.ts | 4 +- .../mcp-tools/{rschedule.ts => rminders.ts} | 32 +- shared/draggable.ts | 201 +- 15 files changed, 244 insertions(+), 5939 deletions(-) delete mode 100644 modules/rschedule/components/automation-canvas.css delete mode 100644 modules/rschedule/components/folk-automation-canvas.ts delete mode 100644 modules/rschedule/components/folk-reminders-widget.ts delete mode 100644 modules/rschedule/components/folk-schedule-app.ts delete mode 100644 modules/rschedule/components/schedule.css delete mode 100644 modules/rschedule/landing.ts delete mode 100644 modules/rschedule/local-first-client.ts delete mode 100644 modules/rschedule/mod.ts delete mode 100644 modules/rschedule/schemas.ts rename server/mcp-tools/{rschedule.ts => rminders.ts} (86%) diff --git a/docker-compose.yml b/docker-compose.yml index 4c378753..a21bc64e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,10 +175,14 @@ services: - "traefik.http.routers.rspace-rsocials.entrypoints=web" - "traefik.http.routers.rspace-rsocials.priority=120" - "traefik.http.routers.rspace-rsocials.service=rspace-online" - # Rate limiting middleware (coarse edge defense — token bucket per source) - - "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=120" - - "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=30" + # Rate limiting middleware (coarse edge defense — token bucket per client IP) + # Without sourceCriterion Traefik groups by request Host, so one bucket is + # shared across ALL users of a domain — trips instantly under normal use. + # Scope per client IP using Cloudflare's CF-Connecting-IP header. + - "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=600" + - "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=150" - "traefik.http.middlewares.rspace-ratelimit.ratelimit.period=1m" + - "traefik.http.middlewares.rspace-ratelimit.ratelimit.sourcecriterion.requestheadername=CF-Connecting-IP" - "traefik.http.routers.rspace-main.middlewares=rspace-ratelimit" - "traefik.http.routers.rspace-canvas.middlewares=rspace-ratelimit" # Service configuration diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 105b5a5b..aea20011 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -167,6 +167,23 @@ const styles = css` width: 16px; } + /* Touch devices: enlarge handles to ≥22px so they're reachable, + and make rotation handles visible (tinted dot). Hover-only reveal + doesn't work on touch. */ + @media (pointer: coarse) { + [part^="resize"] { + width: 22px; + border-width: 2px; + border-radius: 4px; + } + [part^="rotation"] { + opacity: 0.6; + width: 26px; + background: hsl(214, 84%, 56%); + border-radius: 50%; + } + } + [part$="top-left"] { top: 0; left: 0; diff --git a/modules/rcart/components/folk-payments-dashboard.ts b/modules/rcart/components/folk-payments-dashboard.ts index d9e33f1a..2d806d07 100644 --- a/modules/rcart/components/folk-payments-dashboard.ts +++ b/modules/rcart/components/folk-payments-dashboard.ts @@ -1,9 +1,5 @@ /** - * — Payments dashboard showing requests in/out. - * - * Tabs: - * - Payments In: payment requests the user created (money coming to them) - * - Payments Out: placeholder for future on-chain tx tracking + * — Payments dashboard showing payment requests. */ interface PaymentRow { @@ -27,7 +23,6 @@ class FolkPaymentsDashboard extends HTMLElement { private authenticated = false; private loading = true; private error = ''; - private activeTab: 'in' | 'out' = 'in'; private payments: PaymentRow[] = []; constructor() { @@ -119,16 +114,8 @@ class FolkPaymentsDashboard extends HTMLElement { + Create Payment Request -
- - -
-
- ${this.activeTab === 'in' ? this.renderPaymentsIn() : this.renderPaymentsOut()} + ${this.renderPaymentsIn()}
`; this.bindEvents(); @@ -196,14 +183,6 @@ class FolkPaymentsDashboard extends HTMLElement { `; } - private renderPaymentsOut(): string { - return `
-
🚀
-

Coming soon

-

Outbound payment tracking will show on-chain transactions from your wallet.

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

Click a node to configure it.

-
`; - } - - const node = this.nodes.find(n => n.id === this.selectedNodeId); - if (!node) return ''; - const def = getNodeDef(node.type); - if (!def) return ''; - - const fieldsHtml = def.configSchema.map(field => { - const val = node.config[field.key] ?? ''; - if (field.type === 'select') { - const options = (field.options || []).map(o => - `` - ).join(''); - return ` -
- - -
`; - } - if (field.type === 'textarea') { - return ` -
- - -
`; - } - return ` -
- - -
`; - }).join(''); - - const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => ` -
- - ${esc(e.message)} (${e.durationMs}ms) -
- `).join(''); - - return ` -
- ${def.icon} ${esc(node.label)} - -
-
-
- - -
- ${fieldsHtml} - ${logHtml ? `
Execution Log
${logHtml}
` : ''} - -
`; - } - - // ── Redraw helpers ── - - private drawCanvasContent() { - const edgeLayer = this.shadow.getElementById('edge-layer'); - const nodeLayer = this.shadow.getElementById('node-layer'); - const wireLayer = this.shadow.getElementById('wire-layer'); - if (!edgeLayer || !nodeLayer) return; - edgeLayer.innerHTML = this.renderAllEdges(); - nodeLayer.innerHTML = this.renderAllNodes(); - if (wireLayer) wireLayer.innerHTML = ''; - } - - private redrawEdges() { - const edgeLayer = this.shadow.getElementById('edge-layer'); - if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); - } - - private updateNodePosition(node: WorkflowNode) { - const nodeLayer = this.shadow.getElementById('node-layer'); - if (!nodeLayer) return; - const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; - if (!g) return; - const fo = g.querySelector('foreignObject'); - if (fo) { - fo.setAttribute('x', String(node.position.x)); - fo.setAttribute('y', String(node.position.y)); - } - // Update port circle positions - const def = getNodeDef(node.type); - if (!def) return; - const portGroups = g.querySelectorAll('.ac-port-group'); - portGroups.forEach(pg => { - const portName = (pg as HTMLElement).dataset.portName!; - const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output'; - const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH; - const ports = dir === 'input' ? def.inputs : def.outputs; - const idx = ports.findIndex(p => p.name === portName); - const spacing = NODE_HEIGHT / (ports.length + 1); - const y = node.position.y + spacing * (idx + 1); - pg.querySelectorAll('circle').forEach(c => { - c.setAttribute('cx', String(x)); - c.setAttribute('cy', String(y)); - }); - }); - // Update port labels - const labels = g.querySelectorAll('text'); - let labelIdx = 0; - for (const inp of def.inputs) { - if (labels[labelIdx]) { - const y = getPortY(node, inp.name, 'input'); - labels[labelIdx].setAttribute('x', String(node.position.x + 14)); - labels[labelIdx].setAttribute('y', String(y + 4)); - } - labelIdx++; - } - for (const out of def.outputs) { - if (labels[labelIdx]) { - const y = getPortY(node, out.name, 'output'); - labels[labelIdx].setAttribute('x', String(node.position.x + NODE_WIDTH - 14)); - labels[labelIdx].setAttribute('y', String(y + 4)); - } - labelIdx++; - } - } - - private refreshConfigPanel() { - const panel = this.shadow.getElementById('config-panel'); - if (!panel) return; - panel.className = `ac-config ${this.configOpen ? 'open' : ''}`; - panel.innerHTML = this.renderConfigPanel(); - this.attachConfigListeners(); - } - - // ── Node operations ── - - private addNode(type: string, x: number, y: number) { - const def = getNodeDef(type); - if (!def) return; - const id = `n-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - const node: WorkflowNode = { - id, - type: def.type, - label: def.label, - position: { x, y }, - config: {}, - }; - this.nodes.push(node); - this.drawCanvasContent(); - this.selectNode(id); - this.scheduleSave(); - } - - private deleteNode(nodeId: string) { - this.nodes = this.nodes.filter(n => n.id !== nodeId); - this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId); - if (this.selectedNodeId === nodeId) { - this.selectedNodeId = null; - this.configOpen = false; - } - this.drawCanvasContent(); - this.refreshConfigPanel(); - this.scheduleSave(); - } - - private selectNode(nodeId: string) { - this.selectedNodeId = nodeId; - this.configOpen = true; - // Update selection in SVG - const nodeLayer = this.shadow.getElementById('node-layer'); - if (nodeLayer) { - nodeLayer.querySelectorAll('.ac-node').forEach(g => { - g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId); - }); - } - this.refreshConfigPanel(); - } - - // ── Wiring ── - - private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') { - // Only start wiring from output ports - if (dir !== 'output') return; - this.wiringActive = true; - this.wiringSourceNodeId = nodeId; - this.wiringSourcePortName = portName; - this.wiringSourceDir = dir; - const canvas = this.shadow.getElementById('ac-canvas'); - if (canvas) canvas.classList.add('wiring'); - } - - private cancelWiring() { - this.wiringActive = false; - this.wiringSourceNodeId = null; - this.wiringSourcePortName = null; - this.wiringSourceDir = null; - const canvas = this.shadow.getElementById('ac-canvas'); - if (canvas) canvas.classList.remove('wiring'); - const wireLayer = this.shadow.getElementById('wire-layer'); - if (wireLayer) wireLayer.innerHTML = ''; - } - - private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') { - if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; } - if (targetDir !== 'input') { this.cancelWiring(); return; } - if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; } - - // Check for duplicate edges - const exists = this.edges.some(e => - e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName && - e.toNode === targetNodeId && e.toPort === targetPortName - ); - if (exists) { this.cancelWiring(); return; } - - const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - this.edges.push({ - id: edgeId, - fromNode: this.wiringSourceNodeId, - fromPort: this.wiringSourcePortName, - toNode: targetNodeId, - toPort: targetPortName, - }); - - this.cancelWiring(); - this.drawCanvasContent(); - this.scheduleSave(); - } - - private updateWiringTempLine() { - const svg = this.shadow.getElementById('ac-svg') as SVGSVGElement | null; - const wireLayer = this.shadow.getElementById('wire-layer'); - if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortName) return; - - const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId); - if (!sourceNode) return; - - const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output'); - const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output'); - - const rect = svg.getBoundingClientRect(); - const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; - const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; - - const d = bezierPath(x1, y1, x2, y2); - wireLayer.innerHTML = ``; - } - - // ── Execution ── - - private async runWorkflow() { - if (!this.currentWorkflowId) return; - // Reset runtime statuses - for (const n of this.nodes) { - n.runtimeStatus = 'running'; - } - this.drawCanvasContent(); - - try { - const res = await fetch(`${this.basePath}api/workflows/${this.currentWorkflowId}/run`, { method: 'POST' }); - const data = await res.json(); - this.execLog = data.results || []; - - // Update node statuses - for (const n of this.nodes) { - const logEntry = this.execLog.find(e => e.nodeId === n.id); - n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle'; - n.runtimeMessage = logEntry?.message; - } - } catch { - for (const n of this.nodes) { - n.runtimeStatus = 'error'; - } - } - - this.drawCanvasContent(); - if (this.selectedNodeId) this.refreshConfigPanel(); - - // Reset after 5s - setTimeout(() => { - for (const n of this.nodes) { - n.runtimeStatus = 'idle'; - n.runtimeMessage = undefined; - } - this.drawCanvasContent(); - }, 5000); - } - - // ── Event listeners ── - - private attachEventListeners() { - const canvas = this.shadow.getElementById('ac-canvas')!; - const svg = this.shadow.getElementById('ac-svg')!; - const palette = this.shadow.getElementById('palette')!; - - // Toolbar - this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => { - this.workflowName = (e.target as HTMLInputElement).value; - this.scheduleSave(); - }); - - this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => { - this.workflowEnabled = (e.target as HTMLInputElement).checked; - this.scheduleSave(); - }); - - this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow()); - this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow()); - this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow()); - - this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => { - const id = (e.target as HTMLSelectElement).value; - const wf = this.workflows.find(w => w.id === id); - if (wf) { - this.loadWorkflow(wf); - this.drawCanvasContent(); - this.refreshConfigPanel(); - requestAnimationFrame(() => this.fitView()); - } - }); - - // Zoom controls - this.shadow.getElementById('zoom-in')?.addEventListener('click', () => { - const rect = svg.getBoundingClientRect(); - this.zoomAt(rect.width / 2, rect.height / 2, 1.2); - }); - this.shadow.getElementById('zoom-out')?.addEventListener('click', () => { - const rect = svg.getBoundingClientRect(); - this.zoomAt(rect.width / 2, rect.height / 2, 0.8); - }); - this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView()); - - // Canvas mouse wheel - canvas.addEventListener('wheel', (e: WheelEvent) => { - e.preventDefault(); - const rect = svg.getBoundingClientRect(); - const factor = e.deltaY < 0 ? 1.1 : 0.9; - this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor); - }, { passive: false }); - - // Palette drag - palette.querySelectorAll('.ac-palette__card').forEach(card => { - card.addEventListener('dragstart', (e: Event) => { - const de = e as DragEvent; - const type = (card as HTMLElement).dataset.nodeType!; - de.dataTransfer?.setData('text/plain', type); - }); - }); - - // Canvas drop - canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); }); - canvas.addEventListener('drop', (e: DragEvent) => { - e.preventDefault(); - const type = e.dataTransfer?.getData('text/plain'); - if (!type) return; - const rect = svg.getBoundingClientRect(); - const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom; - const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom; - this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); - }); - - // SVG pointer events - svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); - - // Global move/up - this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e); - this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e); - this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); - document.addEventListener('pointermove', this._boundPointerMove); - document.addEventListener('pointerup', this._boundPointerUp); - document.addEventListener('keydown', this._boundKeyDown); - - // Config panel - this.attachConfigListeners(); - } - - private attachConfigListeners() { - this.shadow.getElementById('config-close')?.addEventListener('click', () => { - this.configOpen = false; - this.selectedNodeId = null; - const panel = this.shadow.getElementById('config-panel'); - if (panel) panel.className = 'ac-config'; - this.drawCanvasContent(); - }); - - this.shadow.getElementById('config-label')?.addEventListener('input', (e) => { - const node = this.nodes.find(n => n.id === this.selectedNodeId); - if (node) { - node.label = (e.target as HTMLInputElement).value; - this.drawCanvasContent(); - this.scheduleSave(); - } - }); - - this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => { - if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); - }); - - // Config field inputs - const configPanel = this.shadow.getElementById('config-panel'); - if (configPanel) { - configPanel.querySelectorAll('[data-config-key]').forEach(el => { - el.addEventListener('input', (e) => { - const key = (el as HTMLElement).dataset.configKey!; - const node = this.nodes.find(n => n.id === this.selectedNodeId); - if (node) { - node.config[key] = (e.target as HTMLInputElement).value; - this.scheduleSave(); - } - }); - el.addEventListener('change', (e) => { - const key = (el as HTMLElement).dataset.configKey!; - const node = this.nodes.find(n => n.id === this.selectedNodeId); - if (node) { - node.config[key] = (e.target as HTMLSelectElement).value; - this.scheduleSave(); - } - }); - }); - } - } - - private handlePointerDown(e: PointerEvent) { - const svg = this.shadow.getElementById('ac-svg') as unknown as SVGSVGElement; - const target = e.target as Element; - - // Port click — start/complete wiring - const portGroup = target.closest('.ac-port-group') as SVGElement | null; - if (portGroup) { - e.stopPropagation(); - const nodeId = portGroup.dataset.nodeId!; - const portName = portGroup.dataset.portName!; - const dir = portGroup.dataset.portDir as 'input' | 'output'; - - if (this.wiringActive) { - this.completeWiring(nodeId, portName, dir); - } else { - this.enterWiring(nodeId, portName, dir); - } - return; - } - - // Edge click — delete - const edgeGroup = target.closest('.ac-edge-group') as SVGElement | null; - if (edgeGroup) { - e.stopPropagation(); - const edgeId = edgeGroup.dataset.edgeId!; - this.edges = this.edges.filter(ed => ed.id !== edgeId); - this.redrawEdges(); - this.scheduleSave(); - return; - } - - // Node click — select + start drag - const nodeGroup = target.closest('.ac-node') as SVGElement | null; - if (nodeGroup) { - e.stopPropagation(); - if (this.wiringActive) { - this.cancelWiring(); - return; - } - const nodeId = nodeGroup.dataset.nodeId!; - this.selectNode(nodeId); - - const node = this.nodes.find(n => n.id === nodeId); - if (node) { - this.draggingNodeId = nodeId; - this.dragStartX = e.clientX; - this.dragStartY = e.clientY; - this.dragNodeStartX = node.position.x; - this.dragNodeStartY = node.position.y; - } - return; - } - - // Canvas click — pan or deselect - if (this.wiringActive) { - this.cancelWiring(); - return; - } - - this.isPanning = true; - this.panStartX = e.clientX; - this.panStartY = e.clientY; - this.panStartPanX = this.canvasPanX; - this.panStartPanY = this.canvasPanY; - const canvas = this.shadow.getElementById('ac-canvas'); - if (canvas) canvas.classList.add('grabbing'); - - // Deselect - if (this.selectedNodeId) { - this.selectedNodeId = null; - this.configOpen = false; - this.drawCanvasContent(); - this.refreshConfigPanel(); - } - } - - private handlePointerMove(e: PointerEvent) { - if (this.wiringActive) { - this.wiringPointerX = e.clientX; - this.wiringPointerY = e.clientY; - this.updateWiringTempLine(); - return; - } - - if (this.draggingNodeId) { - const node = this.nodes.find(n => n.id === this.draggingNodeId); - if (node) { - const dx = (e.clientX - this.dragStartX) / this.canvasZoom; - const dy = (e.clientY - this.dragStartY) / this.canvasZoom; - node.position.x = this.dragNodeStartX + dx; - node.position.y = this.dragNodeStartY + dy; - this.updateNodePosition(node); - this.redrawEdges(); - } - return; - } - - if (this.isPanning) { - this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); - this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); - this.updateCanvasTransform(); - } - } - - private handlePointerUp(_e: PointerEvent) { - if (this.draggingNodeId) { - this.draggingNodeId = null; - this.scheduleSave(); - } - if (this.isPanning) { - this.isPanning = false; - const canvas = this.shadow.getElementById('ac-canvas'); - if (canvas) canvas.classList.remove('grabbing'); - } - } - - private handleKeyDown(e: KeyboardEvent) { - if (e.key === 'Escape') { - if (this.wiringActive) this.cancelWiring(); - } - if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) { - // Don't delete if focused on an input - if ((e.target as Element)?.tagName === 'INPUT' || (e.target as Element)?.tagName === 'TEXTAREA') return; - this.deleteNode(this.selectedNodeId); - } - } -} - -customElements.define('folk-automation-canvas', FolkAutomationCanvas); diff --git a/modules/rschedule/components/folk-reminders-widget.ts b/modules/rschedule/components/folk-reminders-widget.ts deleted file mode 100644 index eade3194..00000000 --- a/modules/rschedule/components/folk-reminders-widget.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * — lightweight sidebar widget for upcoming reminders. - * - * Fetches upcoming reminders from rSchedule API, renders compact card list, - * supports quick actions (complete, snooze, delete), and accepts drops - * of cross-module items to create new reminders. - */ - -interface ReminderData { - id: string; - title: string; - description: string; - remindAt: number; - allDay: boolean; - completed: boolean; - notified: boolean; - sourceModule: string | null; - sourceLabel: string | null; - sourceColor: string | null; -} - -class FolkRemindersWidget extends HTMLElement { - private shadow: ShadowRoot; - private space = ""; - private reminders: ReminderData[] = []; - private loading = false; - private showAddForm = false; - private formTitle = ""; - private formDate = ""; - private refreshTimer: ReturnType | null = null; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: "open" }); - } - - connectedCallback() { - this.space = this.getAttribute("space") || "demo"; - this.loadReminders(); - // Auto-refresh every 5 minutes to pick up newly-due reminders - this.refreshTimer = setInterval(() => this.loadReminders(), 5 * 60_000); - } - - disconnectedCallback() { - if (this.refreshTimer) { - clearInterval(this.refreshTimer); - this.refreshTimer = null; - } - } - - private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)/); - return match ? `${match[1]}/rschedule` : "/rschedule"; - } - - private async loadReminders() { - this.loading = true; - this.render(); - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/reminders?upcoming=7&completed=false`); - if (res.ok) { - const data = await res.json(); - this.reminders = data.results || []; - } - } catch { this.reminders = []; } - this.loading = false; - this.render(); - } - - private async completeReminder(id: string) { - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" }); - await this.loadReminders(); - } - - private async snoozeReminder(id: string) { - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}/snooze`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hours: 24 }), - }); - await this.loadReminders(); - } - - private async deleteReminder(id: string) { - if (!confirm("Delete this reminder?")) return; - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" }); - await this.loadReminders(); - } - - private async createReminder(title: string, date: string) { - if (!title.trim() || !date) return; - const base = this.getApiBase(); - await fetch(`${base}/api/reminders`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: title.trim(), - remindAt: new Date(date + "T09:00:00").getTime(), - allDay: true, - syncToCalendar: true, - }), - }); - this.showAddForm = false; - this.formTitle = ""; - this.formDate = ""; - await this.loadReminders(); - } - - private async handleDrop(e: DragEvent) { - e.preventDefault(); - (e.currentTarget as HTMLElement)?.classList.remove("widget-drop-active"); - - let title = ""; - let sourceModule: string | null = null; - let sourceEntityId: string | null = null; - let sourceLabel: string | null = null; - let sourceColor: string | null = null; - - const rspaceData = e.dataTransfer?.getData("application/rspace-item"); - if (rspaceData) { - try { - const parsed = JSON.parse(rspaceData); - title = parsed.title || ""; - sourceModule = parsed.module || null; - sourceEntityId = parsed.entityId || null; - sourceLabel = parsed.label || null; - sourceColor = parsed.color || null; - } catch { /* fall through */ } - } - - if (!title) { - title = e.dataTransfer?.getData("text/plain") || ""; - } - - if (!title.trim()) return; - - // Show add form pre-filled - this.formTitle = title.trim(); - this.formDate = new Date().toISOString().slice(0, 10); - this.showAddForm = true; - this.render(); - - // Store source info for when form is submitted - (this as any)._pendingSource = { sourceModule, sourceEntityId, sourceLabel, sourceColor }; - } - - private formatRelativeTime(ts: number): string { - const diff = ts - Date.now(); - if (diff < 0) return "overdue"; - if (diff < 3600000) return `in ${Math.floor(diff / 60000)}m`; - if (diff < 86400000) return `in ${Math.floor(diff / 3600000)}h`; - return `in ${Math.floor(diff / 86400000)}d`; - } - - private esc(s: string): string { - return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); - } - - private render() { - const styles = ` - - `; - - if (this.loading) { - this.shadow.innerHTML = `${styles}
Loading reminders...
`; - return; - } - - const cards = this.reminders.map(r => ` -
-
-
-
${this.esc(r.title)}
-
${this.formatRelativeTime(r.remindAt)}
- ${r.sourceLabel ? `
${this.esc(r.sourceLabel)}
` : ""} -
-
- - - -
-
- `).join(""); - - const addForm = this.showAddForm ? ` -
- - -
- - -
-
- ` : ""; - - this.shadow.innerHTML = ` - ${styles} -
-
- 🔔 Reminders - -
- ${addForm} - ${this.reminders.length > 0 ? cards : '
No upcoming reminders
'} -
- `; - - this.attachListeners(); - } - - private attachListeners() { - // Add button - this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => { - this.showAddForm = !this.showAddForm; - this.formTitle = ""; - this.formDate = new Date().toISOString().slice(0, 10); - this.render(); - }); - - // Form submit - this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => { - const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || ""; - const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || ""; - this.createReminder(title, date); - }); - - // Form cancel - this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { - this.showAddForm = false; - this.render(); - }); - - // Complete - this.shadow.querySelectorAll("[data-complete]").forEach(btn => { - btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!)); - }); - - // Snooze - this.shadow.querySelectorAll("[data-snooze]").forEach(btn => { - btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!)); - }); - - // Delete - this.shadow.querySelectorAll("[data-delete]").forEach(btn => { - btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!)); - }); - - // Drop target on the whole widget - const root = this.shadow.getElementById("widget-root"); - if (root) { - root.addEventListener("dragover", (e) => { - e.preventDefault(); - root.classList.add("widget-drop-active"); - }); - root.addEventListener("dragleave", () => { - root.classList.remove("widget-drop-active"); - }); - root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent)); - } - } -} - -customElements.define("folk-reminders-widget", FolkRemindersWidget); diff --git a/modules/rschedule/components/folk-schedule-app.ts b/modules/rschedule/components/folk-schedule-app.ts deleted file mode 100644 index eec1f2ab..00000000 --- a/modules/rschedule/components/folk-schedule-app.ts +++ /dev/null @@ -1,1047 +0,0 @@ -/** - * — schedule management UI. - * - * Job list with create/edit forms, execution log viewer, - * and manual run triggers. REST-based with offline-first Automerge sync. - */ - -import { scheduleSchema, scheduleDocId, type ScheduleDoc } from "../schemas"; -import type { DocumentId } from "../../../shared/local-first/document"; -import { TourEngine } from "../../../shared/tour-engine"; -import { ViewHistory } from "../../../shared/view-history.js"; -import { startPresenceHeartbeat } from '../../../shared/collab-presence'; - -interface JobData { - id: string; - name: string; - description: string; - enabled: boolean; - cronExpression: string; - cronHuman?: string; - timezone: string; - actionType: string; - actionConfig: Record; - lastRunAt: number | null; - lastRunStatus: "success" | "error" | null; - lastRunMessage: string; - nextRunAt: number | null; - runCount: number; - createdBy: string; - createdAt: number; - updatedAt: number; -} - -interface LogEntry { - id: string; - jobId: string; - status: "success" | "error"; - message: string; - durationMs: number; - timestamp: number; -} - -interface ReminderData { - id: string; - title: string; - description: string; - remindAt: number; - allDay: boolean; - timezone: string; - notifyEmail: string | null; - notified: boolean; - completed: boolean; - sourceModule: string | null; - sourceLabel: string | null; - sourceColor: string | null; - cronExpression: string | null; - createdAt: number; - updatedAt: number; -} - -const ACTION_TYPES = [ - { value: "email", label: "Email" }, - { value: "webhook", label: "Webhook" }, - { value: "calendar-event", label: "Calendar Event" }, - { value: "broadcast", label: "Broadcast" }, - { value: "backlog-briefing", label: "Backlog Briefing" }, - { value: "calendar-reminder", label: "Calendar Reminder" }, -]; - -const CRON_PRESETS = [ - { label: "Every minute", value: "* * * * *" }, - { label: "Every 5 minutes", value: "*/5 * * * *" }, - { label: "Hourly", value: "0 * * * *" }, - { label: "Daily at 9am", value: "0 9 * * *" }, - { label: "Weekday mornings", value: "0 9 * * 1-5" }, - { label: "Weekly (Monday 9am)", value: "0 9 * * 1" }, - { label: "Monthly (1st at 9am)", value: "0 9 1 * *" }, - { label: "Custom", value: "" }, -]; - -class FolkScheduleApp extends HTMLElement { - private shadow: ShadowRoot; - private space = ""; - private jobs: JobData[] = []; - private log: LogEntry[] = []; - private reminders: ReminderData[] = []; - private view: "jobs" | "log" | "form" | "reminders" | "reminder-form" = "jobs"; - private _history = new ViewHistory<"jobs" | "log" | "form" | "reminders" | "reminder-form">("jobs", "rschedule"); - private editingJob: JobData | null = null; - private editingReminder: ReminderData | null = null; - private loading = false; - private runningJobId: string | null = null; - private _offlineUnsub: (() => void) | null = null; - private _subscribedDocIds: string[] = []; - private _stopPresence: (() => void) | null = null; - private _tour!: TourEngine; - private static readonly TOUR_STEPS = [ - { target: '[data-view="jobs"]', title: "Scheduled Jobs", message: "View and manage automated jobs that run on a cron schedule — email alerts, webhooks, and more.", advanceOnClick: true }, - { target: '[data-view="reminders"]', title: "Reminders", message: "Set personal reminders with optional email notifications and calendar sync.", advanceOnClick: true }, - { target: '[data-view="log"]', title: "Execution Log", message: "Review the history of all job runs with status, duration, and error details.", advanceOnClick: true }, - { target: '[data-action="create"]', title: "Create Job", message: "Create a new scheduled job with cron expressions and configurable actions.", advanceOnClick: false }, - ]; - - // Reminder form state - private rFormTitle = ""; - private rFormDescription = ""; - private rFormDate = ""; - private rFormTime = "09:00"; - private rFormAllDay = true; - private rFormEmail = ""; - private rFormSyncCal = true; - - // Form state - private formName = ""; - private formDescription = ""; - private formCron = "0 9 * * 1-5"; - private formTimezone = "America/Vancouver"; - private formActionType = "email"; - private formEnabled = true; - private formConfig: Record = {}; - - constructor() { - super(); - this.shadow = this.attachShadow({ mode: "open" }); - this._tour = new TourEngine( - this.shadow, - FolkScheduleApp.TOUR_STEPS, - "rschedule_tour_done", - () => this.shadow.host as HTMLElement, - ); - } - - connectedCallback() { - this.space = this.getAttribute("space") || "demo"; - this.subscribeOffline(); - this.loadJobs(); - if (!localStorage.getItem("rschedule_tour_done")) { - setTimeout(() => this._tour.start(), 1200); - } - this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rschedule', context: 'Schedule' })); - window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener); - } - - disconnectedCallback() { - this._history.destroy(); - window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener); - if (this._offlineUnsub) { - this._offlineUnsub(); - this._offlineUnsub = null; - } - this._stopPresence?.(); - const runtime = (window as any).__rspaceOfflineRuntime; - if (runtime) { - for (const id of this._subscribedDocIds) runtime.unsubscribe(id); - } - this._subscribedDocIds = []; - } - - private async subscribeOffline() { - const runtime = (window as any).__rspaceOfflineRuntime; - if (!runtime?.isInitialized) return; - - try { - const docId = scheduleDocId(this.space) as DocumentId; - const doc = await runtime.subscribe(docId, scheduleSchema); - this._subscribedDocIds.push(docId); - if (doc) this.renderFromDoc(doc as ScheduleDoc); - - this._offlineUnsub = runtime.onChange(docId, (doc: ScheduleDoc) => { - if (doc) this.renderFromDoc(doc); - }); - } catch { /* runtime unavailable */ } - } - - private renderFromDoc(doc: ScheduleDoc) { - if (doc.jobs && Object.keys(doc.jobs).length > 0) { - this.jobs = Object.values(doc.jobs).map((j) => ({ - id: j.id, - name: j.name, - description: j.description, - enabled: j.enabled, - cronExpression: j.cronExpression, - timezone: j.timezone, - actionType: j.actionType, - actionConfig: j.actionConfig as Record, - lastRunAt: j.lastRunAt, - lastRunStatus: j.lastRunStatus, - lastRunMessage: j.lastRunMessage, - nextRunAt: j.nextRunAt, - runCount: j.runCount, - createdBy: j.createdBy, - createdAt: j.createdAt, - updatedAt: j.updatedAt, - })); - } - - if (doc.reminders && Object.keys(doc.reminders).length > 0) { - this.reminders = Object.values(doc.reminders).map((r) => ({ - id: r.id, - title: r.title, - description: r.description, - remindAt: r.remindAt, - allDay: r.allDay, - timezone: r.timezone, - notifyEmail: r.notifyEmail, - notified: r.notified, - completed: r.completed, - sourceModule: r.sourceModule, - sourceLabel: r.sourceLabel, - sourceColor: r.sourceColor, - cronExpression: r.cronExpression, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })); - } - - if (doc.log && doc.log.length > 0) { - this.log = doc.log.map((e) => ({ - id: e.id, - jobId: e.jobId, - status: e.status, - message: e.message, - durationMs: e.durationMs, - timestamp: e.timestamp, - })); - } - - this.loading = false; - this.render(); - } - - private getApiBase(): string { - const path = window.location.pathname; - const match = path.match(/^(\/[^/]+)?\/rschedule/); - return match ? match[0] : ""; - } - - private async loadJobs() { - this.loading = true; - this.render(); - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/jobs`); - if (res.ok) { - const data = await res.json(); - this.jobs = data.results || []; - } - } catch { this.jobs = []; } - this.loading = false; - this.render(); - } - - private async loadLog() { - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/log`); - if (res.ok) { - const data = await res.json(); - this.log = data.results || []; - } - } catch { this.log = []; } - this.render(); - } - - private async loadReminders() { - try { - const base = this.getApiBase(); - const res = await fetch(`${base}/api/reminders`); - if (res.ok) { - const data = await res.json(); - this.reminders = data.results || []; - } - } catch { this.reminders = []; } - this.render(); - } - - private async completeReminder(id: string) { - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" }); - await this.loadReminders(); - } - - private async deleteReminder(id: string) { - if (!confirm("Delete this reminder?")) return; - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" }); - await this.loadReminders(); - } - - private async snoozeReminder(id: string) { - const base = this.getApiBase(); - await fetch(`${base}/api/reminders/${id}/snooze`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hours: 24 }), - }); - await this.loadReminders(); - } - - private async submitReminderForm() { - const base = this.getApiBase(); - const remindAt = this.rFormAllDay - ? new Date(this.rFormDate + "T09:00:00").getTime() - : new Date(this.rFormDate + "T" + this.rFormTime).getTime(); - - const payload: Record = { - title: this.rFormTitle, - description: this.rFormDescription, - remindAt, - allDay: this.rFormAllDay, - notifyEmail: this.rFormEmail || null, - syncToCalendar: this.rFormSyncCal, - }; - - const isEdit = !!this.editingReminder; - const url = isEdit ? `${base}/api/reminders/${this.editingReminder!.id}` : `${base}/api/reminders`; - const method = isEdit ? "PUT" : "POST"; - - const res = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Request failed" })); - alert(err.error || "Failed to save reminder"); - return; - } - - this.view = "reminders"; - this.editingReminder = null; - await this.loadReminders(); - } - - private openCreateReminderForm() { - this.editingReminder = null; - this.rFormTitle = ""; - this.rFormDescription = ""; - this.rFormDate = new Date().toISOString().slice(0, 10); - this.rFormTime = "09:00"; - this.rFormAllDay = true; - this.rFormEmail = ""; - this.rFormSyncCal = true; - this._history.push(this.view); - this.view = "reminder-form"; - this._history.push("reminder-form"); - this.render(); - } - - private openEditReminderForm(r: ReminderData) { - this.editingReminder = r; - const d = new Date(r.remindAt); - this.rFormTitle = r.title; - this.rFormDescription = r.description; - this.rFormDate = d.toISOString().slice(0, 10); - this.rFormTime = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; - this.rFormAllDay = r.allDay; - this.rFormEmail = r.notifyEmail || ""; - this.rFormSyncCal = true; - this._history.push(this.view); - this.view = "reminder-form"; - this._history.push("reminder-form"); - this.render(); - } - - private async toggleJob(id: string, enabled: boolean) { - const base = this.getApiBase(); - await fetch(`${base}/api/jobs/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), - }); - await this.loadJobs(); - } - - private async deleteJob(id: string) { - if (!confirm("Delete this scheduled job?")) return; - const base = this.getApiBase(); - await fetch(`${base}/api/jobs/${id}`, { method: "DELETE" }); - await this.loadJobs(); - } - - private async runJob(id: string) { - this.runningJobId = id; - this.render(); - const base = this.getApiBase(); - try { - const res = await fetch(`${base}/api/jobs/${id}/run`, { method: "POST" }); - const result = await res.json(); - alert(result.success ? `Success: ${result.message}` : `Error: ${result.message}`); - } catch (e: any) { - alert(`Run failed: ${e.message}`); - } - this.runningJobId = null; - await this.loadJobs(); - } - - private async submitForm() { - const base = this.getApiBase(); - const payload: Record = { - name: this.formName, - description: this.formDescription, - cronExpression: this.formCron, - timezone: this.formTimezone, - actionType: this.formActionType, - actionConfig: { ...this.formConfig }, - enabled: this.formEnabled, - }; - - const isEdit = !!this.editingJob; - const url = isEdit ? `${base}/api/jobs/${this.editingJob!.id}` : `${base}/api/jobs`; - const method = isEdit ? "PUT" : "POST"; - - const res = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Request failed" })); - alert(err.error || "Failed to save job"); - return; - } - - this.view = "jobs"; - this.editingJob = null; - await this.loadJobs(); - } - - private openCreateForm() { - this.editingJob = null; - this.formName = ""; - this.formDescription = ""; - this.formCron = "0 9 * * 1-5"; - this.formTimezone = "America/Vancouver"; - this.formActionType = "email"; - this.formEnabled = true; - this.formConfig = {}; - this._history.push(this.view); - this.view = "form"; - this._history.push("form"); - this.render(); - } - - private openEditForm(job: JobData) { - this.editingJob = job; - this.formName = job.name; - this.formDescription = job.description; - this.formCron = job.cronExpression; - this.formTimezone = job.timezone; - this.formActionType = job.actionType; - this.formEnabled = job.enabled; - this.formConfig = {}; - if (job.actionConfig) { - for (const [k, v] of Object.entries(job.actionConfig)) { - this.formConfig[k] = String(v); - } - } - this._history.push(this.view); - this.view = "form"; - this._history.push("form"); - this.render(); - } - - private _onViewRestored = (e: CustomEvent) => { - if (e.detail?.moduleId !== 'rschedule') return; - this.view = e.detail.view; - if (e.detail.view === "reminders") this.loadReminders(); - else if (e.detail.view === "log") this.loadLog(); - else this.render(); - }; - - private goBack() { - const prev = this._history.back(); - if (!prev) return; - this.view = prev.view; - if (prev.view === "reminders") this.loadReminders(); - else if (prev.view === "log") this.loadLog(); - else this.render(); - } - - private formatTime(ts: number | null): string { - if (!ts) return "—"; - const d = new Date(ts); - const now = Date.now(); - const diff = now - ts; - - if (diff < 60_000) return "just now"; - if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; - - return d.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); - } - - private formatFuture(ts: number | null): string { - if (!ts) return "—"; - const diff = ts - Date.now(); - if (diff < 0) return "overdue"; - if (diff < 60_000) return "< 1m"; - if (diff < 3600_000) return `in ${Math.floor(diff / 60_000)}m`; - if (diff < 86400_000) return `in ${Math.floor(diff / 3600_000)}h`; - return `in ${Math.floor(diff / 86400_000)}d`; - } - - private renderActionConfigFields(): string { - switch (this.formActionType) { - case "email": - return ` - - - - `; - case "webhook": - return ` - - - - `; - case "calendar-event": - return ` - - - `; - case "broadcast": - return ` - - - `; - case "backlog-briefing": - return ` - - - `; - default: - return `

No configuration needed for this action type.

`; - } - } - - private esc(s: string): string { - return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); - } - - private render() { - const styles = ` - - `; - - if (this.loading) { - this.shadow.innerHTML = `${styles}
Loading schedule...
`; - return; - } - - let content = ""; - - if (this.view === "jobs") { - content = this.renderJobList(); - } else if (this.view === "log") { - content = this.renderLog(); - } else if (this.view === "form") { - content = this.renderForm(); - } else if (this.view === "reminders") { - content = this.renderReminderList(); - } else if (this.view === "reminder-form") { - content = this.renderReminderForm(); - } - - const activeTab = this.view === "form" ? "jobs" : this.view === "reminder-form" ? "reminders" : this.view; - let headerAction = ""; - if (this.view === "jobs") headerAction = ``; - else if (this.view === "reminders") headerAction = ``; - - this.shadow.innerHTML = ` - ${styles} -
-

rSchedule

-
- - - -
- - ${headerAction} -
- ${content} - `; - - this.attachListeners(); - this._tour.renderOverlay(); - } - - startTour() { - this._tour.start(); - } - - private renderJobList(): string { - if (this.jobs.length === 0) { - return `

No scheduled jobs yet.

`; - } - - const rows = this.jobs.map((j) => ` - - - - - - ${this.esc(j.name)} - ${j.description ? `
${this.esc(j.description)}` : ""} - - ${this.esc(j.cronHuman || j.cronExpression)} - ${this.esc(j.timezone)} - ${this.esc(j.actionType)} - - - ${this.formatTime(j.lastRunAt)} - - ${this.formatFuture(j.nextRunAt)} - -
- - - -
- - - `).join(""); - - return ` -
- - - - - - - - - - - - - - ${rows} -
OnJobScheduleTimezoneActionLast RunNext RunActions
-
- `; - } - - private renderLog(): string { - if (this.log.length === 0) { - return `

No execution log entries yet.

Jobs will log their results here after they run.

`; - } - - const jobNames = new Map(this.jobs.map((j) => [j.id, j.name])); - const entries = this.log.map((e) => ` -
- - ${new Date(e.timestamp).toLocaleString()} - ${this.esc(jobNames.get(e.jobId) || e.jobId)} - ${this.esc(e.message)} - ${e.durationMs}ms -
- `).join(""); - - return `
${entries}
`; - } - - private renderForm(): string { - const isEdit = !!this.editingJob; - const presetOptions = CRON_PRESETS.map((p) => - `` - ).join(""); - - const actionOptions = ACTION_TYPES.map((a) => - `` - ).join(""); - - return ` -
-

${isEdit ? "Edit Job" : "Create New Job"}

-
- - - - - - - -
-

Action Configuration

-
- ${this.renderActionConfigFields()} -
-
-
-
- - -
-
- `; - } - - private renderReminderList(): string { - if (this.reminders.length === 0) { - return `

No reminders yet.

`; - } - - const rows = this.reminders.map((r) => { - const dateStr = new Date(r.remindAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); - const timeStr = r.allDay ? "All day" : new Date(r.remindAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); - const statusBadge = r.completed - ? 'Done' - : r.notified - ? 'Sent' - : 'Pending'; - - return ` - -
- -
- ${this.esc(r.title)} - ${r.description ? `
${this.esc(r.description.slice(0, 60))}` : ""} -
-
- - ${dateStr}
${timeStr} - ${r.sourceLabel ? `${this.esc(r.sourceLabel)}` : 'Free-form'} - ${statusBadge} - -
- ${!r.completed ? `` : ""} - ${!r.completed ? `` : ""} - - -
- - `; - }).join(""); - - return ` -
- - - - - - - - - - - ${rows} -
ReminderDateSourceStatusActions
-
- `; - } - - private renderReminderForm(): string { - const isEdit = !!this.editingReminder; - return ` -
-

${isEdit ? "Edit Reminder" : "Create New Reminder"}

-
- - - - - ${!this.rFormAllDay ? `` : ""} - - -
-
- - -
-
- `; - } - - private attachListeners() { - this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour()); - - // Tab switching - this.shadow.querySelectorAll("[data-view]").forEach((btn) => { - btn.addEventListener("click", () => { - const newView = btn.dataset.view as "jobs" | "log" | "reminders"; - this._history.push(this.view); - this.view = newView; - this._history.push(newView); - if (newView === "log") this.loadLog(); - else if (newView === "reminders") this.loadReminders(); - else this.render(); - }); - }); - - // Create button - this.shadow.querySelectorAll("[data-action='create']").forEach((btn) => { - btn.addEventListener("click", () => this.openCreateForm()); - }); - - // Toggle - this.shadow.querySelectorAll("[data-toggle]").forEach((input) => { - input.addEventListener("change", () => { - this.toggleJob(input.dataset.toggle!, input.checked); - }); - }); - - // Run - this.shadow.querySelectorAll("[data-run]").forEach((btn) => { - btn.addEventListener("click", () => this.runJob(btn.dataset.run!)); - }); - - // Edit - this.shadow.querySelectorAll("[data-edit]").forEach((btn) => { - btn.addEventListener("click", () => { - const job = this.jobs.find((j) => j.id === btn.dataset.edit); - if (job) this.openEditForm(job); - }); - }); - - // Delete - this.shadow.querySelectorAll("[data-delete]").forEach((btn) => { - btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!)); - }); - - // Form: cancel / back - this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => { - this.goBack(); - }); - - // Form: submit - this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => { - this.collectFormData(); - this.submitForm(); - }); - - // Form: preset selector - this.shadow.querySelector("#f-preset")?.addEventListener("change", (e) => { - const val = (e.target as HTMLSelectElement).value; - if (val) { - this.formCron = val; - const cronInput = this.shadow.querySelector("#f-cron"); - if (cronInput) cronInput.value = val; - } - }); - - // Form: action type change -> re-render config fields - this.shadow.querySelector("#f-action")?.addEventListener("change", (e) => { - this.collectFormData(); - this.formActionType = (e.target as HTMLSelectElement).value; - this.formConfig = {}; // reset config for new action type - const container = this.shadow.querySelector("#f-config-fields"); - if (container) container.innerHTML = this.renderActionConfigFields(); - this.attachConfigListeners(); - }); - - this.attachConfigListeners(); - - // Reminder: create button - this.shadow.querySelectorAll("[data-action='create-reminder']").forEach((btn) => { - btn.addEventListener("click", () => this.openCreateReminderForm()); - }); - - // Reminder: complete - this.shadow.querySelectorAll("[data-r-complete]").forEach((btn) => { - btn.addEventListener("click", () => this.completeReminder(btn.dataset.rComplete!)); - }); - - // Reminder: snooze - this.shadow.querySelectorAll("[data-r-snooze]").forEach((btn) => { - btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.rSnooze!)); - }); - - // Reminder: edit - this.shadow.querySelectorAll("[data-r-edit]").forEach((btn) => { - btn.addEventListener("click", () => { - const r = this.reminders.find((rem) => rem.id === btn.dataset.rEdit); - if (r) this.openEditReminderForm(r); - }); - }); - - // Reminder: delete - this.shadow.querySelectorAll("[data-r-delete]").forEach((btn) => { - btn.addEventListener("click", () => this.deleteReminder(btn.dataset.rDelete!)); - }); - - // Reminder form: cancel / back - this.shadow.querySelector("[data-action='cancel-reminder']")?.addEventListener("click", () => { - this.goBack(); - }); - - // Reminder form: submit - this.shadow.querySelector("[data-action='submit-reminder']")?.addEventListener("click", () => { - this.collectReminderFormData(); - this.submitReminderForm(); - }); - - // Reminder form: all-day toggle re-renders to show/hide time field - this.shadow.querySelector("#rf-allday")?.addEventListener("change", (e) => { - this.collectReminderFormData(); - this.rFormAllDay = (e.target as HTMLInputElement).checked; - this.render(); - }); - } - - private attachConfigListeners() { - this.shadow.querySelectorAll("[data-config]").forEach((el) => { - el.addEventListener("input", () => { - this.formConfig[el.dataset.config!] = el.value; - }); - el.addEventListener("change", () => { - this.formConfig[el.dataset.config!] = el.value; - }); - }); - } - - private collectFormData() { - const getName = this.shadow.querySelector("#f-name"); - const getDesc = this.shadow.querySelector("#f-desc"); - const getCron = this.shadow.querySelector("#f-cron"); - const getTz = this.shadow.querySelector("#f-tz"); - const getAction = this.shadow.querySelector("#f-action"); - const getEnabled = this.shadow.querySelector("#f-enabled"); - - if (getName) this.formName = getName.value; - if (getDesc) this.formDescription = getDesc.value; - if (getCron) this.formCron = getCron.value; - if (getTz) this.formTimezone = getTz.value; - if (getAction) this.formActionType = getAction.value; - if (getEnabled) this.formEnabled = getEnabled.checked; - - // Collect config fields - this.shadow.querySelectorAll("[data-config]").forEach((el) => { - this.formConfig[el.dataset.config!] = el.value; - }); - } - - private collectReminderFormData() { - const getTitle = this.shadow.querySelector("#rf-title"); - const getDesc = this.shadow.querySelector("#rf-desc"); - const getDate = this.shadow.querySelector("#rf-date"); - const getTime = this.shadow.querySelector("#rf-time"); - const getAllDay = this.shadow.querySelector("#rf-allday"); - const getEmail = this.shadow.querySelector("#rf-email"); - const getSync = this.shadow.querySelector("#rf-sync"); - - if (getTitle) this.rFormTitle = getTitle.value; - if (getDesc) this.rFormDescription = getDesc.value; - if (getDate) this.rFormDate = getDate.value; - if (getTime) this.rFormTime = getTime.value; - if (getAllDay) this.rFormAllDay = getAllDay.checked; - if (getEmail) this.rFormEmail = getEmail.value; - if (getSync) this.rFormSyncCal = getSync.checked; - } -} - -customElements.define("folk-schedule-app", FolkScheduleApp); diff --git a/modules/rschedule/components/schedule.css b/modules/rschedule/components/schedule.css deleted file mode 100644 index f524b905..00000000 --- a/modules/rschedule/components/schedule.css +++ /dev/null @@ -1,6 +0,0 @@ -/* rSchedule module — dark theme */ -folk-schedule-app { - display: block; - min-height: 400px; - padding: 20px; -} diff --git a/modules/rschedule/landing.ts b/modules/rschedule/landing.ts deleted file mode 100644 index 0a668e90..00000000 --- a/modules/rschedule/landing.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * rSchedule landing page — persistent job scheduling for rSpace. - */ -export function renderLanding(): string { - return ` - -
- - Persistent Scheduling - -

- Automate (you)rSpace,
on (you)rSchedule. -

-

- Cron-powered job scheduling with email, webhooks, calendar events, and backlog briefings — all managed from within rSpace. -

-

- rSchedule replaces system-level crontabs with an in-process, persistent scheduler. - Jobs survive restarts, fire on a 60-second tick loop, and are fully configurable through the UI. -

- -
- - -
-
-
-
-
- -
-

Cron Expressions

-

Standard cron syntax with timezone support. Schedule anything from every minute to once a year.

-
-
-
- 📧 -
-

Email Actions

-

Send scheduled emails via SMTP — morning briefings, weekly digests, monthly audits.

-
-
-
- 🔗 -
-

Webhook Actions

-

Fire HTTP requests on schedule — trigger builds, sync data, or ping external services.

-
-
-
- 📋 -
-

Backlog Briefings

-

Automated task digests from your Backlog — morning, weekly, and monthly summaries delivered by email.

-
-
-
-
- - -
-
-

Your Automations

-

- Visual workflows built on the automation canvas -

-
-

Loading automations…

-
-
-
- - - - -
-
-

How it works

-
-
-

Persistent Jobs

-

Jobs are stored in Automerge documents — they survive container restarts and server reboots. No more lost crontabs.

-
-
-

60-Second Tick Loop

-

A lightweight in-process loop checks every 60 seconds for due jobs. No external scheduler process needed.

-
-
-
-
- - -
-
-

Ecosystem Integration

-
-
-

rCal

-

Create recurring calendar events automatically via the calendar-event action type.

-
-
-

rInbox

-

Schedule email delivery through shared SMTP infrastructure.

-
-
-

Backlog

-

Scan backlog tasks and generate automated priority briefings on any cadence.

-
-
-
-
- - -
-

- Stop managing crontabs. Start scheduling from rSpace. -

-

- ← Back to rSpace -

-
-`; -} diff --git a/modules/rschedule/local-first-client.ts b/modules/rschedule/local-first-client.ts deleted file mode 100644 index 0bb92f4d..00000000 --- a/modules/rschedule/local-first-client.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * rSchedule Local-First Client - * - * Wraps the shared local-first stack for collaborative schedule management. - * Jobs, reminders, workflows, and execution logs sync in real-time. - */ - -import { DocumentManager } from '../../shared/local-first/document'; -import type { DocumentId } from '../../shared/local-first/document'; -import { EncryptedDocStore } from '../../shared/local-first/storage'; -import { DocSyncManager } from '../../shared/local-first/sync'; -import { DocCrypto } from '../../shared/local-first/crypto'; -import { scheduleSchema, scheduleDocId, MAX_LOG_ENTRIES } from './schemas'; -import type { ScheduleDoc, ScheduleJob, Reminder, Workflow, ExecutionLogEntry } from './schemas'; - -export class ScheduleLocalFirstClient { - #space: string; - #documents: DocumentManager; - #store: EncryptedDocStore; - #sync: DocSyncManager; - #initialized = false; - - constructor(space: string, docCrypto?: DocCrypto) { - this.#space = space; - this.#documents = new DocumentManager(); - this.#store = new EncryptedDocStore(space, docCrypto); - this.#sync = new DocSyncManager({ - documents: this.#documents, - store: this.#store, - }); - this.#documents.registerSchema(scheduleSchema); - } - - get isConnected(): boolean { return this.#sync.isConnected; } - - async init(): Promise { - if (this.#initialized) return; - await this.#store.open(); - const cachedIds = await this.#store.listByModule('schedule', 'jobs'); - const cached = await this.#store.loadMany(cachedIds); - for (const [docId, binary] of cached) { - this.#documents.open(docId, scheduleSchema, binary); - } - await this.#sync.preloadSyncStates(cachedIds); - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; - try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[ScheduleClient] Working offline'); } - this.#initialized = true; - } - - async subscribe(): Promise { - const docId = scheduleDocId(this.#space) as DocumentId; - let doc = this.#documents.get(docId); - if (!doc) { - const binary = await this.#store.load(docId); - doc = binary - ? this.#documents.open(docId, scheduleSchema, binary) - : this.#documents.open(docId, scheduleSchema); - } - await this.#sync.subscribe([docId]); - return doc ?? null; - } - - getDoc(): ScheduleDoc | undefined { - return this.#documents.get(scheduleDocId(this.#space) as DocumentId); - } - - onChange(cb: (doc: ScheduleDoc) => void): () => void { - return this.#sync.onChange(scheduleDocId(this.#space) as DocumentId, cb as (doc: any) => void); - } - - onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } - - // ── Job CRUD ── - - saveJob(job: ScheduleJob): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Save job ${job.name}`, (d) => { - d.jobs[job.id] = job; - }); - } - - deleteJob(jobId: string): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Delete job`, (d) => { - delete d.jobs[jobId]; - }); - } - - toggleJob(jobId: string, enabled: boolean): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Toggle job`, (d) => { - if (d.jobs[jobId]) { - d.jobs[jobId].enabled = enabled; - d.jobs[jobId].updatedAt = Date.now(); - } - }); - } - - // ── Reminder CRUD ── - - saveReminder(reminder: Reminder): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Save reminder ${reminder.title}`, (d) => { - d.reminders[reminder.id] = reminder; - }); - } - - deleteReminder(reminderId: string): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Delete reminder`, (d) => { - delete d.reminders[reminderId]; - }); - } - - completeReminder(reminderId: string): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Complete reminder`, (d) => { - if (d.reminders[reminderId]) { - d.reminders[reminderId].completed = true; - d.reminders[reminderId].updatedAt = Date.now(); - } - }); - } - - // ── Workflow CRUD ── - - saveWorkflow(workflow: Workflow): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Save workflow ${workflow.name}`, (d) => { - d.workflows[workflow.id] = workflow; - }); - } - - deleteWorkflow(workflowId: string): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Delete workflow`, (d) => { - delete d.workflows[workflowId]; - }); - } - - // ── Execution Log ── - - appendLogEntry(entry: ExecutionLogEntry): void { - const docId = scheduleDocId(this.#space) as DocumentId; - this.#sync.change(docId, `Log execution`, (d) => { - if (!d.log) d.log = [] as any; - d.log.push(entry); - // Trim to keep doc size manageable - while (d.log.length > MAX_LOG_ENTRIES) d.log.splice(0, 1); - }); - } - - async disconnect(): Promise { - await this.#sync.flush(); - this.#sync.disconnect(); - } -} diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts deleted file mode 100644 index 6d53f580..00000000 --- a/modules/rschedule/mod.ts +++ /dev/null @@ -1,2141 +0,0 @@ -/** - * Schedule module — persistent cron-based job scheduling. - * - * Replaces system-level crontabs with an in-process scheduler. - * Jobs are stored in Automerge (survives restarts), evaluated on - * a 60-second tick loop, and can execute emails, webhooks, - * calendar events, broadcasts, or backlog briefings. - * - * All persistence uses Automerge documents via SyncServer. - */ - -import { Hono } from "hono"; -import * as Automerge from "@automerge/automerge"; -import { createTransport, type Transporter } from "nodemailer"; -import { CronExpressionParser } from "cron-parser"; -import { renderShell } from "../../server/shell"; -import { getModuleInfoList } from "../../shared/module"; -import type { RSpaceModule } from "../../shared/module"; -import { renderLanding } from "./landing"; -import type { SyncServer } from "../../server/local-first/sync-server"; -import { - scheduleSchema, - scheduleDocId, - MAX_LOG_ENTRIES, - MAX_REMINDERS, - MAX_WORKFLOW_LOG, -} from "./schemas"; -import type { - ScheduleDoc, - ScheduleJob, - ExecutionLogEntry, - ActionType, - Reminder, - Workflow, - WorkflowNode, - WorkflowEdge, - WorkflowLogEntry, -} from "./schemas"; -import { NODE_CATALOG } from "./schemas"; -import { calendarDocId } from "../rcal/schemas"; -import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas"; -import { boardDocId, createTaskItem } from "../rtasks/schemas"; -import type { BoardDoc } from "../rtasks/schemas"; - -let _syncServer: SyncServer | null = null; - -const routes = new Hono(); - -// ── SMTP transport (lazy init) ── - -let _smtpTransport: Transporter | null = null; - -function getSmtpTransport(): Transporter | null { - if (_smtpTransport) return _smtpTransport; - const host = process.env.SMTP_HOST || "mail.rmail.online"; - const isInternal = host.includes('mailcow') || host.includes('postfix'); - if (!process.env.SMTP_PASS && !isInternal) return null; - _smtpTransport = createTransport({ - host, - port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), - secure: !isInternal && Number(process.env.SMTP_PORT) === 465, - ...(isInternal ? {} : { - auth: { - user: process.env.SMTP_USER || "noreply@rmail.online", - pass: process.env.SMTP_PASS!, - }, - }), - tls: { rejectUnauthorized: false }, - }); - return _smtpTransport; -} - -// ── Local-first helpers ── - -function ensureDoc(space: string): ScheduleDoc { - const docId = scheduleDocId(space); - let doc = _syncServer!.getDoc(docId); - if (!doc) { - doc = Automerge.change( - Automerge.init(), - "init schedule", - (d) => { - const init = scheduleSchema.init(); - d.meta = init.meta; - d.meta.spaceSlug = space; - d.jobs = {}; - d.reminders = {}; - d.workflows = {}; - d.log = []; - }, - ); - _syncServer!.setDoc(docId, doc); - } - return doc; -} - -// ── Cron helpers ── - -function computeNextRun(cronExpression: string, timezone: string): number | null { - try { - const interval = CronExpressionParser.parse(cronExpression, { - currentDate: new Date(), - tz: timezone, - }); - return interval.next().toDate().getTime(); - } catch { - return null; - } -} - -function cronToHuman(expr: string): string { - const parts = expr.split(/\s+/); - if (parts.length !== 5) return expr; - const [min, hour, dom, mon, dow] = parts; - - const dowNames: Record = { - "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", - "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun", - "1-5": "weekdays", "0,6": "weekends", - }; - - if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "*") - return `Daily at ${hour}:00`; - if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "1-5") - return `Weekdays at ${hour}:00`; - if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow !== "*") - return `${dowNames[dow] || dow} at ${hour}:00`; - if (min === "0" && hour !== "*" && dom !== "*" && mon === "*" && dow === "*") - return `Monthly on day ${dom} at ${hour}:00`; - if (min === "*" && hour === "*" && dom === "*" && mon === "*" && dow === "*") - return "Every minute"; - if (min.startsWith("*/")) - return `Every ${min.slice(2)} minutes`; - return expr; -} - -// ── Template helpers ── - -function renderTemplate(template: string, vars: Record): string { - let result = template; - for (const [key, value] of Object.entries(vars)) { - result = result.replaceAll(`{{${key}}}`, value); - } - return result; -} - -// ── Action executors ── - -async function executeEmail( - job: ScheduleJob, -): Promise<{ success: boolean; message: string }> { - const transport = getSmtpTransport(); - if (!transport) - return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; - - const config = job.actionConfig as { - to?: string; - subject?: string; - bodyTemplate?: string; - }; - if (!config.to) - return { success: false, message: "No recipient (to) configured" }; - - const vars = { - date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }), - jobName: job.name, - timestamp: new Date().toISOString(), - }; - - const subject = renderTemplate(config.subject || `[rSchedule] ${job.name}`, vars); - const html = renderTemplate(config.bodyTemplate || `

Scheduled job ${job.name} executed at ${vars.date}.

`, vars); - - await transport.sendMail({ - from: process.env.SMTP_FROM || "rSchedule ", - to: config.to, - subject, - html, - }); - - return { success: true, message: `Email sent to ${config.to}` }; -} - -async function executeWebhook( - job: ScheduleJob, -): Promise<{ success: boolean; message: string }> { - const config = job.actionConfig as { - url?: string; - method?: string; - headers?: Record; - bodyTemplate?: string; - }; - if (!config.url) - return { success: false, message: "No webhook URL configured" }; - - const vars = { - date: new Date().toISOString(), - jobName: job.name, - timestamp: new Date().toISOString(), - }; - - const method = (config.method || "POST").toUpperCase(); - const headers: Record = { - "Content-Type": "application/json", - ...config.headers, - }; - - const body = method !== "GET" - ? renderTemplate(config.bodyTemplate || JSON.stringify({ job: job.name, timestamp: vars.date }), vars) - : undefined; - - const res = await fetch(config.url, { method, headers, body }); - if (!res.ok) - return { success: false, message: `Webhook ${res.status}: ${await res.text().catch(() => "")}` }; - - return { success: true, message: `Webhook ${method} ${config.url} → ${res.status}` }; -} - -async function executeCalendarEvent( - job: ScheduleJob, - space: string, -): Promise<{ success: boolean; message: string }> { - if (!_syncServer) - return { success: false, message: "SyncServer not available" }; - - const config = job.actionConfig as { - title?: string; - duration?: number; - sourceId?: string; - }; - - const calDocId = calendarDocId(space); - const calDoc = _syncServer.getDoc(calDocId); - if (!calDoc) - return { success: false, message: `Calendar doc not found for space ${space}` }; - - const eventId = crypto.randomUUID(); - const now = Date.now(); - const durationMs = (config.duration || 60) * 60 * 1000; - - _syncServer.changeDoc(calDocId, `rSchedule: create event for ${job.name}`, (d) => { - d.events[eventId] = { - id: eventId, - title: config.title || job.name, - description: `Auto-created by rSchedule job: ${job.name}`, - startTime: now, - endTime: now + durationMs, - allDay: false, - timezone: job.timezone || "UTC", - rrule: null, - status: null, - visibility: null, - sourceId: config.sourceId || null, - sourceName: null, - sourceType: null, - sourceColor: null, - locationId: null, - locationName: null, - coordinates: null, - locationGranularity: null, - locationLat: null, - locationLng: null, - locationBreadcrumb: null, - bookingStatus: null, - isVirtual: false, - virtualUrl: null, - virtualPlatform: null, - rToolSource: "rSchedule", - rToolEntityId: job.id, - attendees: [], - attendeeCount: 0, - tags: null, - metadata: null, - likelihood: null, - createdAt: now, - updatedAt: now, - }; - }); - - return { success: true, message: `Calendar event '${config.title || job.name}' created (${eventId})` }; -} - -async function executeBroadcast( - job: ScheduleJob, -): Promise<{ success: boolean; message: string }> { - const config = job.actionConfig as { - channel?: string; - message?: string; - }; - - // Broadcast via SyncServer's WebSocket connections is not directly accessible - // from module code. For now, log the intent. Future: expose ws broadcast on SyncServer. - const msg = config.message || `Scheduled broadcast from ${job.name}`; - console.log(`[Schedule] Broadcast (${config.channel || "default"}): ${msg}`); - return { success: true, message: `Broadcast sent: ${msg}` }; -} - -async function executeBacklogBriefing( - job: ScheduleJob, -): Promise<{ success: boolean; message: string }> { - const config = job.actionConfig as { - mode?: "morning" | "weekly" | "monthly"; - scanPaths?: string[]; - to?: string; - }; - - const transport = getSmtpTransport(); - if (!transport) - return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; - if (!config.to) - return { success: false, message: "No recipient (to) configured" }; - - const mode = config.mode || "morning"; - const scanPaths = config.scanPaths || ["/data/communities/*/backlog/tasks/"]; - const now = new Date(); - const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); - - // Scan for backlog task files - const { readdir, readFile, stat } = await import("node:fs/promises"); - const { join, basename } = await import("node:path"); - const { Glob } = await import("bun"); - - interface TaskInfo { - file: string; - title: string; - priority: string; - status: string; - updatedAt: Date | null; - staleDays: number; - } - - const tasks: TaskInfo[] = []; - - for (const pattern of scanPaths) { - try { - const glob = new Glob(pattern.endsWith("/") ? pattern + "*.md" : pattern); - for await (const filePath of glob.scan()) { - try { - const content = await readFile(filePath, "utf-8"); - const fstat = await stat(filePath); - - // Parse YAML frontmatter - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - let title = basename(filePath, ".md").replace(/-/g, " "); - let priority = "medium"; - let status = "open"; - - if (fmMatch) { - const fm = fmMatch[1]; - const titleMatch = fm.match(/^title:\s*(.+)$/m); - const prioMatch = fm.match(/^priority:\s*(.+)$/m); - const statusMatch = fm.match(/^status:\s*(.+)$/m); - if (titleMatch) title = titleMatch[1].replace(/^["']|["']$/g, ""); - if (prioMatch) priority = prioMatch[1].trim().toLowerCase(); - if (statusMatch) status = statusMatch[1].trim().toLowerCase(); - } - - const staleDays = Math.floor( - (now.getTime() - fstat.mtime.getTime()) / (1000 * 60 * 60 * 24), - ); - - tasks.push({ file: filePath, title, priority, status, updatedAt: fstat.mtime, staleDays }); - } catch { - // Skip unreadable files - } - } - } catch { - // Glob pattern didn't match or dir doesn't exist - } - } - - // Filter and sort based on mode - let filtered = tasks.filter((t) => t.status !== "done" && t.status !== "closed"); - let subject: string; - let heading: string; - - switch (mode) { - case "morning": - // High/urgent priority + recently updated - filtered = filtered - .filter((t) => t.priority === "high" || t.priority === "urgent" || t.staleDays < 3) - .sort((a, b) => { - const priOrder: Record = { urgent: 0, high: 1, medium: 2, low: 3 }; - return (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2); - }); - subject = `Morning Briefing — ${dateStr}`; - heading = "Good morning! Here's your task briefing:"; - break; - case "weekly": - // All open tasks sorted by priority then staleness - filtered.sort((a, b) => { - const priOrder: Record = { urgent: 0, high: 1, medium: 2, low: 3 }; - const pDiff = (priOrder[a.priority] ?? 2) - (priOrder[b.priority] ?? 2); - return pDiff !== 0 ? pDiff : b.staleDays - a.staleDays; - }); - subject = `Weekly Backlog Review — ${dateStr}`; - heading = "Weekly review of all open tasks:"; - break; - case "monthly": - // Focus on stale items (> 14 days untouched) - filtered = filtered - .filter((t) => t.staleDays > 14) - .sort((a, b) => b.staleDays - a.staleDays); - subject = `Monthly Backlog Audit — ${dateStr}`; - heading = "Monthly audit — these tasks haven't been touched in 14+ days:"; - break; - } - - // Build HTML email - const taskRows = filtered.length > 0 - ? filtered - .slice(0, 50) - .map((t) => { - const prioColor: Record = { - urgent: "#ef4444", high: "#f97316", medium: "#f59e0b", low: "#6b7280", - }; - return ` - - ${t.priority} - - ${t.title} - ${t.status} - ${t.staleDays}d ago - `; - }) - .join("\n") - : `No tasks match this filter.`; - - const html = ` -
-

${heading}

-

${dateStr} • ${filtered.length} task${filtered.length !== 1 ? "s" : ""}

- - - - - - - - - - ${taskRows} -
PriorityTaskStatusLast Update
-

- Sent by rSchedule • Manage Schedules -

-
- `; - - await transport.sendMail({ - from: process.env.SMTP_FROM || "rSchedule ", - to: config.to, - subject: `[rSchedule] ${subject}`, - html, - }); - - return { success: true, message: `${mode} briefing sent to ${config.to} (${filtered.length} tasks)` }; -} - -async function executeCalendarReminder( - job: ScheduleJob, - space: string, -): Promise<{ success: boolean; message: string }> { - if (!_syncServer) - return { success: false, message: "SyncServer not available" }; - - const transport = getSmtpTransport(); - if (!transport) - return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; - - const config = job.actionConfig as { to?: string }; - if (!config.to) - return { success: false, message: "No recipient (to) configured" }; - - // Load the calendar doc for this space - const calDocId = calendarDocId(space); - const calDoc = _syncServer.getDoc(calDocId); - if (!calDoc) - return { success: false, message: `Calendar doc not found for space ${space}` }; - - // Find scheduled items due today that haven't been reminded yet - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const todayEnd = todayStart + 86400000; - - const dueItems = Object.values(calDoc.events).filter((ev) => { - const meta = ev.metadata as ScheduledItemMetadata | null; - return meta?.isScheduledItem === true - && !meta.reminderSent - && ev.startTime >= todayStart - && ev.startTime < todayEnd; - }); - - if (dueItems.length === 0) - return { success: true, message: "No scheduled items due today" }; - - // Render email with all due items - const dateStr = now.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }); - const itemRows = dueItems.map((ev) => { - const meta = ev.metadata as ScheduledItemMetadata; - const preview = meta.itemPreview; - const prov = meta.provenance; - const thumbHtml = preview.thumbnailUrl - ? `thumbnail` - : ""; - const canvasLink = preview.canvasUrl - ? `Open in Canvas` - : ""; - return ` - -
${ev.title}
-
${preview.textPreview}
-
- Source: ${prov.sourceType} in ${prov.sourceSpace} - ${prov.rid ? ` • RID: ${prov.rid}` : ""} -
- ${thumbHtml} -
${canvasLink}
- - `; - }).join("\n"); - - const html = ` -
-

Scheduled Knowledge Reminders

-

${dateStr} • ${dueItems.length} item${dueItems.length !== 1 ? "s" : ""}

- - ${itemRows} -
-

- Sent by rSchedule • View Calendar -

-
- `; - - await transport.sendMail({ - from: process.env.SMTP_FROM || "rSchedule ", - to: config.to, - subject: `[rSpace] ${dueItems.length} scheduled item${dueItems.length !== 1 ? "s" : ""} for ${dateStr}`, - html, - }); - - // Mark all sent items as reminded - _syncServer.changeDoc(calDocId, `mark ${dueItems.length} reminders sent`, (d) => { - for (const item of dueItems) { - const ev = d.events[item.id]; - if (!ev) continue; - const meta = ev.metadata as ScheduledItemMetadata; - meta.reminderSent = true; - meta.reminderSentAt = Date.now(); - ev.updatedAt = Date.now(); - } - }); - - return { success: true, message: `Calendar reminder sent to ${config.to} (${dueItems.length} items)` }; -} - -// ── Unified executor ── - -async function executeJob( - job: ScheduleJob, - space: string, -): Promise<{ success: boolean; message: string }> { - switch (job.actionType) { - case "email": - return executeEmail(job); - case "webhook": - return executeWebhook(job); - case "calendar-event": - return executeCalendarEvent(job, space); - case "broadcast": - return executeBroadcast(job); - case "backlog-briefing": - return executeBacklogBriefing(job); - case "calendar-reminder": - return executeCalendarReminder(job, space); - default: - return { success: false, message: `Unknown action type: ${job.actionType}` }; - } -} - -// ── Tick loop ── - -const TICK_INTERVAL = 60_000; - -function startTickLoop() { - console.log("[Schedule] Tick loop started — checking every 60s"); - - const tick = async () => { - if (!_syncServer) return; - - const now = Date.now(); - - // Iterate all known schedule docs - // Convention: check the "demo" space and any spaces that have schedule docs - const spaceSlugs = new Set(); - spaceSlugs.add("demo"); - - // Also scan for any schedule docs already loaded - const allDocs = _syncServer.listDocs(); - for (const docId of allDocs) { - const match = docId.match(/^(.+):schedule:jobs$/); - if (match) spaceSlugs.add(match[1]); - } - - for (const space of spaceSlugs) { - try { - const docId = scheduleDocId(space); - const doc = _syncServer.getDoc(docId); - if (!doc) continue; - - const dueJobs = Object.values(doc.jobs).filter( - (j) => j.enabled && j.nextRunAt && j.nextRunAt <= now, - ); - - for (const job of dueJobs) { - const startMs = Date.now(); - let result: { success: boolean; message: string }; - - try { - result = await executeJob(job, space); - } catch (e: any) { - result = { success: false, message: e.message || String(e) }; - } - - const durationMs = Date.now() - startMs; - const logEntry: ExecutionLogEntry = { - id: crypto.randomUUID(), - jobId: job.id, - status: result.success ? "success" : "error", - message: result.message, - durationMs, - timestamp: Date.now(), - }; - - console.log( - `[Schedule] ${result.success ? "OK" : "ERR"} ${job.name} (${durationMs}ms): ${result.message}`, - ); - - // Update job state + append log - _syncServer.changeDoc(docId, `run job ${job.id}`, (d) => { - const j = d.jobs[job.id]; - if (!j) return; - j.lastRunAt = Date.now(); - j.lastRunStatus = result.success ? "success" : "error"; - j.lastRunMessage = result.message; - j.runCount = (j.runCount || 0) + 1; - j.nextRunAt = computeNextRun(j.cronExpression, j.timezone) ?? null; - - // Append log entry, trim to max - d.log.push(logEntry); - while (d.log.length > MAX_LOG_ENTRIES) { - d.log.splice(0, 1); - } - }); - } - // ── Process due reminders ── - const dueReminders = Object.values(doc.reminders || {}).filter( - (r) => !r.notified && !r.completed && r.remindAt <= now && r.notifyEmail, - ); - - for (const reminder of dueReminders) { - try { - const result = await executeReminderEmail(reminder, space); - console.log( - `[Schedule] Reminder ${result.success ? "OK" : "ERR"} "${reminder.title}": ${result.message}`, - ); - - _syncServer.changeDoc(docId, `notify reminder ${reminder.id}`, (d) => { - const r = d.reminders[reminder.id]; - if (!r) return; - r.notified = true; - r.updatedAt = Date.now(); - - // Handle recurring reminders - if (r.cronExpression) { - const nextRun = computeNextRun(r.cronExpression, r.timezone); - if (nextRun) { - r.remindAt = nextRun; - r.notified = false; - } - } - }); - } catch (e) { - console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e); - } - } - - // ── Process due automation workflows ── - const workflows = Object.values(doc.workflows || {}); - for (const wf of workflows) { - if (!wf.enabled) continue; - const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron"); - for (const cronNode of cronNodes) { - const expr = String(cronNode.config.cronExpression || ""); - const tz = String(cronNode.config.timezone || "UTC"); - if (!expr) continue; - try { - const interval = CronExpressionParser.parse(expr, { - currentDate: new Date(now - TICK_INTERVAL), - tz, - }); - const nextDate = interval.next().toDate(); - if (nextDate.getTime() <= now) { - console.log(`[Schedule] Running cron workflow "${wf.name}" for space ${space}`); - const results = await executeWorkflow(wf, space); - const allOk = results.every(r => r.status !== "error"); - _syncServer.changeDoc(docId, `tick workflow ${wf.id}`, (d) => { - const w = d.workflows[wf.id]; - if (!w) return; - w.lastRunAt = Date.now(); - w.lastRunStatus = allOk ? "success" : "error"; - w.runCount = (w.runCount || 0) + 1; - w.updatedAt = Date.now(); - }); - appendWorkflowLog(space, wf.id, results, "cron"); - } - } catch { /* invalid cron — skip */ } - } - } - } catch (e) { - console.error(`[Schedule] Tick error for space ${space}:`, e); - } - } - }; - - setTimeout(tick, 10_000); // First tick after 10s - setInterval(tick, TICK_INTERVAL); -} - -// ── Seed default jobs ── - -const SEED_JOBS: Omit[] = [ - { - id: "backlog-morning", - name: "Morning Backlog Briefing", - description: "Weekday morning digest of high-priority and recently-updated tasks.", - enabled: true, - cronExpression: "0 14 * * 1-5", - timezone: "America/Vancouver", - actionType: "backlog-briefing", - actionConfig: { mode: "morning", to: "jeff@jeffemmett.com" }, - createdBy: "system", - }, - { - id: "backlog-weekly", - name: "Weekly Backlog Review", - description: "Friday afternoon review of all open tasks sorted by priority and staleness.", - enabled: true, - cronExpression: "0 22 * * 5", - timezone: "America/Vancouver", - actionType: "backlog-briefing", - actionConfig: { mode: "weekly", to: "jeff@jeffemmett.com" }, - createdBy: "system", - }, - { - id: "backlog-monthly", - name: "Monthly Backlog Audit", - description: "First of the month audit of stale tasks (14+ days untouched).", - enabled: true, - cronExpression: "0 14 1 * *", - timezone: "America/Vancouver", - actionType: "backlog-briefing", - actionConfig: { mode: "monthly", to: "jeff@jeffemmett.com" }, - createdBy: "system", - }, - { - id: "calendar-reminder-daily", - name: "Daily Calendar Reminders", - description: "Sends email reminders for knowledge items scheduled on today's date.", - enabled: true, - cronExpression: "0 14 * * *", - timezone: "America/Vancouver", - actionType: "calendar-reminder", - actionConfig: { to: "jeff@jeffemmett.com" }, - createdBy: "system", - }, -]; - -function seedDefaultJobs(space: string) { - const docId = scheduleDocId(space); - const doc = ensureDoc(space); - - if (Object.keys(doc.jobs).length > 0) return; - - const now = Date.now(); - _syncServer!.changeDoc(docId, "seed default jobs", (d) => { - for (const seed of SEED_JOBS) { - d.jobs[seed.id] = { - ...seed, - lastRunAt: null, - lastRunStatus: null, - lastRunMessage: "", - nextRunAt: computeNextRun(seed.cronExpression, seed.timezone), - runCount: 0, - createdAt: now, - updatedAt: now, - }; - } - }); - - console.log(`[Schedule] Seeded ${SEED_JOBS.length} default jobs for space "${space}"`); -} - -// ── API routes ── - -// GET / — serve schedule UI -routes.get("/", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - return c.html( - renderShell({ - title: `${space} — Schedule | rSpace`, - moduleId: "rschedule", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - }), - ); -}); - -// GET /api/jobs — list all jobs -routes.get("/api/jobs", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const doc = ensureDoc(dataSpace); - const jobs = Object.values(doc.jobs).map((j) => ({ - ...j, - cronHuman: cronToHuman(j.cronExpression), - })); - jobs.sort((a, b) => a.name.localeCompare(b.name)); - return c.json({ count: jobs.length, results: jobs }); -}); - -// POST /api/jobs — create a new job -routes.post("/api/jobs", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const body = await c.req.json(); - - const { name, description, cronExpression, timezone, actionType, actionConfig, enabled } = body; - if (!name?.trim() || !cronExpression || !actionType) - return c.json({ error: "name, cronExpression, and actionType required" }, 400); - - // Validate cron expression - try { - CronExpressionParser.parse(cronExpression); - } catch { - return c.json({ error: "Invalid cron expression" }, 400); - } - - const docId = scheduleDocId(dataSpace); - ensureDoc(dataSpace); - const jobId = crypto.randomUUID(); - const now = Date.now(); - const tz = timezone || "UTC"; - - _syncServer!.changeDoc(docId, `create job ${jobId}`, (d) => { - d.jobs[jobId] = { - id: jobId, - name: name.trim(), - description: description || "", - enabled: enabled !== false, - cronExpression, - timezone: tz, - actionType, - actionConfig: actionConfig || {}, - lastRunAt: null, - lastRunStatus: null, - lastRunMessage: "", - nextRunAt: computeNextRun(cronExpression, tz), - runCount: 0, - createdBy: "user", - createdAt: now, - updatedAt: now, - }; - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.jobs[jobId], 201); -}); - -// GET /api/jobs/:id -routes.get("/api/jobs/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const doc = ensureDoc(dataSpace); - - const job = doc.jobs[id]; - if (!job) return c.json({ error: "Job not found" }, 404); - return c.json({ ...job, cronHuman: cronToHuman(job.cronExpression) }); -}); - -// PUT /api/jobs/:id — update a job -routes.put("/api/jobs/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const body = await c.req.json(); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); - - // Validate cron if provided - if (body.cronExpression) { - try { - CronExpressionParser.parse(body.cronExpression); - } catch { - return c.json({ error: "Invalid cron expression" }, 400); - } - } - - _syncServer!.changeDoc(docId, `update job ${id}`, (d) => { - const j = d.jobs[id]; - if (!j) return; - if (body.name !== undefined) j.name = body.name; - if (body.description !== undefined) j.description = body.description; - if (body.enabled !== undefined) j.enabled = body.enabled; - if (body.cronExpression !== undefined) { - j.cronExpression = body.cronExpression; - j.nextRunAt = computeNextRun(body.cronExpression, body.timezone || j.timezone); - } - if (body.timezone !== undefined) { - j.timezone = body.timezone; - j.nextRunAt = computeNextRun(j.cronExpression, body.timezone); - } - if (body.actionType !== undefined) j.actionType = body.actionType; - if (body.actionConfig !== undefined) j.actionConfig = body.actionConfig; - j.updatedAt = Date.now(); - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.jobs[id]); -}); - -// DELETE /api/jobs/:id -routes.delete("/api/jobs/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.jobs[id]) return c.json({ error: "Job not found" }, 404); - - _syncServer!.changeDoc(docId, `delete job ${id}`, (d) => { - delete d.jobs[id]; - }); - - return c.json({ ok: true }); -}); - -// POST /api/jobs/:id/run — manually trigger a job -routes.post("/api/jobs/:id/run", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - const job = doc.jobs[id]; - if (!job) return c.json({ error: "Job not found" }, 404); - - const startMs = Date.now(); - let result: { success: boolean; message: string }; - - try { - result = await executeJob(job, dataSpace); - } catch (e: any) { - result = { success: false, message: e.message || String(e) }; - } - - const durationMs = Date.now() - startMs; - const logEntry: ExecutionLogEntry = { - id: crypto.randomUUID(), - jobId: job.id, - status: result.success ? "success" : "error", - message: result.message, - durationMs, - timestamp: Date.now(), - }; - - _syncServer!.changeDoc(docId, `manual run ${id}`, (d) => { - const j = d.jobs[id]; - if (j) { - j.lastRunAt = Date.now(); - j.lastRunStatus = result.success ? "success" : "error"; - j.lastRunMessage = result.message; - j.runCount = (j.runCount || 0) + 1; - } - d.log.push(logEntry); - while (d.log.length > MAX_LOG_ENTRIES) { - d.log.splice(0, 1); - } - }); - - return c.json({ ...result, durationMs }); -}); - -// GET /api/log — execution log -routes.get("/api/log", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const doc = ensureDoc(dataSpace); - const log = [...doc.log].reverse(); // newest first - return c.json({ count: log.length, results: log }); -}); - -// GET /api/log/:jobId — execution log filtered by job -routes.get("/api/log/:jobId", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const jobId = c.req.param("jobId"); - const doc = ensureDoc(dataSpace); - const log = doc.log.filter((e) => e.jobId === jobId).reverse(); - return c.json({ count: log.length, results: log }); -}); - -// ── Reminder helpers ── - -function ensureRemindersCalendarSource(space: string): string { - const calDocId = calendarDocId(space); - const calDoc = _syncServer!.getDoc(calDocId); - if (!calDoc) return ""; - - // Check if "Reminders" source already exists - const existing = Object.values(calDoc.sources).find( - (s) => s.name === "Reminders" && s.sourceType === "rSchedule", - ); - if (existing) return existing.id; - - const sourceId = crypto.randomUUID(); - const now = Date.now(); - _syncServer!.changeDoc(calDocId, "create Reminders calendar source", (d) => { - d.sources[sourceId] = { - id: sourceId, - name: "Reminders", - sourceType: "rSchedule", - url: null, - color: "#f59e0b", - isActive: true, - isVisible: true, - syncIntervalMinutes: null, - lastSyncedAt: now, - ownerId: null, - createdAt: now, - }; - }); - return sourceId; -} - -function syncReminderToCalendar(reminder: Reminder, space: string): string | null { - if (!_syncServer) return null; - - const calDocId = calendarDocId(space); - const calDoc = _syncServer.getDoc(calDocId); - if (!calDoc) return null; - - const sourceId = ensureRemindersCalendarSource(space); - const eventId = crypto.randomUUID(); - const now = Date.now(); - const duration = reminder.allDay ? 86400000 : 3600000; - - _syncServer.changeDoc(calDocId, `sync reminder ${reminder.id} to calendar`, (d) => { - d.events[eventId] = { - id: eventId, - title: reminder.title, - description: reminder.description, - startTime: reminder.remindAt, - endTime: reminder.remindAt + duration, - allDay: reminder.allDay, - timezone: reminder.timezone || "UTC", - rrule: null, - status: null, - visibility: null, - sourceId, - sourceName: "Reminders", - sourceType: "rSchedule", - sourceColor: reminder.sourceColor || "#f59e0b", - locationId: null, - locationName: null, - coordinates: null, - locationGranularity: null, - locationLat: null, - locationLng: null, - locationBreadcrumb: null, - bookingStatus: null, - isVirtual: false, - virtualUrl: null, - virtualPlatform: null, - rToolSource: "rSchedule", - rToolEntityId: reminder.id, - attendees: [], - attendeeCount: 0, - tags: null, - metadata: null, - likelihood: null, - createdAt: now, - updatedAt: now, - }; - }); - - return eventId; -} - -function deleteCalendarEvent(space: string, eventId: string) { - if (!_syncServer) return; - const calDocId = calendarDocId(space); - const calDoc = _syncServer.getDoc(calDocId); - if (!calDoc || !calDoc.events[eventId]) return; - - _syncServer.changeDoc(calDocId, `delete reminder calendar event ${eventId}`, (d) => { - delete d.events[eventId]; - }); -} - -// ── Reminder email executor ── - -async function executeReminderEmail( - reminder: Reminder, - space: string, -): Promise<{ success: boolean; message: string }> { - const transport = getSmtpTransport(); - if (!transport) - return { success: false, message: "SMTP not configured (SMTP_PASS missing)" }; - if (!reminder.notifyEmail) - return { success: false, message: "No email address on reminder" }; - - const dateStr = new Date(reminder.remindAt).toLocaleDateString("en-US", { - weekday: "long", year: "numeric", month: "long", day: "numeric", - hour: "numeric", minute: "2-digit", - }); - - const sourceInfo = reminder.sourceModule - ? `

Source: ${reminder.sourceLabel || reminder.sourceModule}

` - : ""; - - const html = ` -
-

🔔 Reminder: ${reminder.title}

-

${dateStr}

- ${reminder.description ? `

${reminder.description}

` : ""} - ${sourceInfo} -

- Sent by rSchedule • Manage Reminders -

-
- `; - - await transport.sendMail({ - from: process.env.SMTP_FROM || "rSchedule ", - to: reminder.notifyEmail, - subject: `[Reminder] ${reminder.title}`, - html, - }); - - return { success: true, message: `Reminder email sent to ${reminder.notifyEmail}` }; -} - -// ── Reminder API routes ── - -// GET /api/reminders — list reminders -routes.get("/api/reminders", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const doc = ensureDoc(dataSpace); - - let reminders = Object.values(doc.reminders); - - // Query filters - const upcoming = c.req.query("upcoming"); - const completed = c.req.query("completed"); - - if (completed === "false") { - reminders = reminders.filter((r) => !r.completed); - } else if (completed === "true") { - reminders = reminders.filter((r) => r.completed); - } - - if (upcoming) { - const days = parseInt(upcoming) || 7; - const now = Date.now(); - const cutoff = now + days * 86400000; - reminders = reminders.filter((r) => r.remindAt >= now && r.remindAt <= cutoff); - } - - reminders.sort((a, b) => a.remindAt - b.remindAt); - return c.json({ count: reminders.length, results: reminders }); -}); - -// POST /api/reminders — create a reminder -routes.post("/api/reminders", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const body = await c.req.json(); - - const { title, description, remindAt, allDay, timezone, notifyEmail, syncToCalendar, cronExpression } = body; - if (!title?.trim() || !remindAt) - return c.json({ error: "title and remindAt required" }, 400); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - - if (Object.keys(doc.reminders).length >= MAX_REMINDERS) - return c.json({ error: `Maximum ${MAX_REMINDERS} reminders reached` }, 400); - - const reminderId = crypto.randomUUID(); - const now = Date.now(); - - const reminder: Reminder = { - id: reminderId, - title: title.trim(), - description: description || "", - remindAt: typeof remindAt === "number" ? remindAt : new Date(remindAt).getTime(), - allDay: allDay || false, - timezone: timezone || "UTC", - notifyEmail: notifyEmail || null, - notified: false, - completed: false, - sourceModule: body.sourceModule || null, - sourceEntityId: body.sourceEntityId || null, - sourceLabel: body.sourceLabel || null, - sourceColor: body.sourceColor || null, - cronExpression: cronExpression || null, - calendarEventId: null, - createdBy: "user", - createdAt: now, - updatedAt: now, - }; - - // Sync to calendar if requested - if (syncToCalendar) { - const eventId = syncReminderToCalendar(reminder, dataSpace); - if (eventId) reminder.calendarEventId = eventId; - } - - _syncServer!.changeDoc(docId, `create reminder ${reminderId}`, (d) => { - d.reminders[reminderId] = reminder; - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.reminders[reminderId], 201); -}); - -// GET /api/reminders/:id — get single reminder -routes.get("/api/reminders/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const doc = ensureDoc(dataSpace); - - const reminder = doc.reminders[id]; - if (!reminder) return c.json({ error: "Reminder not found" }, 404); - return c.json(reminder); -}); - -// PUT /api/reminders/:id — update a reminder -routes.put("/api/reminders/:id", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const body = await c.req.json(); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); - - _syncServer!.changeDoc(docId, `update reminder ${id}`, (d) => { - const r = d.reminders[id]; - if (!r) return; - if (body.title !== undefined) r.title = body.title; - if (body.description !== undefined) r.description = body.description; - if (body.remindAt !== undefined) r.remindAt = typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime(); - if (body.allDay !== undefined) r.allDay = body.allDay; - if (body.timezone !== undefined) r.timezone = body.timezone; - if (body.notifyEmail !== undefined) r.notifyEmail = body.notifyEmail; - if (body.cronExpression !== undefined) r.cronExpression = body.cronExpression; - r.updatedAt = Date.now(); - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.reminders[id]); -}); - -// DELETE /api/reminders/:id — delete (cascades to calendar) -routes.delete("/api/reminders/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - const reminder = doc.reminders[id]; - if (!reminder) return c.json({ error: "Reminder not found" }, 404); - - // Cascade: delete linked calendar event - if (reminder.calendarEventId) { - deleteCalendarEvent(dataSpace, reminder.calendarEventId); - } - - _syncServer!.changeDoc(docId, `delete reminder ${id}`, (d) => { - delete d.reminders[id]; - }); - - return c.json({ ok: true }); -}); - -// POST /api/reminders/:id/complete — mark completed -routes.post("/api/reminders/:id/complete", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); - - _syncServer!.changeDoc(docId, `complete reminder ${id}`, (d) => { - const r = d.reminders[id]; - if (!r) return; - r.completed = true; - r.updatedAt = Date.now(); - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.reminders[id]); -}); - -// POST /api/reminders/:id/snooze — reschedule to a new date -routes.post("/api/reminders/:id/snooze", async (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - const body = await c.req.json(); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.reminders[id]) return c.json({ error: "Reminder not found" }, 404); - - const newRemindAt = body.remindAt - ? (typeof body.remindAt === "number" ? body.remindAt : new Date(body.remindAt).getTime()) - : Date.now() + (body.hours || 24) * 3600000; - - _syncServer!.changeDoc(docId, `snooze reminder ${id}`, (d) => { - const r = d.reminders[id]; - if (!r) return; - r.remindAt = newRemindAt; - r.notified = false; - r.updatedAt = Date.now(); - }); - - // Update linked calendar event if exists - const updated = _syncServer!.getDoc(docId)!; - const reminder = updated.reminders[id]; - if (reminder?.calendarEventId) { - const calDocId = calendarDocId(dataSpace); - const duration = reminder.allDay ? 86400000 : 3600000; - _syncServer!.changeDoc(calDocId, `update reminder event time`, (d) => { - const ev = d.events[reminder.calendarEventId!]; - if (ev) { - ev.startTime = newRemindAt; - ev.endTime = newRemindAt + duration; - ev.updatedAt = Date.now(); - } - }); - } - - return c.json(updated.reminders[id]); -}); - -// ── Automation canvas page route ── - -routes.get("/reminders", (c) => { - const space = c.req.param("space") || "demo"; - return c.html( - renderShell({ - title: `${space} — Automations | rSpace`, - moduleId: "rschedule", - spaceSlug: space, - modules: getModuleInfoList(), - theme: "dark", - body: ``, - scripts: ``, - styles: ``, - }), - ); -}); - -// ── Workflow CRUD API ── - -routes.get("/api/workflows", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || 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") || space; - const body = await c.req.json(); - - const docId = scheduleDocId(dataSpace); - ensureDoc(dataSpace); - const wfId = crypto.randomUUID(); - const now = Date.now(); - - const workflow: Workflow = { - id: wfId, - name: body.name || "New Workflow", - enabled: body.enabled !== false, - nodes: body.nodes || [], - edges: body.edges || [], - lastRunAt: null, - lastRunStatus: null, - runCount: 0, - createdAt: now, - updatedAt: now, - }; - - _syncServer!.changeDoc(docId, `create workflow ${wfId}`, (d) => { - if (!d.workflows) d.workflows = {} as any; - (d.workflows as any)[wfId] = workflow; - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.workflows[wfId], 201); -}); - -routes.get("/api/workflows/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || 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") || space; - const id = c.req.param("id"); - const body = await c.req.json(); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); - - _syncServer!.changeDoc(docId, `update workflow ${id}`, (d) => { - const wf = d.workflows[id]; - if (!wf) return; - if (body.name !== undefined) wf.name = body.name; - if (body.enabled !== undefined) wf.enabled = body.enabled; - if (body.nodes !== undefined) { - // Replace the nodes array - while (wf.nodes.length > 0) wf.nodes.splice(0, 1); - for (const n of body.nodes) wf.nodes.push(n); - } - if (body.edges !== undefined) { - while (wf.edges.length > 0) wf.edges.splice(0, 1); - for (const e of body.edges) wf.edges.push(e); - } - wf.updatedAt = Date.now(); - }); - - const updated = _syncServer!.getDoc(docId)!; - return c.json(updated.workflows[id]); -}); - -routes.delete("/api/workflows/:id", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); - - _syncServer!.changeDoc(docId, `delete workflow ${id}`, (d) => { - delete d.workflows[id]; - }); - - return c.json({ ok: true }); -}); - -// ── Workflow execution engine ── - -interface NodeResult { - nodeId: string; - status: "success" | "error" | "skipped"; - message: string; - durationMs: number; - outputData?: unknown; -} - -function topologicalSort(nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowNode[] { - const adj = new Map(); - const inDegree = new Map(); - - for (const n of nodes) { - adj.set(n.id, []); - inDegree.set(n.id, 0); - } - - for (const e of edges) { - adj.get(e.fromNode)?.push(e.toNode); - inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1); - } - - const queue: string[] = []; - for (const [id, deg] of inDegree) { - if (deg === 0) queue.push(id); - } - - const sorted: string[] = []; - while (queue.length > 0) { - const id = queue.shift()!; - sorted.push(id); - for (const neighbor of adj.get(id) || []) { - const newDeg = (inDegree.get(neighbor) || 1) - 1; - inDegree.set(neighbor, newDeg); - if (newDeg === 0) queue.push(neighbor); - } - } - - const nodeMap = new Map(nodes.map(n => [n.id, n])); - return sorted.map(id => nodeMap.get(id)!).filter(Boolean); -} - -function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number { - const R = 6371; - const dLat = (lat2 - lat1) * Math.PI / 180; - const dLng = (lng2 - lng1) * Math.PI / 180; - const a = Math.sin(dLat / 2) ** 2 + - Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - -async function executeWorkflowNode( - node: WorkflowNode, - inputData: unknown, - space: string, -): Promise<{ success: boolean; message: string; outputData?: unknown }> { - const cfg = node.config; - - switch (node.type) { - // ── Triggers ── - case "trigger-cron": - return { success: true, message: "Cron triggered", outputData: { timestamp: Date.now() } }; - - case "trigger-data-change": - return { success: true, message: `Watching ${cfg.module || "any"} module`, outputData: inputData || {} }; - - case "trigger-webhook": - return { success: true, message: "Webhook received", outputData: inputData || {} }; - - case "trigger-manual": - return { success: true, message: "Manual trigger fired", outputData: { timestamp: Date.now() } }; - - case "trigger-proximity": { - const data = inputData as { lat?: number; lng?: number } | undefined; - if (!data?.lat || !data?.lng) return { success: true, message: "No location data", outputData: { distance: null } }; - const dist = haversineKm(data.lat, data.lng, Number(cfg.lat) || 0, Number(cfg.lng) || 0); - const inRange = dist <= (Number(cfg.radiusKm) || 1); - return { success: true, message: `Distance: ${dist.toFixed(2)}km (${inRange ? "in range" : "out of range"})`, outputData: { distance: dist, inRange } }; - } - - // ── Conditions ── - case "condition-compare": { - const val = String(inputData ?? ""); - const cmp = String(cfg.compareValue ?? ""); - let result = false; - switch (cfg.operator) { - case "equals": result = val === cmp; break; - case "not-equals": result = val !== cmp; break; - case "greater-than": result = Number(val) > Number(cmp); break; - case "less-than": result = Number(val) < Number(cmp); break; - case "contains": result = val.includes(cmp); break; - } - return { success: true, message: `Compare: ${result}`, outputData: result }; - } - - case "condition-geofence": { - const coords = inputData as { lat?: number; lng?: number } | undefined; - if (!coords?.lat || !coords?.lng) return { success: true, message: "No coords", outputData: false }; - const dist = haversineKm(coords.lat, coords.lng, Number(cfg.centerLat) || 0, Number(cfg.centerLng) || 0); - const inside = dist <= (Number(cfg.radiusKm) || 5); - return { success: true, message: `Geofence: ${inside ? "inside" : "outside"} (${dist.toFixed(2)}km)`, outputData: inside }; - } - - case "condition-time-window": { - const now = new Date(); - const hour = now.getHours(); - const day = now.getDay(); - const startH = Number(cfg.startHour) || 0; - const endH = Number(cfg.endHour) || 23; - const days = String(cfg.days || "0,1,2,3,4,5,6").split(",").map(Number); - const inWindow = hour >= startH && hour < endH && days.includes(day); - return { success: true, message: `Time window: ${inWindow ? "in" : "outside"}`, outputData: inWindow }; - } - - case "condition-data-filter": { - const data = inputData as Record | undefined; - const field = String(cfg.field || ""); - const val = data?.[field]; - let match = false; - switch (cfg.operator) { - case "equals": match = String(val) === String(cfg.value); break; - case "not-equals": match = String(val) !== String(cfg.value); break; - case "contains": match = String(val ?? "").includes(String(cfg.value ?? "")); break; - case "exists": match = val !== undefined && val !== null; break; - } - return { success: true, message: `Filter: ${match ? "match" : "no match"}`, outputData: match ? data : null }; - } - - // ── Actions ── - case "action-send-email": { - const transport = getSmtpTransport(); - if (!transport) return { success: false, message: "SMTP not configured" }; - if (!cfg.to) return { success: false, message: "No recipient" }; - - const vars: Record = { - date: new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric" }), - timestamp: new Date().toISOString(), - ...(typeof inputData === "object" && inputData !== null - ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) - : {}), - }; - - const subject = renderTemplate(String(cfg.subject || "Automation Notification"), vars); - const html = renderTemplate(String(cfg.bodyTemplate || `

Automation executed at ${vars.date}.

`), vars); - - await transport.sendMail({ - from: process.env.SMTP_FROM || "rSchedule ", - to: String(cfg.to), - subject, - html, - }); - return { success: true, message: `Email sent to ${cfg.to}` }; - } - - case "action-post-webhook": { - if (!cfg.url) return { success: false, message: "No URL configured" }; - const method = String(cfg.method || "POST").toUpperCase(); - const vars: Record = { - timestamp: new Date().toISOString(), - ...(typeof inputData === "object" && inputData !== null - ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) - : {}), - }; - const body = renderTemplate(String(cfg.bodyTemplate || JSON.stringify({ timestamp: vars.timestamp })), vars); - const res = await fetch(String(cfg.url), { - method, - headers: { "Content-Type": "application/json" }, - body: method !== "GET" ? body : undefined, - }); - if (!res.ok) return { success: false, message: `Webhook ${res.status}` }; - return { success: true, message: `Webhook ${method} ${cfg.url} -> ${res.status}`, outputData: await res.json().catch(() => null) }; - } - - case "action-create-event": { - if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; - const calDocId = calendarDocId(space); - const calDoc = _syncServer.getDoc(calDocId); - if (!calDoc) return { success: false, message: "Calendar doc not found" }; - - const eventId = crypto.randomUUID(); - const now = Date.now(); - const durationMs = (Number(cfg.durationMinutes) || 60) * 60 * 1000; - - _syncServer.changeDoc(calDocId, `automation: create event`, (d) => { - d.events[eventId] = { - id: eventId, - title: String(cfg.title || "Automation Event"), - description: "Created by rSchedule automation", - startTime: now, - endTime: now + durationMs, - allDay: false, - timezone: "UTC", - rrule: null, - status: null, - visibility: null, - sourceId: null, - sourceName: null, - sourceType: null, - sourceColor: null, - locationId: null, - locationName: null, - coordinates: null, - locationGranularity: null, - locationLat: null, - locationLng: null, - locationBreadcrumb: null, - bookingStatus: null, - isVirtual: false, - virtualUrl: null, - virtualPlatform: null, - rToolSource: "rSchedule", - rToolEntityId: node.id, - attendees: [], - attendeeCount: 0, - tags: null, - metadata: null, - likelihood: null, - createdAt: now, - updatedAt: now, - }; - }); - return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } }; - } - - case "action-create-task": { - if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; - const title = String(cfg.title || "New task"); - const taskId = crypto.randomUUID(); - - // Find the default board for this space (first board doc) - const defaultBoardId = "default"; - const taskDocId = boardDocId(space, defaultBoardId); - let taskDoc = _syncServer.getDoc(taskDocId); - if (!taskDoc) { - // Initialize the board doc if it doesn't exist - const initDoc = Automerge.change(Automerge.init(), "init board", (d) => { - d.meta = { module: "tasks", collection: "boards", version: 1, spaceSlug: space, createdAt: Date.now() } as any; - d.board = { id: defaultBoardId, name: "Default Board", slug: "default", description: "", icon: null, ownerDid: null, statuses: ["TODO", "IN_PROGRESS", "DONE"], labels: [], createdAt: Date.now(), updatedAt: Date.now() } as any; - d.tasks = {} as any; - }); - _syncServer.setDoc(taskDocId, initDoc); - taskDoc = _syncServer.getDoc(taskDocId); - } - - const task = createTaskItem(taskId, space, title, { - description: String(cfg.description || ""), - priority: String(cfg.priority || "medium"), - status: "TODO", - }); - - _syncServer.changeDoc(taskDocId, `automation: create task`, (d) => { - d.tasks[taskId] = task; - }); - return { success: true, message: `Task created: ${title}`, outputData: { taskId, title } }; - } - - case "action-send-notification": { - const title = String(cfg.title || "Notification"); - const message = String(cfg.message || ""); - const level = String(cfg.level || "info"); - - // Log the notification server-side; delivery to clients happens via - // the community doc's eventLog (synced to all connected peers). - console.log(`[Automation] Notification [${level}]: ${title} — ${message}`); - return { success: true, message: `Notification sent: ${title}`, outputData: { title, message, level } }; - } - - case "action-update-data": { - if (!_syncServer) return { success: false, message: "SyncServer unavailable" }; - const module = String(cfg.module || ""); - const operation = String(cfg.operation || "update"); - let templateData: Record = {}; - try { - const vars: Record = { - timestamp: new Date().toISOString(), - ...(typeof inputData === "object" && inputData !== null - ? Object.fromEntries(Object.entries(inputData as Record).map(([k, v]) => [k, String(v)])) - : {}), - }; - const rendered = renderTemplate(String(cfg.template || "{}"), vars); - templateData = JSON.parse(rendered); - } catch { - return { success: false, message: "Invalid data template JSON" }; - } - - // Apply update to the target module's doc - const targetDocId = `${space}:${module}:default`; - const targetDoc = _syncServer.getDoc(targetDocId); - if (!targetDoc) return { success: false, message: `Doc not found: ${targetDocId}` }; - - _syncServer.changeDoc(targetDocId, `automation: ${operation}`, (d: any) => { - for (const [key, value] of Object.entries(templateData)) { - d[key] = value; - } - }); - return { success: true, message: `Data ${operation} applied to ${module}`, outputData: templateData }; - } - - default: - return { success: false, message: `Unknown node type: ${node.type}` }; - } -} - -async function executeWorkflow( - workflow: Workflow, - space: string, - triggerData?: unknown, -): Promise { - const sorted = topologicalSort(workflow.nodes, workflow.edges); - const results: NodeResult[] = []; - const nodeOutputs = new Map(); - - for (const node of sorted) { - const startMs = Date.now(); - - // Gather input data from upstream edges - let inputData: unknown = triggerData; - const incomingEdges = workflow.edges.filter(e => e.toNode === node.id); - if (incomingEdges.length > 0) { - // For conditions that output booleans: if the upstream condition result is false - // and this node connects via the "true" port, skip it - const upstreamNode = workflow.nodes.find(n => n.id === incomingEdges[0].fromNode); - const upstreamOutput = nodeOutputs.get(incomingEdges[0].fromNode); - - if (upstreamNode?.type.startsWith("condition-")) { - const port = incomingEdges[0].fromPort; - // Boolean result from condition - if (port === "true" && upstreamOutput === false) { - results.push({ nodeId: node.id, status: "skipped", message: "Condition false, skipping true branch", durationMs: 0 }); - continue; - } - if (port === "false" && upstreamOutput === true) { - results.push({ nodeId: node.id, status: "skipped", message: "Condition true, skipping false branch", durationMs: 0 }); - continue; - } - if (port === "inside" && upstreamOutput === false) { - results.push({ nodeId: node.id, status: "skipped", message: "Outside geofence, skipping inside branch", durationMs: 0 }); - continue; - } - if (port === "outside" && upstreamOutput === true) { - results.push({ nodeId: node.id, status: "skipped", message: "Inside geofence, skipping outside branch", durationMs: 0 }); - continue; - } - if (port === "in-window" && upstreamOutput === false) { - results.push({ nodeId: node.id, status: "skipped", message: "Outside time window", durationMs: 0 }); - continue; - } - if (port === "match" && upstreamOutput === null) { - results.push({ nodeId: node.id, status: "skipped", message: "Data filter: no match", durationMs: 0 }); - continue; - } - if (port === "no-match" && upstreamOutput !== null) { - results.push({ nodeId: node.id, status: "skipped", message: "Data filter: matched", durationMs: 0 }); - continue; - } - } - - // Use upstream output as input - if (upstreamOutput !== undefined) inputData = upstreamOutput; - } - - // Execute with retry (max 2 retries, exponential backoff 1s/2s) - const MAX_RETRIES = 2; - let lastError: string = ""; - let succeeded = false; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - 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 + (attempt > 0 ? ` (retry ${attempt})` : ""), - durationMs, - outputData: result.outputData, - }); - succeeded = true; - break; - } catch (e: any) { - lastError = e.message || String(e); - if (attempt < MAX_RETRIES) { - await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt))); - } - } - } - - if (!succeeded) { - results.push({ - nodeId: node.id, - status: "error", - message: `${lastError} (after ${MAX_RETRIES + 1} attempts)`, - durationMs: Date.now() - startMs, - }); - } - } - - return results; -} - -/** Append a workflow execution log entry to the schedule doc. */ -function appendWorkflowLog( - space: string, - workflowId: string, - results: NodeResult[], - triggerType: string, -): void { - if (!_syncServer) return; - const docId = scheduleDocId(space); - - const entry: WorkflowLogEntry = { - id: crypto.randomUUID(), - workflowId, - nodeResults: results.map(r => ({ - nodeId: r.nodeId, - status: r.status, - message: r.message, - durationMs: r.durationMs, - })), - overallStatus: results.every(r => r.status !== "error") ? "success" : "error", - timestamp: Date.now(), - triggerType, - }; - - _syncServer.changeDoc(docId, `log workflow ${workflowId}`, (d) => { - if (!d.workflowLog) d.workflowLog = [] as any; - (d.workflowLog as any).push(entry); - // Cap at MAX_WORKFLOW_LOG entries - while ((d.workflowLog as any).length > MAX_WORKFLOW_LOG) { - (d.workflowLog as any).splice(0, 1); - } - }); -} - -// 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") || space; - const id = c.req.param("id"); - - const docId = scheduleDocId(dataSpace); - const doc = ensureDoc(dataSpace); - const wf = doc.workflows?.[id]; - if (!wf) return c.json({ error: "Workflow not found" }, 404); - - const results = await executeWorkflow(wf, dataSpace); - - const allOk = results.every(r => r.status !== "error"); - _syncServer!.changeDoc(docId, `run workflow ${id}`, (d) => { - const w = d.workflows[id]; - if (!w) return; - w.lastRunAt = Date.now(); - w.lastRunStatus = allOk ? "success" : "error"; - w.runCount = (w.runCount || 0) + 1; - w.updatedAt = Date.now(); - }); - - appendWorkflowLog(dataSpace, id, results, "manual"); - - 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") || 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 }); - appendWorkflowLog(dataSpace, wf.id, results, "webhook"); - } - - return c.json({ triggered: matches.length, results: allResults }); -}); - -// GET /api/workflows/log — workflow execution log -routes.get("/api/workflows/log", (c) => { - const space = c.req.param("space") || "demo"; - const dataSpace = c.get("effectiveSpace") || space; - const doc = ensureDoc(dataSpace); - const log = [...(doc.workflowLog || [])].reverse(); // newest first - return c.json({ count: log.length, results: log }); -}); - -// ── Demo workflow seeds ── - -function seedDemoWorkflows(space: string) { - const docId = scheduleDocId(space); - const doc = ensureDoc(space); - - if (Object.keys(doc.workflows || {}).length > 0) return; - - const now = Date.now(); - - const demo1Id = "demo-arriving-home"; - const demo1: Workflow = { - id: demo1Id, - name: "Arriving Home Notification", - enabled: false, - nodes: [ - { id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } }, - { id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } }, - { id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "

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

" } }, - ], - edges: [ - { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, - { id: "e2", fromNode: "n1", fromPort: "distance", toNode: "n2", toPort: "coords" }, - { id: "e3", fromNode: "n2", fromPort: "inside", toNode: "n3", toPort: "trigger" }, - ], - lastRunAt: null, - lastRunStatus: null, - runCount: 0, - createdAt: now, - updatedAt: now, - }; - - const demo2Id = "demo-signoff-pipeline"; - const demo2: Workflow = { - id: demo2Id, - name: "Document Sign-off Pipeline", - enabled: false, - nodes: [ - { id: "n1", type: "trigger-data-change", label: "Watch Sign-offs", position: { x: 50, y: 100 }, config: { module: "rnotes", field: "status" } }, - { id: "n2", type: "condition-compare", label: "Status = Signed", position: { x: 350, y: 100 }, config: { operator: "equals", compareValue: "signed" } }, - { id: "n3", type: "action-create-event", label: "Schedule Review", position: { x: 650, y: 60 }, config: { title: "Document Review Meeting", durationMinutes: "30" } }, - { id: "n4", type: "action-send-notification", label: "Notify Comms", position: { x: 650, y: 180 }, config: { title: "Sign-off Complete", message: "Document has been signed off", level: "success" } }, - ], - edges: [ - { id: "e1", fromNode: "n1", fromPort: "trigger", toNode: "n2", toPort: "trigger" }, - { id: "e2", fromNode: "n1", fromPort: "data", toNode: "n2", toPort: "value" }, - { id: "e3", fromNode: "n2", fromPort: "true", toNode: "n3", toPort: "trigger" }, - { id: "e4", fromNode: "n2", fromPort: "true", toNode: "n4", toPort: "trigger" }, - ], - lastRunAt: null, - lastRunStatus: null, - runCount: 0, - createdAt: now, - updatedAt: now, - }; - - _syncServer!.changeDoc(docId, "seed demo workflows", (d) => { - if (!d.workflows) d.workflows = {} as any; - (d.workflows as any)[demo1Id] = demo1; - (d.workflows as any)[demo2Id] = demo2; - }); - - console.log(`[Schedule] Seeded 2 demo workflows for space "${space}"`); -} - -// ── Module export ── - -export const scheduleModule: RSpaceModule = { - id: "rschedule", - name: "rSchedule", - icon: "⏱", - description: "Persistent cron-based job scheduling with email, webhooks, and backlog briefings", - scoping: { defaultScope: "global", userConfigurable: false }, - docSchemas: [ - { - pattern: "{space}:schedule:jobs", - description: "Scheduled jobs and execution log", - init: scheduleSchema.init, - }, - ], - routes, - landingPage: renderLanding, - seedTemplate: seedDefaultJobs, - async onInit(ctx) { - _syncServer = ctx.syncServer; - seedDefaultJobs("demo"); - seedDemoWorkflows("demo"); - startTickLoop(); - }, - feeds: [ - { - id: "executions", - name: "Executions", - kind: "data", - description: "Job execution events with status, timing, and output", - }, - ], - acceptsFeeds: ["data", "governance"], - 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" }, - ], - onboardingActions: [ - { label: "Create a Schedule", icon: "⏱", description: "Set up a recurring job or reminder", type: 'create', href: '/{space}/rschedule' }, - ], -}; - -// ── MI Data Export ── - -export interface MIReminderItem { - id: string; - title: string; - remindAt: number; - sourceModule: string | null; - sourceLabel: string | null; -} - -export function getUpcomingRemindersForMI(space: string, days = 14, limit = 5): MIReminderItem[] { - if (!_syncServer) return []; - const doc = _syncServer.getDoc(scheduleDocId(space)); - if (!doc?.reminders) return []; - - const now = Date.now(); - const cutoff = now + days * 86400000; - - return Object.values(doc.reminders) - .filter(r => !r.completed && r.remindAt >= now && r.remindAt <= cutoff) - .sort((a, b) => a.remindAt - b.remindAt) - .slice(0, limit) - .map(r => ({ - id: r.id, - title: r.title, - remindAt: r.remindAt, - sourceModule: r.sourceModule, - sourceLabel: r.sourceLabel, - })); -} diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts deleted file mode 100644 index d132157a..00000000 --- a/modules/rschedule/schemas.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * rSchedule Automerge document schemas. - * - * Granularity: one Automerge document per space (all jobs + execution log). - * DocId format: {space}:schedule:jobs - */ - -import type { DocSchema } from '../../shared/local-first/document'; - -// ── Document types ── - -export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing' | 'calendar-reminder'; - -export interface ScheduleJob { - id: string; - name: string; - description: string; - enabled: boolean; - - // Timing - cronExpression: string; - timezone: string; - - // Action - actionType: ActionType; - actionConfig: Record; - - // Execution state - lastRunAt: number | null; - lastRunStatus: 'success' | 'error' | null; - lastRunMessage: string; - nextRunAt: number | null; - runCount: number; - - // Metadata - createdBy: string; - createdAt: number; - updatedAt: number; -} - -export interface ExecutionLogEntry { - id: string; - jobId: string; - status: 'success' | 'error'; - message: string; - durationMs: number; - timestamp: number; -} - -export interface Reminder { - id: string; - title: string; - description: string; - remindAt: number; // epoch ms — when to fire - allDay: boolean; - timezone: string; - notifyEmail: string | null; - notified: boolean; // has email been sent? - completed: boolean; // dismissed by user? - - // Cross-module reference (null for free-form reminders) - sourceModule: string | null; // "rtasks", "rnotes", etc. - sourceEntityId: string | null; - sourceLabel: string | null; // "rTasks Task" - sourceColor: string | null; // "#f97316" - - // Optional recurrence - cronExpression: string | null; - - // Link to rCal event (bidirectional) - calendarEventId: string | null; - - createdBy: string; - createdAt: number; - updatedAt: number; -} - -// ── Workflow / Automation types ── - -export type AutomationNodeType = - // Triggers - | 'trigger-cron' - | 'trigger-data-change' - | 'trigger-webhook' - | 'trigger-manual' - | 'trigger-proximity' - // Conditions - | 'condition-compare' - | 'condition-geofence' - | 'condition-time-window' - | 'condition-data-filter' - // Actions - | 'action-send-email' - | 'action-post-webhook' - | 'action-create-event' - | 'action-create-task' - | 'action-send-notification' - | 'action-update-data'; - -export type AutomationNodeCategory = 'trigger' | 'condition' | 'action'; - -export interface WorkflowNodePort { - name: string; - type: 'trigger' | 'data' | 'boolean'; -} - -export interface WorkflowNode { - id: string; - type: AutomationNodeType; - label: string; - position: { x: number; y: number }; - config: Record; - // Runtime state (not persisted to Automerge — set during execution) - runtimeStatus?: 'idle' | 'running' | 'success' | 'error'; - runtimeMessage?: string; - runtimeDurationMs?: number; -} - -export interface WorkflowEdge { - id: string; - fromNode: string; - fromPort: string; - toNode: string; - toPort: string; -} - -export interface Workflow { - id: string; - name: string; - enabled: boolean; - nodes: WorkflowNode[]; - edges: WorkflowEdge[]; - lastRunAt: number | null; - lastRunStatus: 'success' | 'error' | null; - runCount: number; - createdAt: number; - updatedAt: number; -} - -export interface AutomationNodeDef { - type: AutomationNodeType; - category: AutomationNodeCategory; - label: string; - icon: string; - description: string; - inputs: WorkflowNodePort[]; - outputs: WorkflowNodePort[]; - configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[]; -} - -export const NODE_CATALOG: AutomationNodeDef[] = [ - // ── Triggers ── - { - type: 'trigger-cron', - category: 'trigger', - label: 'Cron Schedule', - icon: '⏰', - description: 'Fire on a cron schedule', - inputs: [], - outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }], - configSchema: [ - { key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' }, - { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' }, - ], - }, - { - type: 'trigger-data-change', - category: 'trigger', - label: 'Data Change', - icon: '📊', - description: 'Fire when data changes in any rApp', - inputs: [], - outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], - configSchema: [ - { key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] }, - { key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' }, - ], - }, - { - type: 'trigger-webhook', - category: 'trigger', - label: 'Webhook Incoming', - icon: '🔗', - description: 'Fire when an external webhook is received', - inputs: [], - outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }], - configSchema: [ - { key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' }, - ], - }, - { - type: 'trigger-manual', - category: 'trigger', - label: 'Manual Trigger', - icon: '👆', - description: 'Fire manually via button click', - inputs: [], - outputs: [{ name: 'trigger', type: 'trigger' }], - configSchema: [], - }, - { - type: 'trigger-proximity', - category: 'trigger', - label: 'Location Proximity', - icon: '📍', - description: 'Fire when a location approaches a point', - inputs: [], - outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }], - configSchema: [ - { key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' }, - { key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' }, - { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' }, - ], - }, - // ── Conditions ── - { - type: 'condition-compare', - category: 'condition', - label: 'Compare Values', - icon: '⚖️', - description: 'Compare two values', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }], - outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }], - configSchema: [ - { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] }, - { key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' }, - ], - }, - { - type: 'condition-geofence', - category: 'condition', - label: 'Geofence Check', - icon: '🗺️', - description: 'Check if coordinates are within a radius', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }], - outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }], - configSchema: [ - { key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' }, - { key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' }, - { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' }, - ], - }, - { - type: 'condition-time-window', - category: 'condition', - label: 'Time Window', - icon: '🕐', - description: 'Check if current time is within a window', - inputs: [{ name: 'trigger', type: 'trigger' }], - outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }], - configSchema: [ - { key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' }, - { key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' }, - { key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' }, - ], - }, - { - type: 'condition-data-filter', - category: 'condition', - label: 'Data Filter', - icon: '🔍', - description: 'Filter data by field value', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], - outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }], - configSchema: [ - { key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' }, - { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] }, - { key: 'value', label: 'Value', type: 'text', placeholder: 'completed' }, - ], - }, - // ── Actions ── - { - type: 'action-send-email', - category: 'action', - label: 'Send Email', - icon: '📧', - description: 'Send an email via SMTP', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], - outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], - configSchema: [ - { key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' }, - { key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' }, - { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '

Hello {{name}}

' }, - ], - }, - { - type: 'action-post-webhook', - category: 'action', - label: 'POST Webhook', - icon: '🌐', - description: 'Send an HTTP POST to an external URL', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], - outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }], - configSchema: [ - { key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' }, - { key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] }, - { key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' }, - ], - }, - { - type: 'action-create-event', - category: 'action', - label: 'Create Calendar Event', - icon: '📅', - description: 'Create an event in rCal', - inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], - outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }], - configSchema: [ - { key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' }, - { key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' }, - ], - }, - { - type: 'action-create-task', - category: 'action', - label: 'Create Task', - icon: '✅', - description: 'Create a task in rTasks', - 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', 'rtasks', '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 WorkflowLogEntry { - id: string; - workflowId: string; - nodeResults: { nodeId: string; status: string; message: string; durationMs: number }[]; - overallStatus: 'success' | 'error'; - timestamp: number; - triggerType: string; -} - -export interface ScheduleDoc { - meta: { - module: string; - collection: string; - version: number; - spaceSlug: string; - createdAt: number; - }; - jobs: Record; - reminders: Record; - workflows: Record; - log: ExecutionLogEntry[]; - workflowLog?: WorkflowLogEntry[]; -} - -// ── Schema registration ── - -export const scheduleSchema: DocSchema = { - module: 'schedule', - collection: 'jobs', - version: 1, - init: (): ScheduleDoc => ({ - meta: { - module: 'schedule', - collection: 'jobs', - version: 1, - spaceSlug: '', - createdAt: Date.now(), - }, - jobs: {}, - reminders: {}, - workflows: {}, - log: [], - }), -}; - -// ── Helpers ── - -export function scheduleDocId(space: string) { - return `${space}:schedule:jobs` as const; -} - -/** Maximum execution log entries to keep per doc */ -export const MAX_LOG_ENTRIES = 200; - -/** Maximum workflow log entries to keep per doc */ -export const MAX_WORKFLOW_LOG = 100; - -/** Maximum reminders per space */ -export const MAX_REMINDERS = 500; diff --git a/server/index.ts b/server/index.ts index 1c0a74c9..6457f13e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -82,7 +82,7 @@ import { chatsModule } from "../modules/rchats/mod"; import { agentsModule } from "../modules/ragents/mod"; import { docsModule } from "../modules/rdocs/mod"; import { designModule } from "../modules/rdesign/mod"; -import { scheduleModule } from "../modules/rschedule/mod"; +import { mindersModule } from "../modules/rminders/mod"; import { bnbModule } from "../modules/rbnb/mod"; import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; @@ -167,7 +167,7 @@ registerModule(dataModule); registerModule(splatModule); registerModule(photosModule); registerModule(socialsModule); -registerModule(scheduleModule); +registerModule(mindersModule); registerModule(meetsModule); registerModule(chatsModule); registerModule(agentsModule); diff --git a/server/mcp-tools/rschedule.ts b/server/mcp-tools/rminders.ts similarity index 86% rename from server/mcp-tools/rschedule.ts rename to server/mcp-tools/rminders.ts index 4fa7cdd7..1ae303c8 100644 --- a/server/mcp-tools/rschedule.ts +++ b/server/mcp-tools/rminders.ts @@ -1,20 +1,20 @@ /** - * MCP tools for rSchedule (cron jobs, reminders, workflows). + * MCP tools for rMinders (cron jobs, reminders, workflows). * - * Tools: rschedule_list_jobs, rschedule_list_reminders, - * rschedule_list_workflows, rschedule_create_reminder + * Tools: rminders_list_jobs, rminders_list_reminders, + * rminders_list_workflows, rminders_create_reminder */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; -import { scheduleDocId } from "../../modules/rschedule/schemas"; -import type { ScheduleDoc } from "../../modules/rschedule/schemas"; +import { mindersDocId } from "../../modules/rminders/schemas"; +import type { MindersDoc } from "../../modules/rminders/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; -export function registerScheduleTools(server: McpServer, syncServer: SyncServer) { +export function registerMindersTools(server: McpServer, syncServer: SyncServer) { server.tool( - "rschedule_list_jobs", + "rminders_list_jobs", "List cron/scheduled jobs in a space", { space: z.string().describe("Space slug"), @@ -25,7 +25,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const doc = syncServer.getDoc(scheduleDocId(space)); + const doc = syncServer.getDoc(mindersDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; } @@ -52,7 +52,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) ); server.tool( - "rschedule_list_reminders", + "rminders_list_reminders", "List reminders in a space", { space: z.string().describe("Space slug"), @@ -65,7 +65,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const doc = syncServer.getDoc(scheduleDocId(space)); + const doc = syncServer.getDoc(mindersDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; } @@ -102,7 +102,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) ); server.tool( - "rschedule_list_workflows", + "rminders_list_workflows", "List automation workflows in a space (summaries only, omits node/edge graph)", { space: z.string().describe("Space slug"), @@ -112,7 +112,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) const access = await resolveAccess(token, space, false); if (!access.allowed) return accessDeniedResponse(access.reason!); - const doc = syncServer.getDoc(scheduleDocId(space)); + const doc = syncServer.getDoc(mindersDocId(space)); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] }; } @@ -135,7 +135,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) ); server.tool( - "rschedule_create_reminder", + "rminders_create_reminder", "Create a new reminder (requires auth token + space membership)", { space: z.string().describe("Space slug"), @@ -151,8 +151,8 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) const access = await resolveAccess(token, space, true); if (!access.allowed) return accessDeniedResponse(access.reason!); - const docId = scheduleDocId(space); - const doc = syncServer.getDoc(docId); + const docId = mindersDocId(space); + const doc = syncServer.getDoc(docId); if (!doc) { return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }], isError: true }; } @@ -160,7 +160,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer) const reminderId = `rem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); - syncServer.changeDoc(docId, `Create reminder ${title}`, (d) => { + syncServer.changeDoc(docId, `Create reminder ${title}`, (d) => { if (!d.reminders) (d as any).reminders = {}; d.reminders[reminderId] = { id: reminderId, diff --git a/shared/draggable.ts b/shared/draggable.ts index a9295f26..8b2f839c 100644 --- a/shared/draggable.ts +++ b/shared/draggable.ts @@ -4,6 +4,10 @@ * Any module card/list-item can become draggable by calling * `makeDraggable(el, payload)` — the calendar and reminders widget * will accept the drop via `application/rspace-item`. + * + * Uses native HTML5 drag for mouse, and a PointerEvent-based fallback + * for touch/pen that synthesizes real DragEvents with a constructed + * DataTransfer — drop receivers don't need to change. */ export interface RSpaceItemPayload { @@ -28,13 +32,19 @@ export const MODULE_COLORS: Record = { rtube: "#a855f7", // purple }; +const TOUCH_DRAG_THRESHOLD = 8; // px before a touch becomes a drag +const TOUCH_DRAG_LONGPRESS_MS = 350; + /** * Make an element draggable with the rspace-item protocol. - * Adds draggable attribute and dragstart handler. + * Adds draggable attribute and dragstart handler for mouse, plus a + * pointer-based touch/pen fallback that fires synthetic DragEvents. */ export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) { el.draggable = true; el.style.cursor = "grab"; + + // Native HTML5 drag (mouse / desktop trackpad) el.addEventListener("dragstart", (e) => { if (!e.dataTransfer) return; e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload)); @@ -45,6 +55,9 @@ export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) { el.addEventListener("dragend", () => { el.style.opacity = ""; }); + + // Pointer-based fallback for touch + pen (iOS/iPadOS/Android) + attachPointerDragFallback(el, payload); } /** @@ -62,3 +75,189 @@ export function makeDraggableAll( if (payload) makeDraggable(el, payload); }); } + +// ── Pointer-based drag fallback ────────────────────────────────────── + +interface TouchDragState { + el: HTMLElement; + payload: RSpaceItemPayload; + startX: number; + startY: number; + started: boolean; + pointerId: number; + dataTransfer: DataTransfer | null; + ghost: HTMLElement | null; + lastTarget: Element | null; + longPressTimer: ReturnType | null; +} + +function attachPointerDragFallback(el: HTMLElement, payload: RSpaceItemPayload) { + let state: TouchDragState | null = null; + + el.addEventListener("pointerdown", (e) => { + // Mouse goes through native HTML5 drag path — skip. + if (e.pointerType === "mouse") return; + // Only primary button + if (e.button !== 0) return; + // If inside an editable surface, don't hijack + const target = e.target as HTMLElement | null; + if (target?.closest("input, textarea, [contenteditable='true']")) return; + + state = { + el, + payload, + startX: e.clientX, + startY: e.clientY, + started: false, + pointerId: e.pointerId, + dataTransfer: null, + ghost: null, + lastTarget: null, + longPressTimer: null, + }; + + // Long-press alternative: start drag after 350ms even without movement + state.longPressTimer = setTimeout(() => { + if (state && !state.started) beginPointerDrag(state, e.clientX, e.clientY); + }, TOUCH_DRAG_LONGPRESS_MS); + }); + + window.addEventListener("pointermove", (e) => { + if (!state || e.pointerId !== state.pointerId) return; + if (!state.started) { + const dx = e.clientX - state.startX; + const dy = e.clientY - state.startY; + if (Math.hypot(dx, dy) < TOUCH_DRAG_THRESHOLD) return; + beginPointerDrag(state, e.clientX, e.clientY); + } + if (!state.started) return; + + // Prevent scrolling while dragging + e.preventDefault(); + + // Move ghost + if (state.ghost) { + state.ghost.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`; + } + + // Find current drop target (hide ghost briefly so it doesn't block elementFromPoint) + const ghost = state.ghost; + if (ghost) ghost.style.display = "none"; + const under = document.elementFromPoint(e.clientX, e.clientY); + if (ghost) ghost.style.display = ""; + + if (under !== state.lastTarget) { + // dragleave on previous + if (state.lastTarget && state.dataTransfer) { + dispatchDragEvent(state.lastTarget, "dragleave", state.dataTransfer, e.clientX, e.clientY); + } + state.lastTarget = under; + if (under && state.dataTransfer) { + dispatchDragEvent(under, "dragenter", state.dataTransfer, e.clientX, e.clientY); + } + } + if (state.lastTarget && state.dataTransfer) { + dispatchDragEvent(state.lastTarget, "dragover", state.dataTransfer, e.clientX, e.clientY); + } + }, { passive: false }); + + const finish = (e: PointerEvent) => { + if (!state || e.pointerId !== state.pointerId) return; + if (state.longPressTimer) clearTimeout(state.longPressTimer); + + if (state.started) { + // Dispatch drop on current target + if (state.lastTarget && state.dataTransfer) { + dispatchDragEvent(state.lastTarget, "drop", state.dataTransfer, e.clientX, e.clientY); + } + // Dispatch dragend on source + if (state.dataTransfer) { + dispatchDragEvent(state.el, "dragend", state.dataTransfer, e.clientX, e.clientY); + } + state.el.style.opacity = ""; + if (state.ghost) state.ghost.remove(); + } + state = null; + }; + + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); +} + +function beginPointerDrag(state: TouchDragState, x: number, y: number) { + state.started = true; + state.el.style.opacity = "0.4"; + + // Build a real DataTransfer so existing dragover/drop listeners that + // call `e.dataTransfer.getData('application/rspace-item')` work unchanged. + let dt: DataTransfer; + try { + dt = new DataTransfer(); + dt.setData("application/rspace-item", JSON.stringify(state.payload)); + dt.setData("text/plain", state.payload.title); + dt.effectAllowed = "copyMove"; + } catch { + // Older Safari fallback — we'll still dispatch events, but dataTransfer.getData + // may not work. Consumers can read from (event as any).rspaceItemPayload. + dt = new DataTransfer(); + } + state.dataTransfer = dt; + + // Let source's existing `dragstart` listeners set extra payloads + // (e.g. rdocs `application/x-rdocs-move`). + dispatchDragEvent(state.el, "dragstart", dt, x, y); + + // Build drag ghost (floating preview of the source element) + const ghost = document.createElement("div"); + ghost.textContent = state.payload.title; + ghost.style.cssText = ` + position: fixed; + top: 0; + left: 0; + transform: translate(${x}px, ${y}px); + z-index: 100000; + pointer-events: none; + padding: 8px 12px; + background: ${state.payload.color || "#3b82f6"}; + color: #fff; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + box-shadow: 0 4px 16px rgba(0,0,0,0.25); + opacity: 0.92; + max-width: 260px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + `; + document.body.appendChild(ghost); + state.ghost = ghost; +} + +function dispatchDragEvent( + target: Element | EventTarget, + type: string, + dataTransfer: DataTransfer, + clientX: number, + clientY: number, +): void { + let ev: DragEvent; + try { + ev = new DragEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + dataTransfer, + }); + } catch { + // Older browsers: fall back to CustomEvent with dataTransfer attached + const fallback = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(fallback, "dataTransfer", { value: dataTransfer, enumerable: true }); + Object.defineProperty(fallback, "clientX", { value: clientX, enumerable: true }); + Object.defineProperty(fallback, "clientY", { value: clientY, enumerable: true }); + target.dispatchEvent(fallback); + return; + } + target.dispatchEvent(ev); +}