feat(rsocials): newsletter drafts, subscribers + MI route updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 20:11:31 -07:00
parent 2eac542e19
commit 77ac0c1e32
4 changed files with 380 additions and 34 deletions

View File

@ -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<SocialsDoc>(docId, "add newsletterDrafts", (d) => {
d.newsletterDrafts = {} as any;
});
doc = _syncServer!.getDoc<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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<SocialsDoc>(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) => {
<p>Plan and manage multi-platform social media campaigns</p>
</div>
</a>
<a href="${base}/campaign-wizard">
<span class="nav-icon">🧙</span>
<div class="nav-body">
<h3>Campaign Wizard</h3>
<p>AI-guided step-by-step campaign creation with progressive approval</p>
</div>
</a>
<a href="${base}/threads">
<span class="nav-icon">🧵</span>
<div class="nav-body">
@ -2095,11 +2212,11 @@ routes.get("/", (c) => {
<p>Browse draft posts, compose threads, and schedule content with live preview</p>
</div>
</a>
<a href="${base}/newsletter-list">
<a href="${base}/newsletter">
<span class="nav-icon">📧</span>
<div class="nav-body">
<h3>Newsletter List</h3>
<p>Manage newsletter subscribers and send campaigns via Listmonk</p>
<h3>Newsletter</h3>
<p>Draft newsletters, manage subscribers, and send campaigns</p>
</div>
</a>
</nav>
@ -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." },
],
},
],

View File

@ -447,6 +447,7 @@ export interface SocialsDoc {
campaignWorkflows: Record<string, CampaignWorkflow>;
pendingApprovals: Record<string, PendingApproval>;
campaignWizards: Record<string, CampaignWizard>;
newsletterDrafts: Record<string, NewsletterDraft>;
}
// ── Schema registration ──
@ -454,12 +455,12 @@ export interface SocialsDoc {
export const socialsSchema: DocSchema<SocialsDoc> = {
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<SocialsDoc> = {
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<SocialsDoc> = {
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;
},
};

View File

@ -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(

View File

@ -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<typeof setInterval> | 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 {
<button class="mi-panel-btn" id="mi-close" title="Close">&times;</button>
</div>
<div class="mi-messages" id="mi-messages">
<div class="mi-welcome">
<div class="mi-welcome" id="mi-welcome">
<span class="mi-welcome-icon">&#10023;</span>
<p>Hi, I'm <strong>mi</strong> your mycelial intelligence guide.</p>
<p class="mi-welcome-sub">I can create content across all rApps, set up spaces, connect knowledge, and help you build.</p>
<div class="mi-suggestions" id="mi-suggestions"></div>
</div>
</div>
<div class="mi-confirm" id="mi-confirm" style="display:none;">
@ -189,6 +198,7 @@ export class RStackMi extends HTMLElement {
barInput.addEventListener("focus", () => {
panel.classList.add("open");
bar.classList.add("focused");
this.#loadSuggestions();
// Transfer any text to the panel input
if (barInput.value.trim()) {
input.value = barInput.value;
@ -266,6 +276,7 @@ export class RStackMi extends HTMLElement {
panel.classList.add("open");
panel.classList.remove("hidden");
bar.classList.add("focused");
this.#loadSuggestions();
input.focus();
});
@ -320,6 +331,113 @@ export class RStackMi extends HTMLElement {
}
}
#loadSuggestions() {
const ctx = this.#gatherContext();
this.#suggestions = getContextSuggestions({
module: ctx.module || "",
hasShapes: !!(ctx.openShapes?.length),
hasSelectedShapes: !!(ctx.selectedShapes?.length),
});
this.#renderSuggestions();
this.#rotatePlaceholder();
// Fetch dynamic suggestions (non-blocking)
this.#fetchDynamicSuggestions(ctx.space, ctx.module);
}
async #fetchDynamicSuggestions(space: string, module: string) {
if (!space) return;
try {
const token = getAccessToken();
const res = await fetch("/api/mi/suggestions", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ space, module }),
});
if (res.ok) {
const data = await res.json();
this.#dynamicSuggestions = data.suggestions || [];
this.#renderSuggestions();
}
} catch { /* offline — skip dynamic suggestions */ }
}
#renderSuggestions() {
const container = this.#shadow.getElementById("mi-suggestions");
if (!container) return;
// Don't show suggestions once a conversation has started
if (this.#messages.length > 0) {
container.innerHTML = "";
return;
}
const allSuggestions = [...this.#suggestions];
const hasDynamic = this.#dynamicSuggestions.length > 0;
let html = allSuggestions
.map((s) => `<button class="mi-suggest-chip" data-prompt="${this.#escapeAttr(s.prompt)}" data-autosend="${s.autoSend !== false}">${s.icon} ${this.#escapeHtml(s.label)}</button>`)
.join("");
if (hasDynamic) {
html += `<div class="mi-suggest-section">For you</div>`;
html += this.#dynamicSuggestions
.map((s) => `<button class="mi-suggest-chip dynamic" data-prompt="${this.#escapeAttr(s.prompt)}" data-autosend="${s.autoSend !== false}">${s.icon} ${this.#escapeHtml(s.label)}</button>`)
.join("");
}
container.innerHTML = html;
// Wire chip clicks
container.querySelectorAll<HTMLButtonElement>(".mi-suggest-chip").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const prompt = btn.dataset.prompt || "";
const autoSend = btn.dataset.autosend === "true";
if (autoSend) {
this.#ask(prompt);
} else {
const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null;
if (input) {
input.value = prompt;
input.focus();
this.#autoResize(input);
}
}
});
});
}
#escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
#rotatePlaceholder() {
const barInput = this.#shadow.getElementById("mi-bar-input") as HTMLInputElement | null;
if (!barInput) return;
const allSuggestions = [...this.#suggestions, ...this.#dynamicSuggestions];
if (allSuggestions.length === 0) return;
// Set initial placeholder from a random suggestion
this.#placeholderIdx = Math.floor(Math.random() * allSuggestions.length);
const hint = allSuggestions[this.#placeholderIdx];
barInput.placeholder = `Ask mi anything... try "${hint.label}"`;
// Rotate every 8 seconds
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
this.#placeholderTimer = setInterval(() => {
const current = [...this.#suggestions, ...this.#dynamicSuggestions];
if (current.length === 0) return;
this.#placeholderIdx = (this.#placeholderIdx + 1) % current.length;
const s = current[this.#placeholderIdx];
barInput.placeholder = `Ask mi anything... try "${s.label}"`;
}, 8000);
}
#minimize() {
this.#minimized = true;
const panel = this.#shadow.getElementById("mi-panel")!;
@ -804,6 +922,35 @@ const STYLES = `
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; color: var(--rs-text-primary); }
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
/* ── Suggestion chips ── */
.mi-suggestions {
display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 0 4px;
justify-content: center;
}
.mi-suggest-chip {
padding: 6px 12px; border-radius: 16px; border: 1px solid var(--rs-border);
font-size: 0.78rem; cursor: pointer; transition: all 0.15s;
font-family: inherit; white-space: nowrap;
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
}
.mi-suggest-chip:hover {
background: var(--rs-bg-hover);
border-color: rgba(6,182,212,0.4);
box-shadow: 0 0 0 1px rgba(6,182,212,0.15);
}
.mi-suggest-chip.dynamic {
border-color: rgba(124,58,237,0.3);
}
.mi-suggest-chip.dynamic:hover {
border-color: rgba(124,58,237,0.5);
box-shadow: 0 0 0 1px rgba(124,58,237,0.15);
}
.mi-suggest-section {
width: 100%; font-size: 0.65rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--rs-text-muted); margin-top: 4px;
}
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.mi-msg--user .mi-msg-who { color: #06b6d4; }