From 61b25e299f64a67586c256e62c22d9b179cca285 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 09:50:39 +0000 Subject: [PATCH 1/3] feat(rinbox): reply/forward, SMTP send, personal/agent inboxes, canvas shape Add reply, reply-all, and forward endpoints with proper RFC 5322 threading headers (In-Reply-To, References). SMTP send executes automatically when approval threshold is met via nodemailer. Personal inbox CRUD lets users connect their own IMAP accounts. Agent inbox system with regex-based rules for auto-classify/auto-reply (drafts go through approval workflow). Multi-sig email canvas shape (folk-multisig-email) with draft/pending/sent states and 5s polling. Per-space auto-provisioning via onSpaceCreate. Co-Authored-By: Claude Opus 4.6 --- lib/folk-multisig-email.ts | 514 +++++++++++++ .../rinbox/components/folk-inbox-client.ts | 547 +++++++++++++- modules/rinbox/mod.ts | 710 +++++++++++++++++- modules/rinbox/schemas.ts | 51 +- website/canvas.html | 42 +- 5 files changed, 1800 insertions(+), 64 deletions(-) create mode 100644 lib/folk-multisig-email.ts diff --git a/lib/folk-multisig-email.ts b/lib/folk-multisig-email.ts new file mode 100644 index 0000000..aface28 --- /dev/null +++ b/lib/folk-multisig-email.ts @@ -0,0 +1,514 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: #0f172a; + color: #e2e8f0; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + min-width: 360px; + min-height: 320px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #6366f1; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 8px 8px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .status-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + } + .status-draft { background: #334155; color: #94a3b8; } + .status-pending { background: #fbbf24; color: #1e293b; } + .status-approved { background: #22c55e; color: white; } + .status-sent { background: #22c55e; color: white; } + .status-rejected { background: #ef4444; color: white; } + + .body { + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; + height: calc(100% - 36px); + overflow-y: auto; + } + + .field { + display: flex; + gap: 6px; + font-size: 12px; + align-items: baseline; + } + + .field-label { + color: #64748b; + font-weight: 600; + min-width: 50px; + flex-shrink: 0; + } + + .field-value { + color: #cbd5e1; + word-break: break-all; + } + + .field-input { + flex: 1; + background: rgba(0,0,0,0.3); + border: 1px solid #334155; + border-radius: 4px; + padding: 3px 6px; + color: #e2e8f0; + font-size: 12px; + font-family: inherit; + outline: none; + } + .field-input:focus { border-color: #6366f1; } + + .field-textarea { + flex: 1; + background: rgba(0,0,0,0.3); + border: 1px solid #334155; + border-radius: 4px; + padding: 4px 6px; + color: #e2e8f0; + font-size: 12px; + font-family: inherit; + outline: none; + resize: vertical; + min-height: 48px; + max-height: 120px; + } + .field-textarea:focus { border-color: #6366f1; } + + .divider { + border: none; + border-top: 1px solid #1e293b; + margin: 4px 0; + } + + .body-preview { + font-size: 11px; + color: #94a3b8; + line-height: 1.4; + max-height: 60px; + overflow: hidden; + padding: 6px; + background: rgba(0,0,0,0.2); + border-radius: 4px; + border-left: 3px solid #334155; + } + + /* Progress */ + .progress-section { + margin-top: auto; + } + + .progress-bar-wrap { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + + .progress-bar { + flex: 1; + height: 6px; + background: #1e293b; + border-radius: 3px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s; + } + .progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); } + .progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); } + + .progress-text { + font-size: 10px; + color: #64748b; + flex-shrink: 0; + } + + .signer-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 2px 0; + } + + .signer-dot { + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 8px; + color: white; + flex-shrink: 0; + } + .signer-dot.signed { background: #22c55e; } + .signer-dot.waiting { background: #334155; } + + .signer-name { flex: 1; } + .signer-name.signed { color: #e2e8f0; } + .signer-name.waiting { color: #64748b; } + + .signer-status { font-size: 10px; font-weight: 600; } + .signer-status.signed { color: #4ade80; } + .signer-status.waiting { color: #64748b; } + + /* Actions */ + .actions { + display: flex; + gap: 6px; + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid #1e293b; + } + + .btn { + padding: 4px 12px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 11px; + font-weight: 600; + transition: opacity 0.15s; + } + .btn:hover { opacity: 0.85; } + .btn-primary { background: #6366f1; color: white; } + .btn-success { background: #22c55e; color: white; } + .btn-danger { background: #ef4444; color: white; } + .btn-secondary { background: #334155; color: #94a3b8; } +`; + +export class FolkMultisigEmail extends FolkShape { + static tagName = "folk-multisig-email"; + + // Shape data + mailboxSlug = ""; + toAddresses: string[] = []; + ccAddresses: string[] = []; + subject = ""; + bodyText = ""; + bodyHtml = ""; + replyToThreadId: string | null = null; + replyType: 'new' | 'reply' | 'forward' = 'new'; + approvalId: string | null = null; + status: 'draft' | 'pending' | 'approved' | 'sent' | 'rejected' = 'draft'; + requiredSignatures = 2; + signatures: Array<{ id: string; signerId: string; vote: string; signedAt: string }> = []; + + private _pollInterval: ReturnType | null = null; + + static get observedAttributes() { + return [...super.observedAttributes, "mailbox-slug", "subject", "status", "approval-id"]; + } + + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + super.attributeChangedCallback(name, oldValue, newValue); + switch (name) { + case "mailbox-slug": this.mailboxSlug = newValue || ""; break; + case "subject": this.subject = newValue || ""; break; + case "status": this.status = (newValue || "draft") as any; break; + case "approval-id": this.approvalId = newValue || null; break; + } + } + + connectedCallback() { + super.connectedCallback(); + this.shadowRoot!.adoptedStyleSheets = [...this.shadowRoot!.adoptedStyleSheets, styles]; + this.renderContent(); + if (this.status === 'pending' && this.approvalId) { + this.startPolling(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.stopPolling(); + } + + private getApiBase(): string { + const space = (this as any).spaceSlug || (window as any).__communitySync?.communitySlug || "demo"; + return `/${space}/rinbox`; + } + + private getAuthHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + const token = localStorage.getItem("encryptid-token"); + if (token) headers["Authorization"] = `Bearer ${token}`; + return headers; + } + + private startPolling() { + this.stopPolling(); + this._pollInterval = setInterval(() => this.pollApproval(), 5000); + } + + private stopPolling() { + if (this._pollInterval) { + clearInterval(this._pollInterval); + this._pollInterval = null; + } + } + + private async pollApproval() { + if (!this.approvalId) return; + try { + const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}`, { + headers: this.getAuthHeaders(), + }); + if (!resp.ok) return; + const data = await resp.json(); + const newStatus = data.status?.toLowerCase(); + if (newStatus && newStatus !== this.status) { + this.status = newStatus; + this.signatures = data.signatures || []; + this.renderContent(); + this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + if (newStatus !== 'pending') this.stopPolling(); + } else { + // Update signature count even if status didn't change + if (data.signatures) { + this.signatures = data.signatures; + this.renderContent(); + } + } + } catch { /* ignore polling errors */ } + } + + private renderContent() { + const sr = this.shadowRoot; + if (!sr) return; + + const isDraft = this.status === 'draft'; + const sigCount = this.signatures.filter(s => s.vote === 'APPROVE').length; + const pct = this.requiredSignatures > 0 ? Math.min(100, Math.round((sigCount / this.requiredSignatures) * 100)) : 0; + + let contentEl = sr.querySelector(".msig-content") as HTMLDivElement; + if (!contentEl) { + contentEl = document.createElement("div"); + contentEl.className = "msig-content"; + contentEl.style.cssText = "display:flex;flex-direction:column;height:100%;"; + sr.appendChild(contentEl); + } + + if (isDraft) { + contentEl.innerHTML = html` +
+
✉ Multi-Sig Email
+ Draft +
+
+
+ From: + ${this.mailboxSlug || "team"}@rspace.online +
+
+ To: + +
+
+ Subject: + +
+
+ +
+ +
+
+ `; + } else { + const snippet = this.bodyText.length > 120 ? this.bodyText.slice(0, 120) + '...' : this.bodyText; + contentEl.innerHTML = html` +
+
✉ Multi-Sig Email
+ ${this.status} +
+
+
+ From: + ${this.mailboxSlug || "team"}@rspace.online +
+
+ To: + ${this.toAddresses.join(', ') || '(none)'} +
+
+ Subject: + ${this.subject || '(no subject)'} +
+
+
${this.escapeHtml(snippet)}
+
+
+
+
+
+ ${sigCount}/${this.requiredSignatures} signatures +
+ ${this.signatures.map(s => html` +
+
${s.vote === 'APPROVE' ? '✓' : ''}
+ ${s.signerId?.slice(0, 12) || 'Unknown'}... + ${s.vote === 'APPROVE' ? 'Signed' : 'Awaiting'} +
+ `).join('')} +
+ ${this.status === 'pending' ? html` +
+ + +
+ ` : ''} + ${this.status === 'draft' ? html` +
+ + +
+ ` : ''} +
+ `; + } + + this.bindShapeEvents(contentEl); + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); + } + + private escapeAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + } + + private bindShapeEvents(root: HTMLElement) { + // Draft field changes + root.querySelectorAll("[data-field]").forEach(el => { + el.addEventListener("input", () => { + const field = (el as HTMLElement).dataset.field; + const value = (el as HTMLInputElement | HTMLTextAreaElement).value; + switch (field) { + case 'to': + this.toAddresses = value.split(',').map(s => s.trim()).filter(Boolean); + break; + case 'subject': + this.subject = value; + break; + case 'body': + this.bodyText = value; + break; + } + this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + }); + }); + + // Submit + root.querySelector("[data-action='submit']")?.addEventListener("click", async () => { + if (!this.subject || this.toAddresses.length === 0) { + alert("Please fill To and Subject fields."); + return; + } + try { + const resp = await fetch(`${this.getApiBase()}/api/approvals`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ + mailbox_slug: this.mailboxSlug || undefined, + subject: this.subject, + body_text: this.bodyText, + to_addresses: this.toAddresses, + cc_addresses: this.ccAddresses, + reply_type: this.replyType, + thread_id: this.replyToThreadId, + }), + }); + if (resp.ok) { + const data = await resp.json(); + this.approvalId = data.id; + this.status = 'pending'; + this.requiredSignatures = data.required_signatures || 2; + this.renderContent(); + this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + this.startPolling(); + } + } catch (e) { + console.error("[MultiSigEmail] Submit error:", e); + } + }); + + // Approve + root.querySelector("[data-action='approve']")?.addEventListener("click", async () => { + if (!this.approvalId) return; + try { + const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ vote: "APPROVE" }), + }); + if (resp.ok) { + const data = await resp.json(); + if (data.status) { + this.status = data.status.toLowerCase(); + if (this.status !== 'pending') this.stopPolling(); + } + this.renderContent(); + this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + } + } catch (e) { + console.error("[MultiSigEmail] Approve error:", e); + } + }); + + // Reject + root.querySelector("[data-action='reject']")?.addEventListener("click", async () => { + if (!this.approvalId) return; + try { + const resp = await fetch(`${this.getApiBase()}/api/approvals/${this.approvalId}/sign`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ vote: "REJECT" }), + }); + if (resp.ok) { + const data = await resp.json(); + if (data.status) this.status = data.status.toLowerCase(); + this.stopPolling(); + this.renderContent(); + this.dispatchEvent(new CustomEvent("content-change", { bubbles: true })); + } + } catch (e) { + console.error("[MultiSigEmail] Reject error:", e); + } + }); + } +} + +customElements.define(FolkMultisigEmail.tagName, FolkMultisigEmail); diff --git a/modules/rinbox/components/folk-inbox-client.ts b/modules/rinbox/components/folk-inbox-client.ts index bcf9acb..1995e7e 100644 --- a/modules/rinbox/components/folk-inbox-client.ts +++ b/modules/rinbox/components/folk-inbox-client.ts @@ -2,32 +2,42 @@ * folk-inbox-client — Collaborative email client. * * Shows mailbox list, thread inbox, thread detail with comments, - * and approval workflow interface. Includes a help/guide popout. + * reply/forward compose, approval workflow, personal inboxes, + * and agent inbox management. Includes a help/guide popout. */ import { mailboxSchema, type MailboxDoc } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; +type ComposeMode = 'new' | 'reply' | 'reply-all' | 'forward'; + class FolkInboxClient extends HTMLElement { private shadow: ShadowRoot; private space = "demo"; private _offlineUnsubs: (() => void)[] = []; - private view: "mailboxes" | "threads" | "thread" | "approvals" = "mailboxes"; + private view: "mailboxes" | "threads" | "thread" | "approvals" | "personal" | "agents" = "mailboxes"; private mailboxes: any[] = []; private threads: any[] = []; private currentMailbox: any = null; private currentThread: any = null; private approvals: any[] = []; + private personalInboxes: any[] = []; + private agentInboxes: any[] = []; private filter: "all" | "open" | "snoozed" | "closed" = "all"; private helpOpen = false; private composeOpen = false; + private composeMode: ComposeMode = 'new'; private showingSampleData = false; + private connectFormOpen = false; + private agentFormOpen = false; + private _usernameCache = new Map(); + private _currentUsername: string | null = null; private demoApprovals: any[] = [ { id: "a1", subject: "Re: Q1 Budget approval for rSpace infrastructure", status: "PENDING", to_addresses: ["finance@example.org"], required_signatures: 3, signature_count: 1, body_text: "Approved. Please proceed with the cloud infrastructure upgrade as outlined. Budget: $4,200/quarter.", - created_at: new Date(Date.now() - 4 * 3600000).toISOString(), + created_at: new Date(Date.now() - 4 * 3600000).toISOString(), reply_type: 'reply', signers: [ { name: "Alice Chen", vote: "APPROVE", signed_at: new Date(Date.now() - 3 * 3600000).toISOString() }, { name: "Bob Martinez", vote: null, signed_at: null }, @@ -38,7 +48,7 @@ class FolkInboxClient extends HTMLElement { id: "a2", subject: "Partnership agreement with Acme Corp", status: "PENDING", to_addresses: ["legal@acmecorp.com", "partnerships@example.org"], required_signatures: 2, signature_count: 1, body_text: "We've reviewed the terms and are ready to proceed. Please find the signed MOU attached.", - created_at: new Date(Date.now() - 12 * 3600000).toISOString(), + created_at: new Date(Date.now() - 12 * 3600000).toISOString(), reply_type: 'new', signers: [ { name: "Dave Park", vote: "APPROVE", signed_at: new Date(Date.now() - 10 * 3600000).toISOString() }, { name: "Alice Chen", vote: null, signed_at: null }, @@ -48,7 +58,7 @@ class FolkInboxClient extends HTMLElement { id: "a3", subject: "Re: Security incident disclosure", status: "APPROVED", to_addresses: ["security@example.com"], required_signatures: 2, signature_count: 2, body_text: "Disclosure notice prepared and co-signed. Sending to affected parties.", - created_at: new Date(Date.now() - 48 * 3600000).toISOString(), + created_at: new Date(Date.now() - 48 * 3600000).toISOString(), reply_type: 'reply', signers: [ { name: "Bob Martinez", vote: "APPROVE", signed_at: new Date(Date.now() - 46 * 3600000).toISOString() }, { name: "Carol Wu", vote: "APPROVE", signed_at: new Date(Date.now() - 44 * 3600000).toISOString() }, @@ -57,15 +67,15 @@ class FolkInboxClient extends HTMLElement { ]; private demoThreads: Record = { team: [ - { id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] }, - { id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] }, - { id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", comments: [] }, - { id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] }, + { id: "t1", from_name: "Alice Chen", from_address: "alice@example.com", subject: "Sprint planning notes", status: "open", is_read: true, is_starred: true, comment_count: 3, received_at: new Date(Date.now() - 2 * 3600000).toISOString(), body_text: "Here are the sprint planning notes from today's session. We agreed on the following priorities for the next two weeks:\n\n1. Ship local-first sync for notes module\n2. Polish the calendar demo mode\n3. Review provider registry API\n\nLet me know if I missed anything.", direction: 'inbound', comments: [{ username: "Bob Martinez", body: "Looks good! I'd add the inbox overhaul too.", created_at: new Date(Date.now() - 1.5 * 3600000).toISOString() }, { username: "Carol Wu", body: "Agreed, calendar polish is top priority.", created_at: new Date(Date.now() - 1 * 3600000).toISOString() }, { username: "Alice Chen", body: "Updated the list. Thanks!", created_at: new Date(Date.now() - 0.5 * 3600000).toISOString() }] }, + { id: "t2", from_name: "Bob Martinez", from_address: "bob@example.com", subject: "Deploy checklist for v2.1", status: "open", is_read: false, is_starred: false, comment_count: 1, received_at: new Date(Date.now() - 5 * 3600000).toISOString(), body_text: "Here is the deploy checklist for v2.1. Please review before we cut the release.\n\n- [ ] Run full test suite\n- [ ] Update changelog\n- [ ] Tag release in Gitea\n- [ ] Deploy to staging\n- [ ] Smoke test all modules", direction: 'inbound', comments: [{ username: "Alice Chen", body: "I can handle the changelog update.", created_at: new Date(Date.now() - 4 * 3600000).toISOString() }] }, + { id: "t3", from_name: "Carol Wu", from_address: "carol@example.com", subject: "Design system color tokens", status: "snoozed", is_read: true, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 24 * 3600000).toISOString(), body_text: "I've been working on standardizing our color tokens across all modules. The current approach of inline hex values is getting unwieldy. Proposal attached.", direction: 'inbound', comments: [] }, + { id: "t4", from_name: "Dave Park", from_address: "dave@example.com", subject: "Q1 Retrospective summary", status: "closed", is_read: true, is_starred: false, comment_count: 5, received_at: new Date(Date.now() - 72 * 3600000).toISOString(), body_text: "Summary of our Q1 retrospective:\n\nWhat went well: Local-first architecture, community engagement, rapid prototyping.\nWhat to improve: Documentation, test coverage, onboarding flow.", direction: 'inbound', comments: [{ username: "Alice Chen", body: "Great summary, Dave.", created_at: new Date(Date.now() - 70 * 3600000).toISOString() }, { username: "Bob Martinez", body: "+1 on improving docs.", created_at: new Date(Date.now() - 69 * 3600000).toISOString() }, { username: "Carol Wu", body: "I can lead the onboarding redesign.", created_at: new Date(Date.now() - 68 * 3600000).toISOString() }, { username: "Dave Park", body: "Sounds good, let's schedule a kickoff.", created_at: new Date(Date.now() - 67 * 3600000).toISOString() }, { username: "Alice Chen", body: "Added to next sprint.", created_at: new Date(Date.now() - 66 * 3600000).toISOString() }] }, ], support: [ - { id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", comments: [] }, - { id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] }, - { id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] }, + { id: "t5", from_name: "New User", from_address: "newuser@example.com", subject: "How do I create a space?", status: "open", is_read: false, is_starred: false, comment_count: 0, received_at: new Date(Date.now() - 1 * 3600000).toISOString(), body_text: "Hi, I just signed up and I'm not sure how to create my own space. The docs mention a space switcher but I can't find it. Could you point me in the right direction?", direction: 'inbound', comments: [] }, + { id: "t6", from_name: "Partner Org", from_address: "partner@example.org", subject: "Integration API access request", status: "open", is_read: true, is_starred: true, comment_count: 2, received_at: new Date(Date.now() - 8 * 3600000).toISOString(), body_text: "We'd like to integrate our platform with rSpace modules via the API. Could you provide API documentation and access credentials for our staging environment?", direction: 'inbound', comments: [{ username: "Team Bot", body: "Request logged. Assigning to API team.", created_at: new Date(Date.now() - 7 * 3600000).toISOString() }, { username: "Bob Martinez", body: "I'll send over the API docs today.", created_at: new Date(Date.now() - 6 * 3600000).toISOString() }] }, + { id: "t7", from_name: "Community Member", from_address: "member@example.com", subject: "Feature request: dark mode", status: "closed", is_read: true, is_starred: false, comment_count: 4, received_at: new Date(Date.now() - 96 * 3600000).toISOString(), body_text: "Would love to see a proper dark mode toggle. The current theme is close but some panels still have bright backgrounds.", direction: 'inbound', comments: [{ username: "Carol Wu", body: "This is on our roadmap! Targeting next release.", created_at: new Date(Date.now() - 90 * 3600000).toISOString() }, { username: "Community Member", body: "Awesome, looking forward to it.", created_at: new Date(Date.now() - 88 * 3600000).toISOString() }, { username: "Carol Wu", body: "Dark mode shipped in v2.0!", created_at: new Date(Date.now() - 48 * 3600000).toISOString() }, { username: "Community Member", body: "Looks great, thanks!", created_at: new Date(Date.now() - 46 * 3600000).toISOString() }] }, ], }; @@ -76,6 +86,7 @@ class FolkInboxClient extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; + this._loadUsername(); if (this.space === "demo") { this.loadDemoData(); return; } this.subscribeOffline(); this.loadMailboxes(); @@ -86,6 +97,25 @@ class FolkInboxClient extends HTMLElement { this._offlineUnsubs = []; } + /** Extract username from EncryptID JWT in localStorage */ + private _loadUsername() { + try { + const token = localStorage.getItem('encryptid-token'); + if (!token) return; + const payload = JSON.parse(atob(token.split('.')[1])); + if (payload.username) this._currentUsername = payload.username; + } catch { /* no token or invalid */ } + } + + /** Resolve a DID to a display name */ + private displayName(did: string | null | undefined): string { + if (!did) return 'Anonymous'; + if (did.startsWith('agent:')) return `Bot (${did.slice(6, 14)}...)`; + if (this._usernameCache.has(did)) return this._usernameCache.get(did)!; + // Truncate DID for display + return did.length > 20 ? did.slice(0, 8) + '...' + did.slice(-6) : did; + } + private async subscribeOffline() { const runtime = (window as any).__rspaceOfflineRuntime; if (!runtime?.isInitialized) return; @@ -191,6 +221,54 @@ class FolkInboxClient extends HTMLElement { this.render(); } + private async loadPersonalInboxes() { + if (this.space === "demo" || this.showingSampleData) { + this.personalInboxes = [ + { id: "pi1", label: "Gmail", email: "user@gmail.com", status: "active", last_sync_at: new Date(Date.now() - 60000).toISOString() }, + ]; + this.render(); + return; + } + try { + const base = window.location.pathname.replace(/\/$/, ""); + const token = localStorage.getItem('encryptid-token'); + const resp = await fetch(`${base}/api/personal-inboxes`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (resp.ok) { + const data = await resp.json(); + this.personalInboxes = data.personal_inboxes || []; + } + } catch { /* ignore */ } + this.render(); + } + + private async loadAgentInboxes() { + if (this.space === "demo" || this.showingSampleData) { + this.agentInboxes = [ + { id: "ai1", name: "Support Bot", email: "support-bot@rspace.online", auto_reply: true, auto_classify: true, rules: [{ match: { field: 'subject', pattern: 'urgent' }, action: 'tag', value: 'urgent' }] }, + ]; + this.render(); + return; + } + try { + const base = window.location.pathname.replace(/\/$/, ""); + const resp = await fetch(`${base}/api/agent-inboxes?space=${this.space}`); + if (resp.ok) { + const data = await resp.json(); + this.agentInboxes = data.agent_inboxes || []; + } + } catch { /* ignore */ } + this.render(); + } + + private getAuthHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + const token = localStorage.getItem('encryptid-token'); + if (token) headers["Authorization"] = `Bearer ${token}`; + return headers; + } + private timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); @@ -214,7 +292,7 @@ class FolkInboxClient extends HTMLElement { .container { max-width: 1000px; margin: 0 auto; } /* Nav */ - .rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; } + .rapp-nav { display: flex; gap: 0.5rem; margin-bottom: 1rem; align-items: center; min-height: 36px; flex-wrap: wrap; } .rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; } .rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); } .nav-btn { padding: 0.4rem 1rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; } @@ -263,7 +341,10 @@ class FolkInboxClient extends HTMLElement { .badge-closed { background: rgba(100,116,139,0.15); color: #94a3b8; } .badge-pending { background: rgba(251,191,36,0.15); color: #fbbf24; } .badge-approved { background: rgba(34,197,94,0.15); color: #4ade80; } + .badge-sent { background: rgba(34,197,94,0.15); color: #4ade80; } .badge-comments { background: rgba(99,102,241,0.15); color: #818cf8; } + .badge-outbound { background: rgba(96,165,250,0.15); color: #60a5fa; } + .badge-bot { background: rgba(168,85,247,0.15); color: #a855f7; } .star { color: #fbbf24; font-size: 0.7rem; } /* Thread detail */ @@ -272,6 +353,13 @@ class FolkInboxClient extends HTMLElement { .detail-subject { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; } .detail-meta { font-size: 0.8rem; color: #64748b; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .detail-body { font-size: 0.9rem; line-height: 1.6; color: #cbd5e1; margin-bottom: 1.5rem; white-space: pre-wrap; } + + /* Thread actions */ + .thread-actions { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; padding: 0.75rem 0; border-top: 1px solid #1e293b; border-bottom: 1px solid #1e293b; } + .thread-actions button { padding: 0.4rem 1rem; border-radius: 6px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; } + .thread-actions button:hover { border-color: #6366f1; color: #818cf8; background: rgba(99,102,241,0.1); } + + /* Comments */ .comments-section { border-top: 1px solid #1e293b; padding-top: 1rem; } .comments-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.75rem; color: #94a3b8; } .comment { padding: 0.75rem; background: rgba(0,0,0,0.2); border-radius: 8px; margin-bottom: 0.5rem; border-left: 3px solid #334155; } @@ -284,7 +372,7 @@ class FolkInboxClient extends HTMLElement { /* Approval cards */ .approval-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; border-left: 4px solid #334155; transition: border-color 0.2s; } .approval-card.status-pending { border-left-color: #fbbf24; } - .approval-card.status-approved { border-left-color: #22c55e; } + .approval-card.status-approved, .approval-card.status-sent { border-left-color: #22c55e; } .approval-card.status-rejected { border-left-color: #ef4444; } .approval-to { font-size: 0.8rem; color: #64748b; margin-bottom: 0.5rem; } .approval-to code { font-size: 0.75rem; color: #818cf8; background: rgba(99,102,241,0.1); padding: 0.1rem 0.4rem; border-radius: 4px; } @@ -293,7 +381,7 @@ class FolkInboxClient extends HTMLElement { .progress-bar { flex: 1; height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; } .progress-fill.pending { background: linear-gradient(90deg, #fbbf24, #f59e0b); } - .progress-fill.approved { background: linear-gradient(90deg, #22c55e, #16a34a); } + .progress-fill.approved, .progress-fill.sent { background: linear-gradient(90deg, #22c55e, #16a34a); } .progress-text { font-size: 0.75rem; color: #94a3b8; flex-shrink: 0; } .signer-list { margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.35rem; } .signer-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; } @@ -329,6 +417,21 @@ class FolkInboxClient extends HTMLElement { .btn-cancel { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.85rem; transition: all 0.15s; } .btn-cancel:hover { border-color: #475569; color: #e2e8f0; } + /* Personal / Agent cards */ + .inbox-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1rem; margin-bottom: 0.75rem; display: flex; align-items: center; gap: 1rem; } + .inbox-card-info { flex: 1; } + .inbox-card-label { font-weight: 600; font-size: 0.9rem; } + .inbox-card-email { font-size: 0.8rem; color: #6366f1; font-family: monospace; } + .inbox-card-status { font-size: 0.7rem; } + .inbox-card-status.active { color: #4ade80; } + .inbox-card-status.error { color: #ef4444; } + .inbox-card-status.paused { color: #fbbf24; } + .inbox-card-actions button { padding: 0.3rem 0.75rem; border-radius: 6px; border: 1px solid #334155; background: transparent; color: #94a3b8; cursor: pointer; font-size: 0.75rem; } + .inbox-card-actions button:hover { border-color: #ef4444; color: #ef4444; } + .agent-toggle { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; } + .agent-toggle .on { color: #4ade80; } + .agent-toggle .off { color: #64748b; } + /* Help panel */ .help-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 1rem; } .help-panel { background: #0f172a; border: 1px solid #334155; border-radius: 16px; max-width: 640px; width: 100%; max-height: 80vh; overflow-y: auto; padding: 2rem; position: relative; } @@ -369,9 +472,11 @@ class FolkInboxClient extends HTMLElement { } private renderNav(): string { - const items = [ + const items: { id: string; label: string }[] = [ { id: "mailboxes", label: "Mailboxes" }, { id: "approvals", label: "Approvals" }, + { id: "personal", label: "Personal" }, + { id: "agents", label: "Agents" }, ]; if (this.currentMailbox) { items.unshift({ id: "threads", label: this.currentMailbox.name }); @@ -381,6 +486,7 @@ class FolkInboxClient extends HTMLElement { ${this.view !== "mailboxes" ? `` : ""} ${items.map((i) => ``).join("")} + ${this._currentUsername ? `${this._currentUsername}` : ''} `; @@ -392,6 +498,8 @@ class FolkInboxClient extends HTMLElement { case "threads": return this.renderThreads(); case "thread": return this.renderThreadDetail(); case "approvals": return this.renderApprovals(); + case "personal": return this.renderPersonal(); + case "agents": return this.renderAgents(); default: return ""; } } @@ -448,6 +556,7 @@ class FolkInboxClient extends HTMLElement {
${t.is_starred ? `` : ""} ${t.status} + ${t.direction === 'outbound' ? `sent` : ''} ${t.comment_count > 0 ? `💬 ${t.comment_count}` : ""}
@@ -461,6 +570,7 @@ class FolkInboxClient extends HTMLElement { if (!this.currentThread) return `
Loading...
`; const t = this.currentThread; const comments = t.comments || []; + const composeHtml = this.composeOpen ? this.renderInlineCompose() : ''; return `
@@ -471,16 +581,23 @@ class FolkInboxClient extends HTMLElement { ${this.timeAgo(t.received_at)} · ${t.status} + ${t.direction === 'outbound' ? `sent` : ''} ${t.is_starred ? `` : ""}
${t.body_text || t.body_html || "(no content)"}
+
+ + + +
+ ${composeHtml}
💬 Internal Comments (${comments.length})
${comments.map((cm: any) => `
- ${cm.username || "Anonymous"} + ${cm.username || this.displayName(cm.author_id) || "Anonymous"} ${this.timeAgo(cm.created_at)}
${cm.body}
@@ -492,8 +609,75 @@ class FolkInboxClient extends HTMLElement { `; } + private renderInlineCompose(): string { + const t = this.currentThread; + let defaultTo = ''; + let defaultSubject = ''; + let defaultBody = ''; + const modeLabel = this.composeMode === 'reply' ? 'Reply' : + this.composeMode === 'reply-all' ? 'Reply All' : + this.composeMode === 'forward' ? 'Forward' : 'New Email'; + + if (t) { + if (this.composeMode === 'reply') { + defaultTo = t.from_address || ''; + defaultSubject = t.subject?.startsWith('Re:') ? t.subject : `Re: ${t.subject || ''}`; + const date = new Date(t.received_at).toLocaleString(); + const from = t.from_name ? `${t.from_name} <${t.from_address}>` : (t.from_address || ''); + defaultBody = `\n\nOn ${date}, ${from} wrote:\n` + + (t.body_text || '').split('\n').map((l: string) => `> ${l}`).join('\n'); + } else if (this.composeMode === 'reply-all') { + const allAddrs = [t.from_address, ...(t.to_addresses || [])].filter(Boolean); + defaultTo = allAddrs.join(', '); + defaultSubject = t.subject?.startsWith('Re:') ? t.subject : `Re: ${t.subject || ''}`; + const date = new Date(t.received_at).toLocaleString(); + const from = t.from_name ? `${t.from_name} <${t.from_address}>` : (t.from_address || ''); + defaultBody = `\n\nOn ${date}, ${from} wrote:\n` + + (t.body_text || '').split('\n').map((l: string) => `> ${l}`).join('\n'); + } else if (this.composeMode === 'forward') { + defaultSubject = t.subject?.startsWith('Fwd:') ? t.subject : `Fwd: ${t.subject || ''}`; + const date = new Date(t.received_at).toLocaleString(); + defaultBody = '\n\n---------- Forwarded message ----------\n' + + `From: ${t.from_name || ''} <${t.from_address || ''}>\n` + + `Date: ${date}\n` + + `Subject: ${t.subject || ''}\n` + + `To: ${(t.to_addresses || []).join(', ')}\n\n` + + (t.body_text || ''); + } + } + + return ` +
+

✎ ${modeLabel}

+
+ + +
+
+ + +
+
+ + +
+
+ 🔒 This email will be submitted for multi-sig approval before sending. +
+
+ + +
+
+ `; + } + + private escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + private renderApprovals(): string { - const composeForm = this.composeOpen ? ` + const composeForm = this.composeOpen && this.view === 'approvals' ? `

✎ Draft for Multi-Sig Approval

@@ -509,7 +693,7 @@ class FolkInboxClient extends HTMLElement {
- 🔒 This email requires 2 of 3 team member signatures before it will be sent. + 🔒 This email requires multi-sig team member signatures before it will be sent.
@@ -534,6 +718,7 @@ class FolkInboxClient extends HTMLElement { const cards = this.approvals.map((a) => { const pct = a.required_signatures > 0 ? Math.min(100, Math.round((a.signature_count / a.required_signatures) * 100)) : 0; const statusClass = a.status.toLowerCase(); + const isAgentDraft = a.author_id?.startsWith('agent:'); const signers = (a.signers || []).map((s: any) => { const initials = (s.name || "?").split(" ").map((w: string) => w[0]).join("").slice(0, 2).toUpperCase(); const isSigned = s.vote === "APPROVE"; @@ -547,10 +732,18 @@ class FolkInboxClient extends HTMLElement {
`; }).join(""); + const replyBadge = a.reply_type && a.reply_type !== 'new' + ? `${a.reply_type}` + : ''; + return `
-
${a.subject}
+
+ ${a.subject} + ${replyBadge} + ${isAgentDraft ? 'bot draft' : ''} +
${a.status}
To: ${(a.to_addresses || []).map((e: string) => `${e}`).join(", ")}
@@ -577,6 +770,145 @@ class FolkInboxClient extends HTMLElement { `; } + private renderPersonal(): string { + const connectForm = this.connectFormOpen ? ` +
+

📩 Connect Email Account

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ` : ''; + + const cards = this.personalInboxes.map((pi: any) => ` +
+
📩
+
+
${pi.label}
+
${pi.email}
+
${pi.status}${pi.last_sync_at ? ` — synced ${this.timeAgo(pi.last_sync_at)}` : ''}
+
+
+ +
+
+ `).join(''); + + return ` + + ${connectForm} + ${cards} + ${!this.connectFormOpen && this.personalInboxes.length === 0 ? ` +
+

📩

+

No personal inboxes

+

+ Connect your personal email accounts (Gmail, Outlook, etc.) to read and reply from rSpace. +

+
+ ` : ''} + `; + } + + private renderAgents(): string { + const agentForm = this.agentFormOpen ? ` +
+

🤖 Create Agent Inbox

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ` : ''; + + const cards = this.agentInboxes.map((ai: any) => ` +
+
🤖
+
+
${ai.name}
+
${ai.email}
+
+ Auto-reply: ${ai.auto_reply ? 'ON' : 'OFF'} + · Auto-classify: ${ai.auto_classify ? 'ON' : 'OFF'} + · ${ai.rules?.length || 0} rule${(ai.rules?.length || 0) !== 1 ? 's' : ''} +
+
+
+ +
+
+ `).join(''); + + return ` + + ${agentForm} + ${cards} + ${!this.agentFormOpen && this.agentInboxes.length === 0 ? ` +
+

🤖

+

No agent inboxes

+

+ Create AI-managed email agents that auto-classify, tag, and draft replies based on rules. + Agent-drafted replies still go through the approval workflow. +

+
+ ` : ''} + `; + } + private renderHelp(): string { return `
@@ -624,7 +956,11 @@ class FolkInboxClient extends HTMLElement {
3 - Approve & send — draft a reply, then collect the required signatures. Once the threshold is met, the email sends. + Reply, forward, or compose — draft a reply inline, forward to someone, or compose a new email. All go through approval. +
+
+ 4 + Approve & send — collect the required signatures. Once the threshold is met, the email sends automatically via SMTP.
@@ -670,6 +1006,12 @@ class FolkInboxClient extends HTMLElement { } else if (nav === "threads") { this.view = "threads"; this.render(); + } else if (nav === "personal") { + this.view = "personal"; + this.loadPersonalInboxes(); + } else if (nav === "agents") { + this.view = "agents"; + this.loadAgentInboxes(); } }); }); @@ -680,8 +1022,9 @@ class FolkInboxClient extends HTMLElement { backBtn.addEventListener("click", () => { if (this.view === "thread") { this.view = "threads"; + this.composeOpen = false; this.render(); - } else if (this.view === "threads" || this.view === "approvals") { + } else if (this.view === "threads" || this.view === "approvals" || this.view === "personal" || this.view === "agents") { this.view = "mailboxes"; this.currentMailbox = null; this.render(); @@ -721,6 +1064,7 @@ class FolkInboxClient extends HTMLElement { row.addEventListener("click", () => { const id = (row as HTMLElement).dataset.thread!; this.view = "thread"; + this.composeOpen = false; this.loadThread(id); }); }); @@ -733,10 +1077,73 @@ class FolkInboxClient extends HTMLElement { }); }); - // Compose + // Thread actions: Reply / Reply All / Forward + this.shadow.querySelector("[data-action='reply']")?.addEventListener("click", () => { + this.composeMode = 'reply'; + this.composeOpen = true; + this.render(); + }); + this.shadow.querySelector("[data-action='reply-all']")?.addEventListener("click", () => { + this.composeMode = 'reply-all'; + this.composeOpen = true; + this.render(); + }); + this.shadow.querySelector("[data-action='forward']")?.addEventListener("click", () => { + this.composeMode = 'forward'; + this.composeOpen = true; + this.render(); + }); + + // Submit reply/forward from thread detail + this.shadow.querySelector("[data-action='submit-reply']")?.addEventListener("click", async () => { + if (this.space === "demo" || this.showingSampleData) { + alert("Reply is disabled in demo mode."); + return; + } + const t = this.currentThread; + if (!t) return; + + const to = (this.shadow.getElementById("compose-to") as HTMLInputElement)?.value.trim(); + const subject = (this.shadow.getElementById("compose-subject") as HTMLInputElement)?.value.trim(); + const body = (this.shadow.getElementById("compose-body") as HTMLTextAreaElement)?.value.trim(); + if (!subject || !body) { alert("Please fill subject and body."); return; } + + const base = window.location.pathname.replace(/\/$/, ""); + try { + if (this.composeMode === 'reply') { + await fetch(`${base}/api/threads/${t.id}/reply`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ body_text: body }), + }); + } else if (this.composeMode === 'reply-all') { + await fetch(`${base}/api/threads/${t.id}/reply-all`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ body_text: body }), + }); + } else if (this.composeMode === 'forward') { + if (!to) { alert("Forward requires a To address."); return; } + await fetch(`${base}/api/threads/${t.id}/forward`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ body_text: body, to_addresses: to.split(',').map(s => s.trim()).filter(Boolean) }), + }); + } + this.composeOpen = false; + this.view = "approvals"; + this.loadApprovals(); + } catch { alert("Failed to submit. Please try again."); } + }); + + // Compose (from approvals view) const composeBtn = this.shadow.querySelector("[data-action='compose']"); if (composeBtn) { - composeBtn.addEventListener("click", () => { this.composeOpen = !this.composeOpen; this.render(); }); + composeBtn.addEventListener("click", () => { + this.composeMode = 'new'; + this.composeOpen = !this.composeOpen; + this.render(); + }); } const cancelCompose = this.shadow.querySelector("[data-action='cancel-compose']"); if (cancelCompose) { @@ -758,8 +1165,8 @@ class FolkInboxClient extends HTMLElement { const mailbox = this.currentMailbox?.slug || ""; await fetch(`${base}/api/approvals`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox }), + headers: this.getAuthHeaders(), + body: JSON.stringify({ to_addresses: [to], subject, body_text: body, mailbox_slug: mailbox }), }); this.composeOpen = false; this.loadApprovals(); @@ -775,7 +1182,7 @@ class FolkInboxClient extends HTMLElement { const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: this.getAuthHeaders(), body: JSON.stringify({ vote: "APPROVE" }), }); this.loadApprovals(); @@ -788,12 +1195,96 @@ class FolkInboxClient extends HTMLElement { const base = window.location.pathname.replace(/\/$/, ""); await fetch(`${base}/api/approvals/${id}/sign`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: this.getAuthHeaders(), body: JSON.stringify({ vote: "REJECT" }), }); this.loadApprovals(); }); }); + + // Personal inbox actions + this.shadow.querySelector("[data-action='connect-account']")?.addEventListener("click", () => { + this.connectFormOpen = !this.connectFormOpen; + this.render(); + }); + this.shadow.querySelector("[data-action='cancel-connect']")?.addEventListener("click", () => { + this.connectFormOpen = false; + this.render(); + }); + this.shadow.querySelector("[data-action='submit-connect']")?.addEventListener("click", async () => { + if (this.space === "demo" || this.showingSampleData) { alert("Connect is disabled in demo mode."); return; } + const label = (this.shadow.getElementById("pi-label") as HTMLInputElement)?.value.trim(); + const email = (this.shadow.getElementById("pi-email") as HTMLInputElement)?.value.trim(); + const imapHost = (this.shadow.getElementById("pi-imap-host") as HTMLInputElement)?.value.trim(); + const imapPort = parseInt((this.shadow.getElementById("pi-imap-port") as HTMLInputElement)?.value || "993"); + const imapUser = (this.shadow.getElementById("pi-imap-user") as HTMLInputElement)?.value.trim(); + const imapPass = (this.shadow.getElementById("pi-imap-pass") as HTMLInputElement)?.value; + const smtpHost = (this.shadow.getElementById("pi-smtp-host") as HTMLInputElement)?.value.trim(); + const smtpPort = parseInt((this.shadow.getElementById("pi-smtp-port") as HTMLInputElement)?.value || "587"); + if (!label || !email || !imapHost || !imapUser || !imapPass) { alert("Please fill required fields."); return; } + try { + const base = window.location.pathname.replace(/\/$/, ""); + const resp = await fetch(`${base}/api/personal-inboxes?space=${this.space}`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ label, email, imap_host: imapHost, imap_port: imapPort, imap_user: imapUser, imap_pass: imapPass, smtp_host: smtpHost || undefined, smtp_port: smtpPort }), + }); + if (!resp.ok) { + const err = await resp.json(); + alert(err.error || "Failed to connect."); + return; + } + this.connectFormOpen = false; + this.loadPersonalInboxes(); + } catch { alert("Failed to connect. Please try again."); } + }); + this.shadow.querySelectorAll("[data-disconnect]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (this.space === "demo" || this.showingSampleData) { alert("Demo mode."); return; } + const id = (btn as HTMLElement).dataset.disconnect!; + const base = window.location.pathname.replace(/\/$/, ""); + await fetch(`${base}/api/personal-inboxes/${id}`, { method: "DELETE", headers: this.getAuthHeaders() }); + this.loadPersonalInboxes(); + }); + }); + + // Agent inbox actions + this.shadow.querySelector("[data-action='create-agent']")?.addEventListener("click", () => { + this.agentFormOpen = !this.agentFormOpen; + this.render(); + }); + this.shadow.querySelector("[data-action='cancel-agent']")?.addEventListener("click", () => { + this.agentFormOpen = false; + this.render(); + }); + this.shadow.querySelector("[data-action='submit-agent']")?.addEventListener("click", async () => { + if (this.space === "demo" || this.showingSampleData) { alert("Agent creation is disabled in demo mode."); return; } + const name = (this.shadow.getElementById("agent-name") as HTMLInputElement)?.value.trim(); + const email = (this.shadow.getElementById("agent-email") as HTMLInputElement)?.value.trim(); + const personality = (this.shadow.getElementById("agent-personality") as HTMLTextAreaElement)?.value.trim(); + const autoReply = (this.shadow.getElementById("agent-auto-reply") as HTMLInputElement)?.checked; + const autoClassify = (this.shadow.getElementById("agent-auto-classify") as HTMLInputElement)?.checked; + if (!name || !email) { alert("Name and email required."); return; } + try { + const base = window.location.pathname.replace(/\/$/, ""); + await fetch(`${base}/api/agent-inboxes?space=${this.space}`, { + method: "POST", + headers: this.getAuthHeaders(), + body: JSON.stringify({ name, email, personality, auto_reply: autoReply, auto_classify: autoClassify, rules: [] }), + }); + this.agentFormOpen = false; + this.loadAgentInboxes(); + } catch { alert("Failed to create agent."); } + }); + this.shadow.querySelectorAll("[data-delete-agent]").forEach((btn) => { + btn.addEventListener("click", async () => { + if (this.space === "demo" || this.showingSampleData) { alert("Demo mode."); return; } + const id = (btn as HTMLElement).dataset.deleteAgent!; + const base = window.location.pathname.replace(/\/$/, ""); + await fetch(`${base}/api/agent-inboxes/${id}`, { method: "DELETE", headers: this.getAuthHeaders() }); + this.loadAgentInboxes(); + }); + }); } } diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index 55f1ed8..19dbcd7 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -25,12 +25,43 @@ import { type ThreadComment, type ApprovalItem, type ApprovalSignature, + type PersonalInbox, + type AgentInbox, + type AgentRule, } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); +// ── SMTP Transport (lazy singleton) ── + +let _smtpTransport: any = null; + +const SMTP_HOST = process.env.SMTP_HOST || "mail.rmail.online"; +const SMTP_PORT = parseInt(process.env.SMTP_PORT || "587"); +const SMTP_USER = process.env.SMTP_USER || ""; +const SMTP_PASS = process.env.SMTP_PASS || ""; + +async function getSmtpTransport() { + if (_smtpTransport) return _smtpTransport; + try { + const nodemailer = await import("nodemailer"); + const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; + _smtpTransport = createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_PORT === 465, + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + }); + console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${SMTP_PORT}`); + return _smtpTransport; + } catch (e) { + console.error("[Inbox] Failed to create SMTP transport:", e); + return null; + } +} + // ── In-memory stores for data not in Automerge schemas ── /** Workspace metadata (no Automerge schema — lightweight index) */ @@ -52,6 +83,15 @@ interface ImapConfig { } const _imapConfigs = new Map(); // mailboxId → config +/** Personal inbox IMAP/SMTP credentials (not stored in CRDT for security) */ +interface PersonalImapSmtpConfig { + imapUser: string; + imapPass: string; + smtpUser: string; + smtpPass: string; +} +const _personalCredentials = new Map(); // personalInboxId → creds + /** IMAP sync state per mailbox (transient server state) */ interface ImapSyncState { mailboxId: string; @@ -88,6 +128,8 @@ function ensureMailboxDoc(space: string, mailboxId: string): MailboxDoc { d.members = []; d.threads = {}; d.approvals = {}; + d.personalInboxes = {}; + d.agentInboxes = {}; }); _syncServer!.setDoc(docId, doc); } @@ -207,6 +249,10 @@ function threadToRest(t: ThreadItem) { received_at: new Date(t.receivedAt).toISOString(), created_at: new Date(t.createdAt).toISOString(), comment_count: t.comments.length, + in_reply_to: t.inReplyTo || null, + references: t.references || [], + direction: t.direction || 'inbound', + parent_thread_id: t.parentThreadId || null, }; } @@ -242,9 +288,126 @@ function approvalToRest(a: ApprovalItem) { created_at: new Date(a.createdAt).toISOString(), resolved_at: a.resolvedAt ? new Date(a.resolvedAt).toISOString() : null, signature_count: a.signatures.length, + in_reply_to: a.inReplyTo || null, + references: a.references || [], + reply_type: a.replyType || 'new', }; } +/** Build a quoted message block for replies */ +function quoteBlock(thread: ThreadItem): string { + const date = new Date(thread.receivedAt).toLocaleString(); + const from = thread.fromName ? `${thread.fromName} <${thread.fromAddress}>` : (thread.fromAddress || 'Unknown'); + const quotedBody = (thread.bodyText || '') + .split('\n') + .map(line => `> ${line}`) + .join('\n'); + return `\nOn ${date}, ${from} wrote:\n${quotedBody}\n`; +} + +/** Build forwarded message block */ +function forwardBlock(thread: ThreadItem): string { + const date = new Date(thread.receivedAt).toLocaleString(); + return [ + '', + '---------- Forwarded message ----------', + `From: ${thread.fromName || ''} <${thread.fromAddress || ''}>`, + `Date: ${date}`, + `Subject: ${thread.subject}`, + `To: ${(thread.toAddresses || []).join(', ')}`, + '', + thread.bodyText || '', + ].join('\n'); +} + +// ── Execute Approved Email ── + +async function executeApproval(docId: string, approvalId: string) { + const transport = await getSmtpTransport(); + if (!transport) { + console.error(`[Inbox] No SMTP transport — cannot send approval ${approvalId}`); + return; + } + + const doc = _syncServer!.getDoc(docId); + if (!doc) return; + + const approval = doc.approvals[approvalId]; + if (!approval || approval.status !== 'APPROVED') return; + + // Find the mailbox to get the from address + const mailboxEmail = doc.mailbox.email; + + try { + const mailOptions: any = { + from: mailboxEmail, + to: approval.toAddresses.join(', '), + subject: approval.subject, + text: approval.bodyText, + }; + + if (approval.ccAddresses.length > 0) { + mailOptions.cc = approval.ccAddresses.join(', '); + } + if (approval.bodyHtml) { + mailOptions.html = approval.bodyHtml; + } + if (approval.inReplyTo) { + mailOptions.inReplyTo = approval.inReplyTo; + } + if (approval.references && approval.references.length > 0) { + mailOptions.references = approval.references.join(' '); + } + + await transport.sendMail(mailOptions); + + // Update status to SENT and create outbound thread + _syncServer!.changeDoc(docId, `Send approval ${approvalId}`, (d) => { + const a = d.approvals[approvalId]; + if (!a || a.status !== 'APPROVED') return; // Guard double-send + + a.status = 'SENT'; + + // Create outbound thread record + const threadId = generateId(); + d.threads[threadId] = { + id: threadId, + mailboxId: d.mailbox.id, + messageId: null, + subject: a.subject, + fromAddress: mailboxEmail, + fromName: d.mailbox.name, + toAddresses: [...a.toAddresses], + ccAddresses: [...a.ccAddresses], + bodyText: a.bodyText, + bodyHtml: a.bodyHtml, + tags: ['sent'], + status: 'closed', + isRead: true, + isStarred: false, + assignedTo: null, + hasAttachments: false, + receivedAt: Date.now(), + createdAt: Date.now(), + comments: [], + inReplyTo: a.inReplyTo || null, + references: [...(a.references || [])], + direction: 'outbound', + parentThreadId: a.threadId || null, + }; + }); + + console.log(`[Inbox] Sent approval ${approvalId}: "${approval.subject}" → ${approval.toAddresses.join(', ')}`); + } catch (e: any) { + console.error(`[Inbox] Failed to send approval ${approvalId}:`, e.message); + // Mark as error but don't reset to PENDING + _syncServer!.changeDoc(docId, `Send error for approval ${approvalId}`, (d) => { + const a = d.approvals[approvalId]; + if (a) a.status = 'SEND_ERROR'; + }); + } +} + // ── Mailboxes API ── // GET /api/mailboxes — list mailboxes @@ -297,7 +460,7 @@ routes.post("/api/mailboxes", async (c) => { d.meta = { module: 'inbox', collection: 'mailboxes', - version: 1, + version: 2, spaceSlug: space, createdAt: now, }; @@ -318,6 +481,8 @@ routes.post("/api/mailboxes", async (c) => { d.members = []; d.threads = {}; d.approvals = {}; + d.personalInboxes = {}; + d.agentInboxes = {}; }); _syncServer!.setDoc(mailboxDocId(space, mailboxId), doc); @@ -476,6 +641,168 @@ routes.post("/api/threads/:id/comments", async (c) => { return c.json(commentToRest(comment), 201); }); +// ── Reply / Forward API ── + +// POST /api/threads/:id/reply — create reply approval +routes.post("/api/threads/:id/reply", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const threadId = c.req.param("id"); + const found = findThreadById(threadId); + if (!found) return c.json({ error: "Thread not found" }, 404); + + const [docId, , thread, doc] = found; + const body = await c.req.json(); + const approvalId = generateId(); + const now = Date.now(); + + const subject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; + const toAddresses = thread.fromAddress ? [thread.fromAddress] : []; + const replyBody = (body.body_text || '') + quoteBlock(thread); + const references = [...(thread.references || [])]; + if (thread.messageId) references.push(thread.messageId); + + _syncServer!.changeDoc(docId, `Reply to thread ${threadId}`, (d) => { + d.approvals[approvalId] = { + id: approvalId, + mailboxId: doc.mailbox.id, + threadId, + authorId: claims.sub, + subject, + bodyText: replyBody, + bodyHtml: '', + toAddresses, + ccAddresses: [], + status: 'PENDING', + requiredSignatures: doc.mailbox.approvalThreshold || 1, + safeTxHash: null, + createdAt: now, + resolvedAt: 0, + signatures: [], + inReplyTo: thread.messageId || null, + references, + replyType: 'reply', + }; + }); + + const updatedDoc = _syncServer!.getDoc(docId)!; + return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); +}); + +// POST /api/threads/:id/reply-all — reply to all recipients +routes.post("/api/threads/:id/reply-all", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const threadId = c.req.param("id"); + const found = findThreadById(threadId); + if (!found) return c.json({ error: "Thread not found" }, 404); + + const [docId, , thread, doc] = found; + const body = await c.req.json(); + const approvalId = generateId(); + const now = Date.now(); + + const subject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; + const mailboxEmail = doc.mailbox.email.toLowerCase(); + + // To: original sender + original To (minus self) + const allRecipients = new Set(); + if (thread.fromAddress) allRecipients.add(thread.fromAddress); + for (const addr of thread.toAddresses || []) { + if (addr.toLowerCase() !== mailboxEmail) allRecipients.add(addr); + } + const toAddresses = Array.from(allRecipients); + + // CC: original CC (minus self) + const ccAddresses = (thread.ccAddresses || []).filter(a => a.toLowerCase() !== mailboxEmail); + + const replyBody = (body.body_text || '') + quoteBlock(thread); + const references = [...(thread.references || [])]; + if (thread.messageId) references.push(thread.messageId); + + _syncServer!.changeDoc(docId, `Reply-all to thread ${threadId}`, (d) => { + d.approvals[approvalId] = { + id: approvalId, + mailboxId: doc.mailbox.id, + threadId, + authorId: claims.sub, + subject, + bodyText: replyBody, + bodyHtml: '', + toAddresses, + ccAddresses, + status: 'PENDING', + requiredSignatures: doc.mailbox.approvalThreshold || 1, + safeTxHash: null, + createdAt: now, + resolvedAt: 0, + signatures: [], + inReplyTo: thread.messageId || null, + references, + replyType: 'reply-all', + }; + }); + + const updatedDoc = _syncServer!.getDoc(docId)!; + return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); +}); + +// POST /api/threads/:id/forward — forward a thread +routes.post("/api/threads/:id/forward", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const threadId = c.req.param("id"); + const found = findThreadById(threadId); + if (!found) return c.json({ error: "Thread not found" }, 404); + + const [docId, , thread, doc] = found; + const body = await c.req.json(); + const { to_addresses } = body; + if (!to_addresses || !Array.isArray(to_addresses) || to_addresses.length === 0) { + return c.json({ error: "to_addresses required" }, 400); + } + + const approvalId = generateId(); + const now = Date.now(); + const subject = thread.subject.startsWith('Fwd:') ? thread.subject : `Fwd: ${thread.subject}`; + const fwdBody = (body.body_text || '') + forwardBlock(thread); + + _syncServer!.changeDoc(docId, `Forward thread ${threadId}`, (d) => { + d.approvals[approvalId] = { + id: approvalId, + mailboxId: doc.mailbox.id, + threadId, + authorId: claims.sub, + subject, + bodyText: fwdBody, + bodyHtml: '', + toAddresses: to_addresses, + ccAddresses: [], + status: 'PENDING', + requiredSignatures: doc.mailbox.approvalThreshold || 1, + safeTxHash: null, + createdAt: now, + resolvedAt: 0, + signatures: [], + inReplyTo: null, // Forwards don't thread + references: [], + replyType: 'forward', + }; + }); + + const updatedDoc = _syncServer!.getDoc(docId)!; + return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); +}); + // ── Approvals API ── // GET /api/approvals — list pending approvals @@ -505,6 +832,24 @@ routes.get("/api/approvals", async (c) => { } }); +// GET /api/approvals/:id — get single approval detail +routes.get("/api/approvals/:id", async (c) => { + const id = c.req.param("id"); + const found = findApprovalById(id); + if (!found) return c.json({ error: "Approval not found" }, 404); + + const [, , approval] = found; + return c.json({ + ...approvalToRest(approval), + signatures: approval.signatures.map(s => ({ + id: s.id, + signer_id: s.signerId, + vote: s.vote, + signed_at: new Date(s.signedAt).toISOString(), + })), + }); +}); + // POST /api/approvals — create approval draft routes.post("/api/approvals", async (c) => { const token = extractToken(c.req.raw.headers); @@ -513,7 +858,7 @@ routes.post("/api/approvals", async (c) => { try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); - const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body; + const { mailbox_slug, thread_id, subject, body_text, to_addresses, cc_addresses, in_reply_to, references: refs, reply_type } = body; if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400); const found = findMailboxBySlug(mailbox_slug); @@ -534,35 +879,21 @@ routes.post("/api/approvals", async (c) => { bodyText: body_text || '', bodyHtml: '', toAddresses: to_addresses || [], - ccAddresses: [], + ccAddresses: cc_addresses || [], status: 'PENDING', requiredSignatures: doc.mailbox.approvalThreshold || 1, safeTxHash: null, createdAt: now, resolvedAt: 0, signatures: [], + inReplyTo: in_reply_to || null, + references: refs || [], + replyType: reply_type || 'new', }; }); - const approval: ApprovalItem = { - id: approvalId, - mailboxId: doc.mailbox.id, - threadId: thread_id || null, - authorId: claims.sub, - subject, - bodyText: body_text || '', - bodyHtml: '', - toAddresses: to_addresses || [], - ccAddresses: [], - status: 'PENDING', - requiredSignatures: doc.mailbox.approvalThreshold || 1, - safeTxHash: null, - createdAt: now, - resolvedAt: 0, - signatures: [], - }; - - return c.json(approvalToRest(approval), 201); + const updatedDoc = _syncServer!.getDoc(docId)!; + return c.json(approvalToRest(updatedDoc.approvals[approvalId]), 201); }); // POST /api/approvals/:id/sign — sign an approval @@ -624,6 +955,11 @@ routes.post("/api/approvals/:id/sign", async (c) => { const finalApproval = updated.approvals[id]; const approveCount = finalApproval.signatures.filter((s) => s.vote === 'APPROVE').length; + // If approved, fire async send + if (finalApproval.status === 'APPROVED') { + executeApproval(docId, id).catch(e => console.error('[Inbox] executeApproval error:', e)); + } + return c.json({ ok: true, status: finalApproval.status, @@ -632,6 +968,235 @@ routes.post("/api/approvals/:id/sign", async (c) => { }); }); +// ── Personal Inboxes API ── + +// POST /api/personal-inboxes — connect a personal email account +routes.post("/api/personal-inboxes", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { label, email, imap_host, imap_port, imap_user, imap_pass, smtp_host, smtp_port, smtp_user, smtp_pass } = body; + if (!label || !email || !imap_host || !imap_user || !imap_pass) { + return c.json({ error: "label, email, imap_host, imap_user, imap_pass required" }, 400); + } + + // Validate IMAP connection + try { + const { ImapFlow } = await import("imapflow"); + const client = new ImapFlow({ + host: imap_host, + port: imap_port || 993, + secure: (imap_port || 993) === 993, + auth: { user: imap_user, pass: imap_pass }, + logger: false, + }); + await client.connect(); + await client.logout(); + } catch (e: any) { + return c.json({ error: `IMAP connection failed: ${e.message}` }, 400); + } + + const inboxId = generateId(); + const now = Date.now(); + + // Store credentials securely (not in Automerge) + _personalCredentials.set(inboxId, { + imapUser: imap_user, + imapPass: imap_pass, + smtpUser: smtp_user || imap_user, + smtpPass: smtp_pass || imap_pass, + }); + + // Store metadata in first available mailbox doc for this space + const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; + const allDocs = getAllMailboxDocs().filter(d => d.space === space); + if (allDocs.length === 0) return c.json({ error: "No mailbox in this space" }, 404); + + const { docId } = allDocs[0]; + _syncServer!.changeDoc(docId, `Add personal inbox ${inboxId}`, (d) => { + if (!d.personalInboxes) d.personalInboxes = {}; + d.personalInboxes[inboxId] = { + id: inboxId, + ownerDid: claims.sub, + label, + email, + imapHost: imap_host, + imapPort: imap_port || 993, + smtpHost: smtp_host || imap_host, + smtpPort: smtp_port || 587, + lastSyncAt: 0, + status: 'active', + }; + }); + + return c.json({ + id: inboxId, + label, + email, + imap_host, + imap_port: imap_port || 993, + smtp_host: smtp_host || imap_host, + smtp_port: smtp_port || 587, + status: 'active', + last_sync_at: null, + }, 201); +}); + +// GET /api/personal-inboxes — list personal inboxes for authenticated user +routes.get("/api/personal-inboxes", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const inboxes: any[] = []; + for (const { doc } of getAllMailboxDocs()) { + if (!doc.personalInboxes) continue; + for (const pi of Object.values(doc.personalInboxes)) { + if (pi.ownerDid === claims.sub) { + inboxes.push({ + id: pi.id, + label: pi.label, + email: pi.email, + imap_host: pi.imapHost, + imap_port: pi.imapPort, + smtp_host: pi.smtpHost, + smtp_port: pi.smtpPort, + status: pi.status, + last_sync_at: pi.lastSyncAt ? new Date(pi.lastSyncAt).toISOString() : null, + }); + } + } + } + + return c.json({ personal_inboxes: inboxes }); +}); + +// DELETE /api/personal-inboxes/:id — disconnect a personal inbox +routes.delete("/api/personal-inboxes/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const inboxId = c.req.param("id"); + _personalCredentials.delete(inboxId); + + for (const { docId, doc } of getAllMailboxDocs()) { + if (doc.personalInboxes?.[inboxId] && doc.personalInboxes[inboxId].ownerDid === claims.sub) { + _syncServer!.changeDoc(docId, `Remove personal inbox ${inboxId}`, (d) => { + if (d.personalInboxes?.[inboxId]) { + delete d.personalInboxes[inboxId]; + } + }); + return c.json({ ok: true }); + } + } + + return c.json({ error: "Personal inbox not found" }, 404); +}); + +// ── Agent Inboxes API ── + +// POST /api/agent-inboxes — create an agent inbox +routes.post("/api/agent-inboxes", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + let claims; + try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const body = await c.req.json(); + const { name, email, personality, auto_reply = false, auto_classify = false, rules = [] } = body; + if (!name || !email) return c.json({ error: "name and email required" }, 400); + + const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; + const allDocs = getAllMailboxDocs().filter(d => d.space === space); + if (allDocs.length === 0) return c.json({ error: "No mailbox in this space" }, 404); + + const agentId = generateId(); + const { docId } = allDocs[0]; + + _syncServer!.changeDoc(docId, `Create agent inbox ${agentId}`, (d) => { + if (!d.agentInboxes) d.agentInboxes = {}; + d.agentInboxes[agentId] = { + id: agentId, + spaceSlug: space, + name, + email, + personality: personality || '', + autoReply: auto_reply, + autoClassify: auto_classify, + rules: rules.map((r: any) => ({ + match: { field: r.match?.field || 'subject', pattern: r.match?.pattern || '' }, + action: r.action || 'tag', + value: r.value || undefined, + })), + }; + }); + + return c.json({ + id: agentId, + name, + email, + personality: personality || '', + auto_reply: auto_reply, + auto_classify: auto_classify, + rules, + space_slug: space, + }, 201); +}); + +// GET /api/agent-inboxes — list agent inboxes for space +routes.get("/api/agent-inboxes", async (c) => { + const space = c.req.query("space") || c.req.param("space") || DEFAULT_SPACE; + + const agents: any[] = []; + for (const { doc } of getAllMailboxDocs()) { + if (!doc.agentInboxes) continue; + for (const ai of Object.values(doc.agentInboxes)) { + if (ai.spaceSlug === space || space === DEFAULT_SPACE) { + agents.push({ + id: ai.id, + name: ai.name, + email: ai.email, + personality: ai.personality, + auto_reply: ai.autoReply, + auto_classify: ai.autoClassify, + rules: ai.rules, + space_slug: ai.spaceSlug, + }); + } + } + } + + return c.json({ agent_inboxes: agents }); +}); + +// DELETE /api/agent-inboxes/:id — remove an agent inbox +routes.delete("/api/agent-inboxes/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const agentId = c.req.param("id"); + + for (const { docId, doc } of getAllMailboxDocs()) { + if (doc.agentInboxes?.[agentId]) { + _syncServer!.changeDoc(docId, `Remove agent inbox ${agentId}`, (d) => { + if (d.agentInboxes?.[agentId]) { + delete d.agentInboxes[agentId]; + } + }); + return c.json({ ok: true }); + } + } + + return c.json({ error: "Agent inbox not found" }, 404); +}); + // ── Workspaces API ── // GET /api/workspaces @@ -684,7 +1249,7 @@ routes.post("/api/workspaces", async (c) => { }); // GET /api/health -routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "" })); +routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "", smtpReady: !!SMTP_USER })); // GET /api/sync-status — show IMAP sync state per mailbox routes.get("/api/sync-status", async (c) => { @@ -809,6 +1374,8 @@ async function syncMailbox(mailbox: SyncableMailbox) { const toAddrs = (parsed.to?.value || []).map((a: any) => a.address || ""); const ccAddrs = (parsed.cc?.value || []).map((a: any) => a.address || ""); const messageId = parsed.messageId || msg.envelope?.messageId || null; + const inReplyTo = parsed.inReplyTo || null; + const references = parsed.references ? (Array.isArray(parsed.references) ? parsed.references : [parsed.references]) : []; const hasAttachments = (parsed.attachments?.length || 0) > 0; const receivedAt = parsed.date ? parsed.date.getTime() : Date.now(); @@ -841,9 +1408,16 @@ async function syncMailbox(mailbox: SyncableMailbox) { receivedAt, createdAt: Date.now(), comments: [], + inReplyTo: inReplyTo || null, + references, + direction: 'inbound', + parentThreadId: null, }; }); count++; + + // Agent inbox processing + processAgentRules(docId, threadId); } } catch (parseErr) { console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr); @@ -873,6 +1447,76 @@ async function syncMailbox(mailbox: SyncableMailbox) { } } +/** Process agent inbox rules for a new inbound thread */ +function processAgentRules(docId: string, threadId: string) { + const doc = _syncServer!.getDoc(docId); + if (!doc || !doc.agentInboxes) return; + + const thread = doc.threads[threadId]; + if (!thread) return; + + for (const agent of Object.values(doc.agentInboxes)) { + for (const rule of agent.rules) { + let fieldValue = ''; + switch (rule.match.field) { + case 'from': fieldValue = thread.fromAddress || ''; break; + case 'subject': fieldValue = thread.subject || ''; break; + case 'body': fieldValue = thread.bodyText || ''; break; + } + + try { + if (new RegExp(rule.match.pattern, 'i').test(fieldValue)) { + if (agent.autoClassify && rule.action === 'tag' && rule.value) { + _syncServer!.changeDoc(docId, `Agent classify: tag ${rule.value}`, (d) => { + const t = d.threads[threadId]; + if (t && !t.tags.includes(rule.value!)) { + t.tags.push(rule.value!); + } + }); + } + if (agent.autoClassify && rule.action === 'assign' && rule.value) { + _syncServer!.changeDoc(docId, `Agent classify: assign ${rule.value}`, (d) => { + const t = d.threads[threadId]; + if (t) t.assignedTo = rule.value!; + }); + } + if (agent.autoReply && rule.action === 'reply') { + // Agent auto-replies go through approval workflow + const approvalId = generateId(); + const replySubject = thread.subject.startsWith('Re:') ? thread.subject : `Re: ${thread.subject}`; + const references = [...(thread.references || [])]; + if (thread.messageId) references.push(thread.messageId); + + _syncServer!.changeDoc(docId, `Agent auto-reply draft`, (d) => { + d.approvals[approvalId] = { + id: approvalId, + mailboxId: d.mailbox.id, + threadId, + authorId: `agent:${agent.id}`, + subject: replySubject, + bodyText: rule.value || `[Auto-reply from ${agent.name}]`, + bodyHtml: '', + toAddresses: thread.fromAddress ? [thread.fromAddress] : [], + ccAddresses: [], + status: 'PENDING', + requiredSignatures: d.mailbox.approvalThreshold || 1, + safeTxHash: null, + createdAt: Date.now(), + resolvedAt: 0, + signatures: [], + inReplyTo: thread.messageId || null, + references, + replyType: 'reply', + }; + }); + console.log(`[Inbox] Agent "${agent.name}" drafted auto-reply for thread ${threadId}`); + } + } + } catch { /* invalid regex — skip */ } + } + } +} + function runSyncLoop() { if (!IMAP_HOST) { console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled"); @@ -999,17 +1643,19 @@ function seedTemplateInbox(space: string) { const day = 86400000; const doc = Automerge.change(Automerge.init(), 'seed template mailbox', (d) => { - d.meta = { module: 'inbox', collection: 'mailboxes', version: 1, spaceSlug: space, createdAt: now }; + d.meta = { module: 'inbox', collection: 'mailboxes', version: 2, spaceSlug: space, createdAt: now }; d.mailbox = { - id: mbId, workspaceId: null, slug: 'commons-team', name: 'Commons Team', - email: `commons-team@${space}.rspace.online`, - description: 'Shared mailbox for the commons coordination team.', + id: mbId, workspaceId: null, slug: `${space}-inbox`, name: `${space.charAt(0).toUpperCase() + space.slice(1)} Inbox`, + email: `${space}@rspace.online`, + description: `Shared mailbox for the ${space} space.`, visibility: 'members', ownerDid: 'did:demo:seed', safeAddress: null, safeChainId: null, approvalThreshold: 1, createdAt: now, }; d.members = []; d.threads = {}; d.approvals = {}; + d.personalInboxes = {}; + d.agentInboxes = {}; const threads: Array<{ subj: string; from: string; fromName: string; body: string; tags: string[]; age: number }> = [ { @@ -1037,17 +1683,18 @@ function seedTemplateInbox(space: string) { d.threads[tId] = { id: tId, mailboxId: mbId, messageId: `<${crypto.randomUUID()}@demo>`, subject: t.subj, fromAddress: t.from, fromName: t.fromName, - toAddresses: [`commons-team@${space}.rspace.online`], ccAddresses: [], + toAddresses: [`${space}@rspace.online`], ccAddresses: [], bodyText: t.body, bodyHtml: '', tags: t.tags, status: 'open', isRead: t.age > 3, isStarred: t.tags.includes('important'), assignedTo: null, hasAttachments: false, receivedAt: now - t.age * day, createdAt: now - t.age * day, comments: [], + inReplyTo: null, references: [], direction: 'inbound', parentThreadId: null, }; } }); _syncServer.setDoc(docId, doc); - console.log(`[Inbox] Template seeded for "${space}": 1 mailbox, 3 threads`); + console.log(`[Inbox] Template seeded for "${space}": 1 mailbox (${space}-inbox), 3 threads`); } export const inboxModule: RSpaceModule = { @@ -1063,6 +1710,11 @@ export const inboxModule: RSpaceModule = { async onInit(ctx) { _syncServer = ctx.syncServer; console.log("[Inbox] Module initialized (Automerge-only, no PG)"); + // Pre-warm SMTP transport + if (SMTP_USER) getSmtpTransport().catch(() => {}); + }, + async onSpaceCreate(ctx) { + seedTemplateInbox(ctx.spaceSlug); }, standaloneDomain: "rinbox.online", feeds: [ diff --git a/modules/rinbox/schemas.ts b/modules/rinbox/schemas.ts index 5b6300f..c347147 100644 --- a/modules/rinbox/schemas.ts +++ b/modules/rinbox/schemas.ts @@ -48,6 +48,11 @@ export interface ThreadItem { receivedAt: number; createdAt: number; comments: ThreadComment[]; + // Threading (RFC 5322) + inReplyTo: string | null; + references: string[]; + direction: 'inbound' | 'outbound'; + parentThreadId: string | null; } export interface ApprovalSignature { @@ -74,6 +79,44 @@ export interface ApprovalItem { createdAt: number; resolvedAt: number; signatures: ApprovalSignature[]; + // Reply/forward context + inReplyTo: string | null; + references: string[]; + replyType: 'reply' | 'reply-all' | 'forward' | 'new'; +} + +// ── Personal Inbox ── + +export interface PersonalInbox { + id: string; + ownerDid: string; + label: string; + email: string; + imapHost: string; + imapPort: number; + smtpHost: string; + smtpPort: number; + lastSyncAt: number; + status: 'active' | 'error' | 'paused'; +} + +// ── Agent Inbox ── + +export interface AgentRule { + match: { field: 'from' | 'subject' | 'body'; pattern: string }; + action: 'reply' | 'forward' | 'tag' | 'assign'; + value?: string; +} + +export interface AgentInbox { + id: string; + spaceSlug: string; + name: string; + email: string; + personality: string; + autoReply: boolean; + autoClassify: boolean; + rules: AgentRule[]; } export interface MailboxMeta { @@ -103,6 +146,8 @@ export interface MailboxDoc { members: MailboxMember[]; threads: Record; approvals: Record; + personalInboxes: Record; + agentInboxes: Record; } // ── Schema registration ── @@ -110,12 +155,12 @@ export interface MailboxDoc { export const mailboxSchema: DocSchema = { module: 'inbox', collection: 'mailboxes', - version: 1, + version: 2, init: (): MailboxDoc => ({ meta: { module: 'inbox', collection: 'mailboxes', - version: 1, + version: 2, spaceSlug: '', createdAt: Date.now(), }, @@ -136,6 +181,8 @@ export const mailboxSchema: DocSchema = { members: [], threads: {}, approvals: {}, + personalInboxes: {}, + agentInboxes: {}, }), }; diff --git a/website/canvas.html b/website/canvas.html index 8d77b4f..a57a800 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1732,7 +1732,8 @@ folk-freecad, folk-kicad, folk-zine-gen, - folk-rapp { + folk-rapp, + folk-multisig-email { position: absolute; } @@ -1753,7 +1754,7 @@ folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, - folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) { + folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email) { cursor: crosshair; } @@ -1765,7 +1766,7 @@ folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, - folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover { + folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -2252,6 +2253,7 @@ +
@@ -2898,6 +2900,7 @@ "folk-splat", "folk-blender", "folk-drawfast", "folk-freecad", "folk-kicad", "folk-rapp", + "folk-multisig-email", "folk-feed" ].join(", "); @@ -3650,6 +3653,21 @@ case "folk-kicad": shape = document.createElement("folk-kicad"); break; + case "folk-multisig-email": + shape = document.createElement("folk-multisig-email"); + if (data.mailboxSlug) shape.mailboxSlug = data.mailboxSlug; + if (data.toAddresses) shape.toAddresses = data.toAddresses; + if (data.ccAddresses) shape.ccAddresses = data.ccAddresses; + if (data.subject) shape.subject = data.subject; + if (data.bodyText) shape.bodyText = data.bodyText; + if (data.bodyHtml) shape.bodyHtml = data.bodyHtml; + if (data.replyToThreadId) shape.replyToThreadId = data.replyToThreadId; + if (data.replyType) shape.replyType = data.replyType; + if (data.approvalId) shape.approvalId = data.approvalId; + if (data.status) shape.status = data.status; + if (data.requiredSignatures != null) shape.requiredSignatures = data.requiredSignatures; + if (data.signatures) shape.signatures = data.signatures; + break; case "folk-canvas": shape = document.createElement("folk-canvas"); shape.parentSlug = communitySlug; // pass parent context for nest-from @@ -3789,6 +3807,7 @@ "folk-spider-3d": { width: 440, height: 480 }, "folk-choice-conviction": { width: 380, height: 480 }, "folk-social-post": { width: 300, height: 380 }, + "folk-multisig-email": { width: 400, height: 380 }, "folk-splat": { width: 480, height: 420 }, "folk-blender": { width: 420, height: 520 }, "folk-drawfast": { width: 500, height: 480 }, @@ -4349,6 +4368,19 @@ }); }); + // Multi-sig email + document.getElementById("new-multisig-email").addEventListener("click", () => { + setPendingTool("folk-multisig-email", { + mailboxSlug: "", + toAddresses: [], + subject: "", + bodyText: "", + status: "draft", + requiredSignatures: 2, + signatures: [], + }); + }); + // Social media post document.getElementById("new-social-post").addEventListener("click", () => { setPendingTool("folk-social-post", { @@ -5181,7 +5213,7 @@ "folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱", "folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️", "folk-freecad": "📐", "folk-kicad": "🔌", - "folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️", + "folk-rapp": "📱", "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️", }; function getShapeLabel(data) { @@ -5923,7 +5955,7 @@ }); // ── Feed Mode ── - const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-feed'; + const FEED_SHAPE_SELECTOR = 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-token-mint, folk-token-ledger, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-multisig-email, folk-feed'; let feedMode = false; let feedSortKey = 'y'; From 8bc7787d37859f52f6016d503d65a41691cd383f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 11:42:05 -0700 Subject: [PATCH 2/3] feat(rschedule): n8n-style automation canvas at /:space/rschedule/reminders Visual workflow builder with drag-and-drop node palette (15 node types across triggers, conditions, and actions), SVG canvas with Bezier wiring, config panel, REST-persisted CRUD, topological execution engine, cron tick loop integration, webhook trigger endpoint, and two demo workflows (proximity notification + document sign-off pipeline). Co-Authored-By: Claude Opus 4.6 --- .../components/automation-canvas.css | 551 +++++++++ .../components/folk-automation-canvas.ts | 1007 +++++++++++++++++ modules/rschedule/mod.ts | 602 ++++++++++ modules/rschedule/schemas.ts | 281 +++++ vite.config.ts | 49 + 5 files changed, 2490 insertions(+) create mode 100644 modules/rschedule/components/automation-canvas.css create mode 100644 modules/rschedule/components/folk-automation-canvas.ts diff --git a/modules/rschedule/components/automation-canvas.css b/modules/rschedule/components/automation-canvas.css new file mode 100644 index 0000000..ab0f850 --- /dev/null +++ b/modules/rschedule/components/automation-canvas.css @@ -0,0 +1,551 @@ +/* rSchedule Automation Canvas — n8n-style workflow builder */ +folk-automation-canvas { + display: block; + height: calc(100vh - 60px); +} + +.ac-root { + display: flex; + flex-direction: column; + height: 100%; + font-family: system-ui, -apple-system, sans-serif; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── Toolbar ── */ +.ac-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + min-height: 46px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + z-index: 10; +} + +.ac-toolbar__title { + font-size: 15px; + font-weight: 600; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.ac-toolbar__title input { + background: transparent; + border: 1px solid transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 15px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + width: 200px; +} + +.ac-toolbar__title input:hover, +.ac-toolbar__title input:focus { + border-color: var(--rs-border-strong, #3d3d5c); + outline: none; +} + +.ac-toolbar__actions { + display: flex; + gap: 6px; + align-items: center; +} + +.ac-btn { + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} + +.ac-btn:hover { + border-color: var(--rs-border-strong, #4d4d6c); +} + +.ac-btn--run { + background: #3b82f622; + border-color: #3b82f655; + color: #60a5fa; +} + +.ac-btn--run:hover { + background: #3b82f633; + border-color: #3b82f6; +} + +.ac-btn--save { + background: #10b98122; + border-color: #10b98155; + color: #34d399; +} + +.ac-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-toggle input[type="checkbox"] { + accent-color: #10b981; +} + +.ac-save-indicator { + font-size: 11px; + color: var(--rs-text-muted, #64748b); +} + +/* ── Canvas area ── */ +.ac-canvas-area { + flex: 1; + display: flex; + overflow: hidden; + position: relative; +} + +/* ── Left sidebar — node palette ── */ +.ac-palette { + width: 200px; + min-width: 200px; + border-right: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.ac-palette__group-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 4px; +} + +.ac-palette__card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-input-bg, #16162a); + cursor: grab; + font-size: 12px; + transition: border-color 0.15s, background 0.15s; + margin-bottom: 4px; +} + +.ac-palette__card:hover { + border-color: #6366f1; + background: #6366f111; +} + +.ac-palette__card:active { + cursor: grabbing; +} + +.ac-palette__card-icon { + font-size: 16px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.ac-palette__card-label { + font-weight: 500; + color: var(--rs-text-primary, #e2e8f0); +} + +/* ── SVG canvas ── */ +.ac-canvas { + flex: 1; + position: relative; + overflow: hidden; + cursor: grab; + background: var(--rs-canvas-bg, #0f0f23); +} + +.ac-canvas.grabbing { + cursor: grabbing; +} + +.ac-canvas.wiring { + cursor: crosshair; +} + +.ac-canvas svg { + display: block; + width: 100%; + height: 100%; +} + +/* ── Right sidebar — config ── */ +.ac-config { + width: 0; + overflow: hidden; + border-left: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + display: flex; + flex-direction: column; + transition: width 0.2s ease; +} + +.ac-config.open { + width: 280px; + min-width: 280px; +} + +.ac-config__header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + font-weight: 600; + font-size: 13px; +} + +.ac-config__header-close { + background: none; + border: none; + color: var(--rs-text-muted, #94a3b8); + font-size: 16px; + cursor: pointer; + margin-left: auto; + padding: 2px; +} + +.ac-config__body { + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + flex: 1; +} + +.ac-config__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-config__field label { + font-size: 11px; + font-weight: 500; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-config__field input, +.ac-config__field select, +.ac-config__field textarea { + width: 100%; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + font-family: inherit; + box-sizing: border-box; +} + +.ac-config__field textarea { + resize: vertical; + min-height: 60px; +} + +.ac-config__field input:focus, +.ac-config__field select:focus, +.ac-config__field textarea:focus { + border-color: #3b82f6; + outline: none; +} + +.ac-config__delete { + margin-top: 12px; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid #ef444455; + background: #ef444422; + color: #f87171; + font-size: 12px; + cursor: pointer; +} + +.ac-config__delete:hover { + background: #ef444433; +} + +/* ── Execution log in config panel ── */ +.ac-exec-log { + margin-top: 12px; + border-top: 1px solid var(--rs-border, #2d2d44); + padding-top: 12px; +} + +.ac-exec-log__title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 8px; +} + +.ac-exec-log__entry { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); +} + +.ac-exec-log__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-exec-log__dot.success { background: #22c55e; } +.ac-exec-log__dot.error { background: #ef4444; } +.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; } + +@keyframes ac-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Zoom controls ── */ +.ac-zoom-controls { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + align-items: center; + gap: 4px; + background: var(--rs-bg-surface, #1a1a2e); + border: 1px solid var(--rs-border-strong, #3d3d5c); + border-radius: 8px; + padding: 4px 6px; + z-index: 5; +} + +.ac-zoom-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--rs-text-primary, #e2e8f0); + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.ac-zoom-btn:hover { + background: var(--rs-bg-surface-raised, #252545); +} + +.ac-zoom-level { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + min-width: 36px; + text-align: center; +} + +/* ── Node styles in SVG ── */ +.ac-node { cursor: pointer; } +.ac-node.selected > foreignObject > div { + outline: 2px solid #6366f1; + outline-offset: 2px; +} + +.ac-node foreignObject > div { + border-radius: 10px; + border: 1px solid var(--rs-border, #2d2d44); + background: var(--rs-bg-surface, #1a1a2e); + overflow: hidden; + font-size: 12px; + transition: border-color 0.15s; +} + +.ac-node:hover foreignObject > div { + border-color: #4f46e5 !important; +} + +.ac-node-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--rs-border, #2d2d44); + cursor: move; +} + +.ac-node-icon { + font-size: 14px; +} + +.ac-node-label { + font-weight: 600; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ac-node-status { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ac-node-status.idle { background: #4b5563; } +.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; } +.ac-node-status.success { background: #22c55e; } +.ac-node-status.error { background: #ef4444; } + +.ac-node-ports { + padding: 6px 10px; + display: flex; + justify-content: space-between; + min-height: 28px; +} + +.ac-node-inputs, +.ac-node-outputs { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ac-node-outputs { + align-items: flex-end; +} + +.ac-port-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Port handles in SVG ── */ +.ac-port-group { cursor: crosshair; } +.ac-port-dot { + transition: r 0.15s, filter 0.15s; +} + +.ac-port-group:hover .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 4px currentColor); +} + +.ac-port-group--wiring-source .ac-port-dot { + r: 7; + filter: drop-shadow(0 0 6px currentColor); +} + +.ac-port-group--wiring-target .ac-port-dot { + r: 7; + animation: port-pulse 0.8s ease-in-out infinite; +} + +.ac-port-group--wiring-dimmed .ac-port-dot { + opacity: 0.2; +} + +@keyframes port-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px currentColor); } + 50% { filter: drop-shadow(0 0 8px currentColor); } +} + +/* ── Edge styles ── */ +.ac-edge-group { pointer-events: stroke; } + +.ac-edge-path { + fill: none; + stroke-width: 2; +} + +.ac-edge-hit { + fill: none; + stroke: transparent; + stroke-width: 16; + cursor: pointer; +} + +.ac-edge-path.running { + animation: ac-edge-flow 1s linear infinite; + stroke-dasharray: 8 4; +} + +@keyframes ac-edge-flow { + to { stroke-dashoffset: -24; } +} + +/* ── Wiring temp line ── */ +.ac-wiring-temp { + fill: none; + stroke: #6366f1; + stroke-width: 2; + stroke-dasharray: 6 4; + opacity: 0.7; + pointer-events: none; +} + +/* ── Workflow selector ── */ +.ac-workflow-select { + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--rs-input-border, #3d3d5c); + background: var(--rs-input-bg, #16162a); + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .ac-palette { + width: 160px; + min-width: 160px; + padding: 8px; + } + + .ac-config.open { + position: absolute; + top: 0; + right: 0; + width: 100%; + height: 100%; + z-index: 20; + min-width: unset; + } + + .ac-toolbar { + flex-wrap: wrap; + padding: 8px 12px; + } +} diff --git a/modules/rschedule/components/folk-automation-canvas.ts b/modules/rschedule/components/folk-automation-canvas.ts new file mode 100644 index 0000000..76d9d45 --- /dev/null +++ b/modules/rschedule/components/folk-automation-canvas.ts @@ -0,0 +1,1007 @@ +/** + * — 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/mod.ts b/modules/rschedule/mod.ts index 6c37a09..632e1d1 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -30,7 +30,11 @@ import type { ExecutionLogEntry, ActionType, Reminder, + Workflow, + WorkflowNode, + WorkflowEdge, } from "./schemas"; +import { NODE_CATALOG } from "./schemas"; import { calendarDocId } from "../rcal/schemas"; import type { CalendarDoc, ScheduledItemMetadata } from "../rcal/schemas"; @@ -73,6 +77,7 @@ function ensureDoc(space: string): ScheduleDoc { d.meta.spaceSlug = space; d.jobs = {}; d.reminders = {}; + d.workflows = {}; d.log = []; }, ); @@ -667,6 +672,38 @@ function startTickLoop() { console.error(`[Schedule] Reminder email error for "${reminder.title}":`, e); } } + + // ── Process due automation workflows ── + const workflows = Object.values(doc.workflows || {}); + for (const wf of workflows) { + if (!wf.enabled) continue; + const cronNodes = wf.nodes.filter(n => n.type === "trigger-cron"); + for (const cronNode of cronNodes) { + const expr = String(cronNode.config.cronExpression || ""); + const tz = String(cronNode.config.timezone || "UTC"); + if (!expr) continue; + try { + const interval = CronExpressionParser.parse(expr, { + currentDate: new Date(now - TICK_INTERVAL), + tz, + }); + const nextDate = interval.next().toDate(); + if (nextDate.getTime() <= now) { + console.log(`[Schedule] Running cron workflow "${wf.name}" for space ${space}`); + const results = await executeWorkflow(wf, space); + const allOk = results.every(r => r.status !== "error"); + _syncServer.changeDoc(docId, `tick workflow ${wf.id}`, (d) => { + const w = d.workflows[wf.id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + } + } catch { /* invalid cron — skip */ } + } + } } catch (e) { console.error(`[Schedule] Tick error for space ${space}:`, e); } @@ -1318,6 +1355,569 @@ routes.post("/api/reminders/:id/snooze", async (c) => { return c.json(updated.reminders[id]); }); +// ── Automation canvas page route ── + +routes.get("/reminders", (c) => { + const space = c.req.param("space") || "demo"; + return c.html( + renderShell({ + title: `${space} — Automations | rSpace`, + moduleId: "rschedule", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ``, + }), + ); +}); + +// ── Workflow CRUD API ── + +routes.get("/api/workflows", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const doc = ensureDoc(dataSpace); + // Ensure workflows field exists on older docs + const workflows = Object.values(doc.workflows || {}); + workflows.sort((a, b) => a.name.localeCompare(b.name)); + return c.json({ count: workflows.length, results: workflows }); +}); + +routes.post("/api/workflows", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const body = await c.req.json(); + + const docId = scheduleDocId(dataSpace); + ensureDoc(dataSpace); + const wfId = crypto.randomUUID(); + const now = Date.now(); + + const workflow: Workflow = { + id: wfId, + name: body.name || "New Workflow", + enabled: body.enabled !== false, + nodes: body.nodes || [], + edges: body.edges || [], + lastRunAt: null, + lastRunStatus: null, + runCount: 0, + createdAt: now, + updatedAt: now, + }; + + _syncServer!.changeDoc(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" as any) as string) || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + return c.json(wf); +}); + +routes.put("/api/workflows/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(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" as any) as string) || space; + const id = c.req.param("id"); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.workflows?.[id]) return c.json({ error: "Workflow not found" }, 404); + + _syncServer!.changeDoc(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, + isVirtual: false, + virtualUrl: null, + virtualPlatform: null, + rToolSource: "rSchedule", + rToolEntityId: node.id, + attendees: [], + attendeeCount: 0, + metadata: null, + createdAt: now, + updatedAt: now, + }; + }); + return { success: true, message: `Event created: ${cfg.title || "Automation Event"}`, outputData: { eventId } }; + } + + case "action-create-task": + return { success: true, message: `Task "${cfg.title || "New task"}" queued`, outputData: { taskTitle: cfg.title } }; + + case "action-send-notification": + console.log(`[Automation] Notification: ${cfg.title || "Notification"} — ${cfg.message || ""}`); + return { success: true, message: `Notification: ${cfg.title}` }; + + case "action-update-data": + return { success: true, message: `Data update queued for ${cfg.module || "unknown"}` }; + + default: + return { success: false, message: `Unknown node type: ${node.type}` }; + } +} + +async function executeWorkflow( + workflow: Workflow, + space: string, + triggerData?: unknown, +): Promise { + 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; + } + + try { + const result = await executeWorkflowNode(node, inputData, space); + const durationMs = Date.now() - startMs; + nodeOutputs.set(node.id, result.outputData); + results.push({ + nodeId: node.id, + status: result.success ? "success" : "error", + message: result.message, + durationMs, + outputData: result.outputData, + }); + } catch (e: any) { + results.push({ + nodeId: node.id, + status: "error", + message: e.message || String(e), + durationMs: Date.now() - startMs, + }); + } + } + + return results; +} + +// POST /api/workflows/:id/run — manual execute +routes.post("/api/workflows/:id/run", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const id = c.req.param("id"); + + const docId = scheduleDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const wf = doc.workflows?.[id]; + if (!wf) return c.json({ error: "Workflow not found" }, 404); + + const results = await executeWorkflow(wf, dataSpace); + + const allOk = results.every(r => r.status !== "error"); + _syncServer!.changeDoc(docId, `run workflow ${id}`, (d) => { + const w = d.workflows[id]; + if (!w) return; + w.lastRunAt = Date.now(); + w.lastRunStatus = allOk ? "success" : "error"; + w.runCount = (w.runCount || 0) + 1; + w.updatedAt = Date.now(); + }); + + return c.json({ success: allOk, results }); +}); + +// POST /api/workflows/webhook/:hookId — external webhook trigger +routes.post("/api/workflows/webhook/:hookId", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const hookId = c.req.param("hookId"); + + const doc = ensureDoc(dataSpace); + let payload: unknown = {}; + try { payload = await c.req.json(); } catch { /* empty payload */ } + + // Find workflows with a trigger-webhook node matching this hookId + const matches: Workflow[] = []; + for (const wf of Object.values(doc.workflows || {})) { + if (!wf.enabled) continue; + for (const node of wf.nodes) { + if (node.type === "trigger-webhook" && node.config.hookId === hookId) { + matches.push(wf); + break; + } + } + } + + if (matches.length === 0) return c.json({ error: "No matching workflow" }, 404); + + const allResults: { workflowId: string; results: NodeResult[] }[] = []; + for (const wf of matches) { + const results = await executeWorkflow(wf, dataSpace, payload); + allResults.push({ workflowId: wf.id, results }); + } + + return c.json({ triggered: matches.length, results: allResults }); +}); + +// ── Demo workflow seeds ── + +function seedDemoWorkflows(space: string) { + const docId = scheduleDocId(space); + const doc = ensureDoc(space); + + if (Object.keys(doc.workflows || {}).length > 0) return; + + const now = Date.now(); + + const demo1Id = "demo-arriving-home"; + const demo1: Workflow = { + id: demo1Id, + name: "Arriving Home Notification", + enabled: false, + nodes: [ + { id: "n1", type: "trigger-proximity", label: "Location Proximity", position: { x: 50, y: 100 }, config: { lat: "49.2827", lng: "-123.1207", radiusKm: "1" } }, + { id: "n2", type: "condition-geofence", label: "Geofence Check", position: { x: 350, y: 100 }, config: { centerLat: "49.2827", centerLng: "-123.1207", radiusKm: "2" } }, + { id: "n3", type: "action-send-email", label: "Notify Family", position: { x: 650, y: 80 }, config: { to: "family@example.com", subject: "Almost home!", bodyTemplate: "

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 = { @@ -1339,6 +1939,7 @@ export const scheduleModule: RSpaceModule = { async onInit(ctx) { _syncServer = ctx.syncServer; seedDefaultJobs("demo"); + seedDemoWorkflows("demo"); startTickLoop(); }, feeds: [ @@ -1353,6 +1954,7 @@ export const scheduleModule: RSpaceModule = { outputPaths: [ { path: "jobs", name: "Jobs", icon: "⏱", description: "Scheduled jobs and their configurations" }, { path: "reminders", name: "Reminders", icon: "🔔", description: "Scheduled reminders with email notifications" }, + { path: "workflows", name: "Automations", icon: "🔀", description: "Visual automation workflows with triggers, conditions, and actions" }, { path: "log", name: "Execution Log", icon: "📋", description: "History of job executions" }, ], }; diff --git a/modules/rschedule/schemas.ts b/modules/rschedule/schemas.ts index 627b4c8..649a2f2 100644 --- a/modules/rschedule/schemas.ts +++ b/modules/rschedule/schemas.ts @@ -75,6 +75,285 @@ export interface Reminder { updatedAt: number; } +// ── Workflow / Automation types ── + +export type AutomationNodeType = + // Triggers + | 'trigger-cron' + | 'trigger-data-change' + | 'trigger-webhook' + | 'trigger-manual' + | 'trigger-proximity' + // Conditions + | 'condition-compare' + | 'condition-geofence' + | 'condition-time-window' + | 'condition-data-filter' + // Actions + | 'action-send-email' + | 'action-post-webhook' + | 'action-create-event' + | 'action-create-task' + | 'action-send-notification' + | 'action-update-data'; + +export type AutomationNodeCategory = 'trigger' | 'condition' | 'action'; + +export interface WorkflowNodePort { + name: string; + type: 'trigger' | 'data' | 'boolean'; +} + +export interface WorkflowNode { + id: string; + type: AutomationNodeType; + label: string; + position: { x: number; y: number }; + config: Record; + // Runtime state (not persisted to Automerge — set during execution) + runtimeStatus?: 'idle' | 'running' | 'success' | 'error'; + runtimeMessage?: string; + runtimeDurationMs?: number; +} + +export interface WorkflowEdge { + id: string; + fromNode: string; + fromPort: string; + toNode: string; + toPort: string; +} + +export interface Workflow { + id: string; + name: string; + enabled: boolean; + nodes: WorkflowNode[]; + edges: WorkflowEdge[]; + lastRunAt: number | null; + lastRunStatus: 'success' | 'error' | null; + runCount: number; + createdAt: number; + updatedAt: number; +} + +export interface AutomationNodeDef { + type: AutomationNodeType; + category: AutomationNodeCategory; + label: string; + icon: string; + description: string; + inputs: WorkflowNodePort[]; + outputs: WorkflowNodePort[]; + configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[]; +} + +export const NODE_CATALOG: AutomationNodeDef[] = [ + // ── Triggers ── + { + type: 'trigger-cron', + category: 'trigger', + label: 'Cron Schedule', + icon: '⏰', + description: 'Fire on a cron schedule', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }], + configSchema: [ + { key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' }, + { key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' }, + ], + }, + { + type: 'trigger-data-change', + category: 'trigger', + label: 'Data Change', + icon: '📊', + description: 'Fire when data changes in any rApp', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] }, + { key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' }, + ], + }, + { + type: 'trigger-webhook', + category: 'trigger', + label: 'Webhook Incoming', + icon: '🔗', + description: 'Fire when an external webhook is received', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }], + configSchema: [ + { key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' }, + ], + }, + { + type: 'trigger-manual', + category: 'trigger', + label: 'Manual Trigger', + icon: '👆', + description: 'Fire manually via button click', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }], + configSchema: [], + }, + { + type: 'trigger-proximity', + category: 'trigger', + label: 'Location Proximity', + icon: '📍', + description: 'Fire when a location approaches a point', + inputs: [], + outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }], + configSchema: [ + { key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' }, + { key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' }, + ], + }, + // ── Conditions ── + { + type: 'condition-compare', + category: 'condition', + label: 'Compare Values', + icon: '⚖️', + description: 'Compare two values', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }], + outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }], + configSchema: [ + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] }, + { key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' }, + ], + }, + { + type: 'condition-geofence', + category: 'condition', + label: 'Geofence Check', + icon: '🗺️', + description: 'Check if coordinates are within a radius', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }], + outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' }, + { key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' }, + { key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' }, + ], + }, + { + type: 'condition-time-window', + category: 'condition', + label: 'Time Window', + icon: '🕐', + description: 'Check if current time is within a window', + inputs: [{ name: 'trigger', type: 'trigger' }], + outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }], + configSchema: [ + { key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' }, + { key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' }, + { key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' }, + ], + }, + { + type: 'condition-data-filter', + category: 'condition', + label: 'Data Filter', + icon: '🔍', + description: 'Filter data by field value', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }], + configSchema: [ + { key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' }, + { key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] }, + { key: 'value', label: 'Value', type: 'text', placeholder: 'completed' }, + ], + }, + // ── Actions ── + { + type: 'action-send-email', + category: 'action', + label: 'Send Email', + icon: '📧', + description: 'Send an email via SMTP', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' }, + { key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' }, + { key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '

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 rWork', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }], + configSchema: [ + { key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' }, + { key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' }, + { key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] }, + ], + }, + { + type: 'action-send-notification', + category: 'action', + label: 'Send Notification', + icon: '🔔', + description: 'Send an in-app notification', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }], + configSchema: [ + { key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' }, + { key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' }, + { key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] }, + ], + }, + { + type: 'action-update-data', + category: 'action', + label: 'Update Data', + icon: '💾', + description: 'Update data in an rApp document', + inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }], + outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }], + configSchema: [ + { key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rwork', 'rcal', 'rnetwork'] }, + { key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] }, + { key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' }, + ], + }, +]; + export interface ScheduleDoc { meta: { module: string; @@ -85,6 +364,7 @@ export interface ScheduleDoc { }; jobs: Record; reminders: Record; + workflows: Record; log: ExecutionLogEntry[]; } @@ -104,6 +384,7 @@ export const scheduleSchema: DocSchema = { }, jobs: {}, reminders: {}, + workflows: {}, log: [], }), }; diff --git a/vite.config.ts b/vite.config.ts index 7c2abd8..2aeebc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -526,6 +526,24 @@ export default defineConfig({ resolve(__dirname, "dist/modules/rtrips/trips.css"), ); + // Build trips demo page script + await build({ + configFile: false, + root: resolve(__dirname, "modules/rtrips/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rtrips"), + lib: { + entry: resolve(__dirname, "modules/rtrips/components/trips-demo.ts"), + formats: ["es"], + fileName: () => "trips-demo.js", + }, + rollupOptions: { + output: { entryFileNames: "trips-demo.js" }, + }, + }, + }); + // Build cal module component await build({ configFile: false, @@ -916,6 +934,37 @@ export default defineConfig({ }, }); + // Build schedule automation canvas component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rschedule/components"), + resolve: { + alias: { + "../schemas": resolve(__dirname, "modules/rschedule/schemas.ts"), + }, + }, + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rschedule"), + lib: { + entry: resolve(__dirname, "modules/rschedule/components/folk-automation-canvas.ts"), + formats: ["es"], + fileName: () => "folk-automation-canvas.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-automation-canvas.js", + }, + }, + }, + }); + + // Copy automation canvas CSS + copyFileSync( + resolve(__dirname, "modules/rschedule/components/automation-canvas.css"), + resolve(__dirname, "dist/modules/rschedule/automation-canvas.css"), + ); + // ── Demo infrastructure ── // Build demo-sync-vanilla library From f22bc4781c3a436aea219d4034ae189a2bd7e82a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 11:42:30 -0700 Subject: [PATCH 3/3] feat(rtrips): wire up demo dashboard with 6-card live grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /demo route, Vite build entry for trips-demo.ts, and demo page CSS (hero, toolbar, 2×3 card grid, calendar, polls, funds, cart). Co-Authored-By: Claude Opus 4.6 --- modules/rtrips/components/trips.css | 303 ++++++++++++++++++++++++++++ modules/rtrips/mod.ts | 136 +++++++++++++ 2 files changed, 439 insertions(+) diff --git a/modules/rtrips/components/trips.css b/modules/rtrips/components/trips.css index 9c2ef99..462a3e7 100644 --- a/modules/rtrips/components/trips.css +++ b/modules/rtrips/components/trips.css @@ -4,3 +4,306 @@ folk-trips-planner { min-height: 400px; padding: 20px; } + +/* ── Demo page layout ── */ +.rd-page { + max-width: 960px; + margin: 0 auto; + padding: 2rem 1.5rem 4rem; +} + +.rd-hero { + text-align: center; + margin-bottom: 2rem; +} +.rd-hero h1 { + font-size: 2rem; + font-weight: 700; + background: linear-gradient(to right, #14b8a6, #06b6d4); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.rd-hero-route { + color: #94a3b8; + font-size: 1rem; + margin: 0.25rem 0; +} +.rd-hero-meta { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + font-size: 0.8125rem; + color: #94a3b8; + margin-top: 0.5rem; +} +.rd-hero-avatars { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + margin-top: 0.75rem; +} +.rd-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: white; + border: 2px solid #0f172a; + margin-left: -4px; +} +.rd-avatar:first-child { margin-left: 0; } + +/* ── Toolbar ── */ +.rd-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} +.rd-status { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.03em; +} +.rd-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #64748b; +} +.rd-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.875rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + background: transparent; + border: 1px solid #334155; + color: #cbd5e1; + cursor: pointer; + transition: all 0.15s; +} +.rd-btn:hover:not(:disabled) { border-color: #14b8a6; color: #14b8a6; } +.rd-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Card grid ── */ +.rd-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.25rem; +} +.rd-card { + background: #0f172a; + border: 1px solid #1e293b; + border-radius: 0.75rem; + overflow: hidden; + transition: border-color 0.2s; +} +.rd-card:hover { border-color: rgba(20, 184, 166, 0.35); } +.rd-card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1.25rem 0.4rem; + gap: 0.75rem; +} +.rd-card-title { + font-size: 0.875rem; + font-weight: 600; + color: #e2e8f0; +} +.rd-card-live { + display: none; + align-items: center; + gap: 0.25rem; + font-size: 0.625rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #34d399; +} +.rd-card-live::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: #34d399; + animation: rd-pulse 2s ease-in-out infinite; +} +.rd-card-body { + padding: 0.5rem 1.25rem 1.25rem; +} + +/* ── Map SVG ── */ +.rd-map-svg { + width: 100%; + height: auto; + display: block; +} + +/* ── Packing list ── */ +.rd-trips-pack-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0; + cursor: pointer; + font-size: 0.8125rem; +} +.rd-trips-pack-item:hover { background: rgba(30, 41, 59, 0.5); border-radius: 0.25rem; } +.rd-trips-pack-check { + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid #334155; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.15s; +} +.rd-trips-pack-check--checked { + background: #14b8a6; + border-color: #14b8a6; +} + +/* ── Calendar ── */ +.rd-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} +.rd-cal-day-name { + font-size: 0.625rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + text-align: center; + padding: 0.25rem 0; +} +.rd-cal-cell { + min-height: 3.5rem; + padding: 0.25rem; + border-radius: 0.25rem; + display: flex; + flex-direction: column; + gap: 2px; +} +.rd-cal-cell--trip { background: rgba(20, 184, 166, 0.06); } +.rd-cal-cell--empty { background: transparent; } +.rd-cal-cell-num { + font-size: 0.6875rem; + font-weight: 500; +} +.rd-cal-cell-num--trip { color: #e2e8f0; } +.rd-cal-cell-num--off { color: #475569; } +.rd-cal-event { + font-size: 0.5625rem; + padding: 1px 4px; + border-radius: 3px; + color: white; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Poll bars ── */ +.rd-trips-poll-bar-bg { + height: 0.375rem; + background: rgba(51, 65, 85, 0.7); + border-radius: 9999px; + overflow: hidden; +} +.rd-trips-poll-bar { + height: 100%; + border-radius: 9999px; + transition: width 0.3s; +} + +/* ── Funds ── */ +.rd-trips-sub-heading { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + margin: 0 0 0.5rem; +} + +/* ── Cart items ── */ +.rd-trips-cart-item { + padding: 0.5rem; + background: rgba(30, 41, 59, 0.4); + border-radius: 0.5rem; +} +.rd-trips-cart-bar-bg { + height: 0.375rem; + background: rgba(51, 65, 85, 0.7); + border-radius: 9999px; + overflow: hidden; +} +.rd-trips-cart-bar { + height: 100%; + border-radius: 9999px; + transition: width 0.3s; +} + +/* ── Skeleton placeholders ── */ +.rd-skeleton { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.rd-skeleton-line { + height: 0.75rem; + background: rgba(51, 65, 85, 0.5); + border-radius: 0.25rem; + animation: rd-pulse 2s ease-in-out infinite; +} + +/* ── Footer ── */ +.rd-footer { + text-align: center; + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid #1e293b; +} +.rd-footer a { + color: #64748b; + text-decoration: none; + font-size: 0.85rem; +} +.rd-footer a:hover { color: #14b8a6; } + +/* ── Animations ── */ +@keyframes rd-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Responsive ── */ +@media (max-width: 768px) { + .rd-grid { grid-template-columns: 1fr; } + .rd-page { padding: 1rem 0.75rem 3rem; } + .rd-hero h1 { font-size: 1.5rem; } +} +@media (max-width: 480px) { + .rd-hero-meta { flex-direction: column; gap: 0.25rem; } + .rd-card-header { padding: 0.6rem 1rem 0.3rem; } + .rd-card-body { padding: 0.4rem 1rem 1rem; } +} diff --git a/modules/rtrips/mod.ts b/modules/rtrips/mod.ts index 599cf44..fd0a599 100644 --- a/modules/rtrips/mod.ts +++ b/modules/rtrips/mod.ts @@ -447,6 +447,142 @@ routes.get("/routes", (c) => { })); }); +// ── Demo dashboard ── +routes.get("/demo", (c) => { + return c.html(renderShell({ + title: "rTrips Demo — Live Trip Dashboard | rSpace", + moduleId: "rtrips", + spaceSlug: "demo", + modules: getModuleInfoList(), + theme: "dark", + styles: ``, + body: ` +
+
+

Alpine Explorer 2026

+

Loading route…

+
+ 📅 Jul 6–20, 2026 + 💶 ~€4,000 budget + 🏔️ 3 countries +
+
+
+ +
+ + + Connecting… + + + ← About rTrips +
+ +
+ +
+
+ 🗺️ Maps + Live +
+
+ + + + + +
+
+ + +
+
+ 📝 Packing List + Live +
+
+
+
+
+
+
+
+
+ + +
+
+ 📅 Calendar + Live + +
+
+
+
+
+
+
+
+
+ + +
+
+ 🗳️ Polls + Live +
+
+
+
+
+
+
+
+
+ + +
+
+ 💶 Funds + Live + €0 +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ 🛒 Cart + Live +
+
+
+
+
+
+
+ +
+
+
+ + +
`, + scripts: ``, + })); +}); + // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo";