From 77ac0c1e32be8fc95b2f8c3656f7e45ec14512b4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 20:11:31 -0700 Subject: [PATCH] feat(rsocials): newsletter drafts, subscribers + MI route updates Co-Authored-By: Claude Opus 4.6 --- modules/rsocials/mod.ts | 165 +++++++++++++++++++++++++++------ modules/rsocials/schemas.ts | 9 +- server/mi-routes.ts | 91 ++++++++++++++++++ shared/components/rstack-mi.ts | 149 ++++++++++++++++++++++++++++- 4 files changed, 380 insertions(+), 34 deletions(-) diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 337bf65..b0926a9 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module"; import type { SyncServer } from "../../server/local-first/sync-server"; import { renderLanding } from "./landing"; import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data"; -import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard } from "./schemas"; +import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft } from "./schemas"; import { generateImageFromPrompt, downloadAndSaveImage, @@ -58,9 +58,18 @@ function ensureDoc(space: string): SocialsDoc { d.campaignWorkflows = {}; d.pendingApprovals = {}; d.campaignWizards = {}; + d.newsletterDrafts = {}; }); _syncServer!.setDoc(docId, doc); } + // Migrate existing docs missing newsletterDrafts + if (!doc.newsletterDrafts) { + const docId = socialsDocId(space); + _syncServer!.changeDoc(docId, "add newsletterDrafts", (d) => { + d.newsletterDrafts = {} as any; + }); + doc = _syncServer!.getDoc(docId)!; + } return doc; } @@ -563,6 +572,112 @@ routes.delete("/api/newsletter/campaigns/:id", async (c) => { return c.json(data, res.status as any); }); +// ── Newsletter Drafts (Automerge-backed) ── + +routes.get("/api/newsletter/drafts", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const drafts = Object.values(doc.newsletterDrafts || {}).sort((a, b) => b.updatedAt - a.updatedAt); + return c.json({ drafts }); +}); + +routes.post("/api/newsletter/drafts", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const body = await c.req.json<{ title?: string; subject?: string; body?: string }>(); + if (!body.title || !body.subject) return c.json({ error: "title and subject required" }, 400); + + const id = crypto.randomUUID(); + const now = Date.now(); + const draft: NewsletterDraft = { + id, + title: body.title, + subject: body.subject, + body: body.body || '', + status: 'draft', + subscribers: [], + createdAt: now, + updatedAt: now, + createdBy: auth.claims.did as string, + }; + + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `create newsletter draft ${id}`, (d) => { + if (!d.newsletterDrafts) d.newsletterDrafts = {} as any; + d.newsletterDrafts[id] = draft; + }); + return c.json({ draft }, 201); +}); + +routes.get("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "moderator"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + const draft = doc.newsletterDrafts?.[draftId]; + if (!draft) return c.json({ error: "Draft not found" }, 404); + return c.json({ draft }); +}); + +routes.put("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404); + + const body = await c.req.json<{ title?: string; subject?: string; body?: string; status?: string; subscribers?: { email: string; name?: string }[] }>(); + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `update newsletter draft ${draftId}`, (d) => { + const draft = d.newsletterDrafts[draftId]; + if (body.title !== undefined) draft.title = body.title; + if (body.subject !== undefined) draft.subject = body.subject; + if (body.body !== undefined) draft.body = body.body; + if (body.status === 'draft' || body.status === 'ready' || body.status === 'sent') draft.status = body.status; + if (body.subscribers) { + draft.subscribers = body.subscribers.map(s => ({ + email: s.email, + name: s.name || '', + addedAt: Date.now(), + })) as any; + } + draft.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json({ draft: updated.newsletterDrafts[draftId] }); +}); + +routes.delete("/api/newsletter/drafts/:draftId", async (c) => { + const auth = await requireNewsletterRole(c, "admin"); + if (auth instanceof Response) return auth; + + const space = c.req.param("space") || "demo"; + const dataSpace = (c as any).get("effectiveSpace") || space; + const draftId = c.req.param("draftId"); + const doc = ensureDoc(dataSpace); + if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404); + + const docId = socialsDocId(dataSpace); + _syncServer!.changeDoc(docId, `delete newsletter draft ${draftId}`, (d) => { + delete d.newsletterDrafts[draftId]; + }); + return c.json({ ok: true }); +}); + // ── Newsletter Approval Bridge ── routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => { @@ -1987,7 +2102,7 @@ routes.get("/scheduler", (c) => { })); }); -routes.get("/newsletter-list", async (c) => { +routes.get("/newsletter", async (c) => { const space = c.req.param("space") || "demo"; // Resolve caller role for UI gating (viewer fallback for unauthenticated) @@ -2013,6 +2128,15 @@ routes.get("/newsletter-list", async (c) => { })); }); +// Legacy redirect +routes.get("/newsletter-list", (c) => { + const space = c.req.param("space") || "demo"; + const host = c.req.header("host")?.split(":")[0] || ""; + const isSubdomain = (host.endsWith(".rspace.online") && host !== "rspace.online" && !host.startsWith("www.")) || host.endsWith(".rsocials.online"); + const base = isSubdomain ? "/rsocials" : `/${space}/rsocials`; + return c.redirect(`${base}/newsletter`, 301); +}); + routes.get("/feed", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; @@ -2081,13 +2205,6 @@ routes.get("/", (c) => {

Plan and manage multi-platform social media campaigns

- - 🧙 - - 🧵 - + 📧 @@ -2160,18 +2277,6 @@ export const socialsModule: RSpaceModule = { { icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." }, ], }, - { - path: "campaign-wizard", - title: "Campaign Wizard", - icon: "\uD83E\uDDD9", - tagline: "rSocials Tool", - description: "AI-guided step-by-step campaign creation. Paste a brief, review the proposed structure, approve generated content, and activate everything at once.", - features: [ - { icon: "\uD83E\uDD16", title: "AI Analysis", text: "AI extracts audience, platforms, tone, and key messages from your raw brief." }, - { icon: "\uD83D\uDCCB", title: "Progressive Approval", text: "Review and approve each step: structure, content, then full review before committing." }, - { icon: "\u26A1", title: "One-Click Activate", text: "Commits campaign, creates threads, drafts newsletters, and builds workflow DAG simultaneously." }, - ], - }, { path: "threads", title: "Posts & Threads", @@ -2185,15 +2290,15 @@ export const socialsModule: RSpaceModule = { ], }, { - path: "newsletter-list", - title: "Newsletter List", + path: "newsletter", + title: "Newsletter", icon: "📧", tagline: "rSocials Tool", - description: "Manage newsletter subscribers and send email campaigns via the embedded Listmonk interface.", + description: "Draft newsletters collaboratively, manage subscriber lists, and send email campaigns. Works standalone or with Listmonk integration.", features: [ - { icon: "👥", title: "Subscriber Management", text: "View, import, and manage newsletter subscribers across multiple mailing lists." }, - { icon: "📨", title: "Email Campaigns", text: "Compose and send email campaigns with templates and scheduling." }, - { icon: "📊", title: "Analytics", text: "Track open rates, click-throughs, and subscriber engagement." }, + { icon: "✍️", title: "Draft Editor", text: "Compose newsletter drafts with HTML editor, live preview, and collaborative editing." }, + { icon: "👥", title: "Subscriber Management", text: "Build and manage subscriber lists directly, or import from Listmonk." }, + { icon: "📨", title: "Send Campaigns", text: "Send newsletters via Listmonk when configured, or manage drafts independently." }, ], }, ], diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index fc21c52..6ffad31 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -447,6 +447,7 @@ export interface SocialsDoc { campaignWorkflows: Record; pendingApprovals: Record; campaignWizards: Record; + newsletterDrafts: Record; } // ── Schema registration ── @@ -454,12 +455,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { module: 'socials', collection: 'data', - version: 6, + version: 7, init: (): SocialsDoc => ({ meta: { module: 'socials', collection: 'data', - version: 6, + version: 7, spaceSlug: '', createdAt: Date.now(), }, @@ -470,6 +471,7 @@ export const socialsSchema: DocSchema = { campaignWorkflows: {}, pendingApprovals: {}, campaignWizards: {}, + newsletterDrafts: {}, }), migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => { if (!doc.campaignFlows) (doc as any).campaignFlows = {}; @@ -477,7 +479,8 @@ export const socialsSchema: DocSchema = { if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {}; if (!doc.pendingApprovals) (doc as any).pendingApprovals = {}; if (!doc.campaignWizards) (doc as any).campaignWizards = {}; - if (doc.meta) doc.meta.version = 6; + if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {}; + if (doc.meta) doc.meta.version = 7; return doc; }, }; diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 2269fc5..53eb64a 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -500,6 +500,97 @@ mi.post("/execute-server-action", async (c) => { } }); +// ── POST /suggestions — dynamic data-driven suggestions ── + +mi.post("/suggestions", async (c) => { + const { space, module: currentModule } = await c.req.json(); + const suggestions: { label: string; icon: string; prompt: string; autoSend?: boolean }[] = []; + + if (!space) return c.json({ suggestions }); + + try { + // Check upcoming events + const upcoming = getUpcomingEventsForMI(space, 1, 3); + if (upcoming.length > 0) { + const next = upcoming[0]; + const startMs = Date.parse(next.start); + const hoursUntil = Math.round((startMs - Date.now()) / 3600000); + if (hoursUntil > 0 && hoursUntil <= 24) { + const timeLabel = hoursUntil === 1 ? "1 hour" : `${hoursUntil} hours`; + suggestions.push({ + label: `${next.title} in ${timeLabel}`, + icon: "⏰", + prompt: `Tell me about the upcoming event "${next.title}"`, + autoSend: true, + }); + } + } + + // Check open tasks + const tasks = getRecentTasksForMI(space, 10); + const openTasks = tasks.filter((t) => t.status !== "DONE"); + if (openTasks.length > 0) { + suggestions.push({ + label: `${openTasks.length} open task${openTasks.length > 1 ? "s" : ""}`, + icon: "📋", + prompt: "Show my open tasks", + autoSend: true, + }); + } + + // Check if current module has zero content — "get started" suggestion + if (currentModule === "rnotes") { + const notes = getRecentNotesForMI(space, 1); + if (notes.length === 0) { + suggestions.push({ + label: "Create your first note", + icon: "📝", + prompt: "Help me create my first notebook", + autoSend: true, + }); + } + } else if (currentModule === "rtasks") { + const t = getRecentTasksForMI(space, 1); + if (t.length === 0) { + suggestions.push({ + label: "Create your first task", + icon: "✅", + prompt: "Help me create my first task board", + autoSend: true, + }); + } + } else if (currentModule === "rcal") { + const ev = getUpcomingEventsForMI(space, 30, 1); + if (ev.length === 0) { + suggestions.push({ + label: "Add your first event", + icon: "📅", + prompt: "Help me create my first calendar event", + autoSend: true, + }); + } + } + + // Recent note to continue editing + if (currentModule === "rnotes") { + const recent = getRecentNotesForMI(space, 1); + if (recent.length > 0) { + suggestions.push({ + label: `Continue "${recent[0].title}"`, + icon: "📝", + prompt: `Help me continue working on "${recent[0].title}"`, + autoSend: true, + }); + } + } + } catch (e: any) { + console.error("[mi/suggestions]", e.message); + } + + // Max 3 dynamic suggestions + return c.json({ suggestions: suggestions.slice(0, 3) }); +}); + // ── Fallback response (when AI is unavailable) ── function generateFallbackResponse( diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index 4e675a0..cf3881a 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -11,6 +11,8 @@ import type { MiAction } from "../../lib/mi-actions"; import { MiActionExecutor } from "../../lib/mi-action-executor"; import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema"; import { SpeechDictation } from "../../lib/speech-dictation"; +import { getContextSuggestions } from "../../lib/mi-suggestions"; +import type { MiSuggestion } from "../../lib/mi-suggestions"; interface MiMessage { role: "user" | "assistant"; @@ -39,6 +41,10 @@ export class RStackMi extends HTMLElement { #availableModels: MiModelConfig[] = []; #pendingConfirm: { actions: MiAction[]; resolve: (ok: boolean) => void } | null = null; #scaffoldProgress: { current: number; total: number; label: string } | null = null; + #suggestions: MiSuggestion[] = []; + #dynamicSuggestions: MiSuggestion[] = []; + #placeholderIdx = 0; + #placeholderTimer: ReturnType | null = null; constructor() { super(); @@ -54,6 +60,7 @@ export class RStackMi extends HTMLElement { disconnectedCallback() { document.removeEventListener("keydown", this.#keyHandler); + if (this.#placeholderTimer) clearInterval(this.#placeholderTimer); } #keyHandler = (e: KeyboardEvent) => { @@ -73,6 +80,7 @@ export class RStackMi extends HTMLElement { panel?.classList.add("open"); bar?.classList.add("focused"); } + this.#loadSuggestions(); const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null; input?.focus(); } @@ -148,10 +156,11 @@ export class RStackMi extends HTMLElement {
-
+

Hi, I'm mi — your mycelial intelligence guide.

I can create content across all rApps, set up spaces, connect knowledge, and help you build.

+