feat(rsocials): merge Posts & Threads nav + Postiz API integration
Rename "Threads" to "Posts & Threads" in hub nav, route title, and subPageInfos. Thread gallery now shows draft/scheduled posts from campaigns alongside threads. Add Postiz API client (postiz-client.ts) with settings schema for URL + API key. Proxy routes: /api/postiz/status, integrations, posts, threads. Wire workflow executor to call real Postiz API for post/thread/cross-post nodes. Add "Send to Postiz" button in thread builder (editor + readonly views). Add approval queue: PendingApproval schema (v5), GET/POST /api/approvals routes, wait-approval workflow node creates pending approvals and pauses execution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ed8274961e
commit
0c9b07525f
|
|
@ -287,6 +287,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
<button data-platform="plain">📄 Plain Text</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--postiz" id="ro-send-postiz">Send to Postiz</button>
|
||||
</div>
|
||||
<div class="ro-cta">
|
||||
<a href="${this.basePath}thread-editor" class="btn btn--success">Create Your Own Thread</a>
|
||||
|
|
@ -322,6 +323,7 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
<button data-platform="plain">📄 Plain Text</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--postiz" id="thread-send-postiz">Send to Postiz</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drafts-area">
|
||||
|
|
@ -838,6 +840,29 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Send to Postiz
|
||||
const postizBtn = sr.getElementById('thread-send-postiz');
|
||||
postizBtn?.addEventListener('click', async () => {
|
||||
const tweets = textarea.value.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
|
||||
if (!tweets.length) { postizBtn.textContent = 'No content'; setTimeout(() => { postizBtn.textContent = 'Send to Postiz'; }, 2000); return; }
|
||||
postizBtn.textContent = 'Sending...';
|
||||
(postizBtn as HTMLButtonElement).disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tweets, type: 'draft' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed');
|
||||
postizBtn.textContent = 'Sent!';
|
||||
setTimeout(() => { postizBtn.textContent = 'Send to Postiz'; (postizBtn as HTMLButtonElement).disabled = false; }, 3000);
|
||||
} catch (err: any) {
|
||||
postizBtn.textContent = err.message || 'Error';
|
||||
setTimeout(() => { postizBtn.textContent = 'Send to Postiz'; (postizBtn as HTMLButtonElement).disabled = false; }, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
// Per-tweet image operations (event delegation)
|
||||
preview?.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
|
@ -969,6 +994,30 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
if (exportMenu) exportMenu.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Send to Postiz (readonly)
|
||||
const roPostizBtn = sr.getElementById('ro-send-postiz');
|
||||
roPostizBtn?.addEventListener('click', async () => {
|
||||
if (!t) return;
|
||||
roPostizBtn.textContent = 'Sending...';
|
||||
(roPostizBtn as HTMLButtonElement).disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/${this._space}/rsocials/api/postiz/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tweets: t.tweets, type: 'draft' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed');
|
||||
this.showToast('Thread sent to Postiz as draft!');
|
||||
roPostizBtn.textContent = 'Sent!';
|
||||
setTimeout(() => { roPostizBtn.textContent = 'Send to Postiz'; (roPostizBtn as HTMLButtonElement).disabled = false; }, 3000);
|
||||
} catch (err: any) {
|
||||
this.showToast(err.message || 'Failed to send');
|
||||
roPostizBtn.textContent = 'Error';
|
||||
setTimeout(() => { roPostizBtn.textContent = 'Send to Postiz'; (roPostizBtn as HTMLButtonElement).disabled = false; }, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showToast(msg: string) {
|
||||
|
|
@ -991,6 +1040,8 @@ export class FolkThreadBuilder extends HTMLElement {
|
|||
.btn--outline:hover { border-color: #6366f1; color: #c4b5fd; }
|
||||
.btn--success { background: #10b981; color: white; }
|
||||
.btn--success:hover { background: #34d399; }
|
||||
.btn--postiz { background: #f97316; color: white; }
|
||||
.btn--postiz:hover { background: #fb923c; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.tweet-card {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,25 @@
|
|||
*/
|
||||
|
||||
import { socialsSchema, socialsDocId } from '../schemas';
|
||||
import type { SocialsDoc, ThreadData } from '../schemas';
|
||||
import type { SocialsDoc, ThreadData, Campaign, CampaignPost } from '../schemas';
|
||||
import type { DocumentId } from '../../../shared/local-first/document';
|
||||
import { DEMO_FEED } from '../lib/types';
|
||||
|
||||
interface DraftPostCard {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
campaignTitle: string;
|
||||
platform: string;
|
||||
content: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
hashtags: string[];
|
||||
}
|
||||
|
||||
export class FolkThreadGallery extends HTMLElement {
|
||||
private _space = 'demo';
|
||||
private _threads: ThreadData[] = [];
|
||||
private _draftPosts: DraftPostCard[] = [];
|
||||
private _offlineUnsub: (() => void) | null = null;
|
||||
private _isDemoFallback = false;
|
||||
|
||||
|
|
@ -79,6 +91,28 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
if (!doc?.threads) return;
|
||||
this._isDemoFallback = false;
|
||||
this._threads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
// Extract draft/scheduled posts from campaigns
|
||||
this._draftPosts = [];
|
||||
if (doc.campaigns) {
|
||||
for (const campaign of Object.values(doc.campaigns)) {
|
||||
for (const post of campaign.posts || []) {
|
||||
if (post.status === 'draft' || post.status === 'scheduled') {
|
||||
this._draftPosts.push({
|
||||
id: post.id,
|
||||
campaignId: campaign.id,
|
||||
campaignTitle: campaign.title,
|
||||
platform: post.platform,
|
||||
content: post.content,
|
||||
scheduledAt: post.scheduledAt,
|
||||
status: post.status,
|
||||
hashtags: post.hashtags || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -94,17 +128,45 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private platformIcon(platform: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
x: '𝕏', twitter: '𝕏', linkedin: '💼', instagram: '📷',
|
||||
threads: '🧵', bluesky: '🦋', youtube: '📹', newsletter: '📧',
|
||||
};
|
||||
return icons[platform.toLowerCase()] || '📱';
|
||||
}
|
||||
|
||||
private render() {
|
||||
if (!this.shadowRoot) return;
|
||||
const space = this._space;
|
||||
const threads = this._threads;
|
||||
const drafts = this._draftPosts;
|
||||
|
||||
const cardsHTML = threads.length === 0
|
||||
const threadCardsHTML = threads.length === 0 && drafts.length === 0
|
||||
? `<div class="empty">
|
||||
<p>No threads yet. Create your first thread!</p>
|
||||
<p>No posts or threads yet. Create your first thread!</p>
|
||||
<a href="${this.basePath}thread-editor" class="btn btn--success">Create Thread</a>
|
||||
</div>`
|
||||
: `<div class="grid">
|
||||
${drafts.map(p => {
|
||||
const preview = this.esc(p.content.substring(0, 200));
|
||||
const schedDate = p.scheduledAt ? new Date(p.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
|
||||
const statusBadge = p.status === 'scheduled'
|
||||
? '<span class="badge badge--scheduled">Scheduled</span>'
|
||||
: '<span class="badge badge--draft">Draft</span>';
|
||||
return `<div class="card card--draft">
|
||||
<div class="card__badges">
|
||||
${statusBadge}
|
||||
<span class="badge badge--campaign">${this.esc(p.campaignTitle)}</span>
|
||||
</div>
|
||||
<h3 class="card__title">${this.platformIcon(p.platform)} ${this.esc(p.platform)} Post</h3>
|
||||
<p class="card__preview">${preview}</p>
|
||||
<div class="card__meta">
|
||||
${p.hashtags.length ? `<span>${p.hashtags.slice(0, 3).join(' ')}</span>` : ''}
|
||||
${schedDate ? `<span>${schedDate}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
${threads.map(t => {
|
||||
const initial = (t.name || '?').charAt(0).toUpperCase();
|
||||
const preview = this.esc((t.tweets[0] || '').substring(0, 200));
|
||||
|
|
@ -155,6 +217,15 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.card:hover { border-color: var(--rs-primary); transform: translateY(-2px); }
|
||||
.card--draft { border-left: 3px solid #f59e0b; }
|
||||
.card__badges { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.badge {
|
||||
font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.5rem; border-radius: 4px;
|
||||
}
|
||||
.badge--draft { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
||||
.badge--scheduled { background: rgba(59,130,246,0.15); color: #60a5fa; }
|
||||
.badge--campaign { background: rgba(99,102,241,0.15); color: #a5b4fc; }
|
||||
.card__title { font-size: 1rem; font-weight: 700; color: var(--rs-text-primary, #f1f5f9); margin: 0; line-height: 1.3; }
|
||||
.card__preview {
|
||||
font-size: 0.85rem; color: var(--rs-text-secondary, #94a3b8); line-height: 1.5;
|
||||
|
|
@ -172,10 +243,10 @@ export class FolkThreadGallery extends HTMLElement {
|
|||
</style>
|
||||
<div class="gallery">
|
||||
<div class="header">
|
||||
<h1>Threads</h1>
|
||||
<h1>Posts & Threads</h1>
|
||||
<a href="${this.basePath}thread-editor" class="btn btn--primary">New Thread</a>
|
||||
</div>
|
||||
${cardsHTML}
|
||||
${threadCardsHTML}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Postiz API client — reads per-space credentials from module settings
|
||||
* and forwards authenticated requests to the Postiz public API.
|
||||
*
|
||||
* Follows the same pattern as listmonk-proxy.ts.
|
||||
*/
|
||||
|
||||
import { loadCommunity, getDocumentData } from "../../../server/community-store";
|
||||
|
||||
export interface PostizConfig {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/** Read Postiz credentials from the space's module settings. */
|
||||
export async function getPostizConfig(spaceSlug: string): Promise<PostizConfig | null> {
|
||||
await loadCommunity(spaceSlug);
|
||||
const data = getDocumentData(spaceSlug);
|
||||
if (!data) return null;
|
||||
|
||||
const settings = data.meta.moduleSettings?.rsocials;
|
||||
if (!settings) return null;
|
||||
|
||||
const url = settings.postizUrl as string | undefined;
|
||||
const apiKey = settings.postizApiKey as string | undefined;
|
||||
|
||||
if (!url || !apiKey) return null;
|
||||
return { url: url.replace(/\/+$/, ''), apiKey };
|
||||
}
|
||||
|
||||
/** Make an authenticated request to the Postiz API. */
|
||||
export async function postizFetch(
|
||||
config: PostizConfig,
|
||||
path: string,
|
||||
opts: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(opts.headers);
|
||||
headers.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
if (!headers.has("Content-Type") && opts.body) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
return fetch(`${config.url}${path}`, { ...opts, headers });
|
||||
}
|
||||
|
||||
/** GET /public/v1/integrations — list connected social channels. */
|
||||
export async function getIntegrations(config: PostizConfig) {
|
||||
const res = await postizFetch(config, "/public/v1/integrations");
|
||||
if (!res.ok) throw new Error(`Postiz integrations error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** POST /public/v1/posts — create a single post (draft, schedule, or now). */
|
||||
export async function createPost(
|
||||
config: PostizConfig,
|
||||
payload: {
|
||||
content: string;
|
||||
integrationIds: string[];
|
||||
type: 'draft' | 'schedule' | 'now';
|
||||
scheduledAt?: string;
|
||||
group?: string;
|
||||
},
|
||||
) {
|
||||
const res = await postizFetch(config, "/public/v1/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`Postiz createPost error: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Create a thread — sends multiple grouped posts sharing a group ID. */
|
||||
export async function createThread(
|
||||
config: PostizConfig,
|
||||
tweets: string[],
|
||||
opts: {
|
||||
integrationIds: string[];
|
||||
type: 'draft' | 'schedule' | 'now';
|
||||
scheduledAt?: string;
|
||||
},
|
||||
) {
|
||||
const group = `thread-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||||
const results = [];
|
||||
for (const content of tweets) {
|
||||
const result = await createPost(config, {
|
||||
content,
|
||||
integrationIds: opts.integrationIds,
|
||||
type: opts.type,
|
||||
scheduledAt: opts.scheduledAt,
|
||||
group,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
return { group, posts: results };
|
||||
}
|
||||
|
|
@ -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 CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge } from "./schemas";
|
||||
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval } from "./schemas";
|
||||
import {
|
||||
generateImageFromPrompt,
|
||||
downloadAndSaveImage,
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "./lib/image-gen";
|
||||
import { DEMO_FEED } from "./lib/types";
|
||||
import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy";
|
||||
import { getPostizConfig, getIntegrations, createPost, createThread } from "./lib/postiz-client";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import type { EncryptIDClaims } from "@encryptid/sdk/server";
|
||||
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
|
||||
|
|
@ -55,6 +56,7 @@ function ensureDoc(space: string): SocialsDoc {
|
|||
d.campaignFlows = {};
|
||||
d.activeFlowId = '';
|
||||
d.campaignWorkflows = {};
|
||||
d.pendingApprovals = {};
|
||||
});
|
||||
_syncServer!.setDoc(docId, doc);
|
||||
}
|
||||
|
|
@ -515,6 +517,125 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
|
|||
return c.json(data, res.status as any);
|
||||
});
|
||||
|
||||
// ── Postiz API proxy routes ──
|
||||
|
||||
routes.get("/api/postiz/status", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const config = await getPostizConfig(space);
|
||||
return c.json({ configured: !!config });
|
||||
});
|
||||
|
||||
routes.get("/api/postiz/integrations", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const config = await getPostizConfig(space);
|
||||
if (!config) return c.json({ error: "Postiz not configured" }, 404);
|
||||
|
||||
try {
|
||||
const data = await getIntegrations(config);
|
||||
return c.json(data);
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post("/api/postiz/posts", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const config = await getPostizConfig(space);
|
||||
if (!config) return c.json({ error: "Postiz not configured" }, 404);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { content, integrationIds, type, scheduledAt } = body;
|
||||
if (!content || !integrationIds?.length) {
|
||||
return c.json({ error: "content and integrationIds are required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPost(config, {
|
||||
content,
|
||||
integrationIds,
|
||||
type: type || 'draft',
|
||||
scheduledAt,
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post("/api/postiz/threads", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const config = await getPostizConfig(space);
|
||||
if (!config) return c.json({ error: "Postiz not configured" }, 404);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { tweets, integrationIds, type, scheduledAt } = body;
|
||||
if (!tweets?.length) {
|
||||
return c.json({ error: "tweets array is required" }, 400);
|
||||
}
|
||||
|
||||
// If no integrationIds provided, try to auto-detect from configured integrations
|
||||
let ids = integrationIds;
|
||||
if (!ids?.length) {
|
||||
try {
|
||||
const integrations = await getIntegrations(config);
|
||||
ids = (integrations || []).map((i: any) => i.id).slice(0, 1);
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
if (!ids?.length) {
|
||||
return c.json({ error: "No integrationIds provided and no integrations found" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createThread(config, tweets, {
|
||||
integrationIds: ids,
|
||||
type: type || 'draft',
|
||||
scheduledAt,
|
||||
});
|
||||
return c.json(result);
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Approval queue routes ──
|
||||
|
||||
routes.get("/api/approvals", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const approvals = Object.values(doc.pendingApprovals || {})
|
||||
.filter(a => a.status === 'pending')
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return c.json(approvals);
|
||||
});
|
||||
|
||||
routes.post("/api/approvals/:id/resolve", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const dataSpace = c.get("effectiveSpace") || space;
|
||||
const approvalId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const action = body.action as 'approve' | 'reject';
|
||||
|
||||
if (!action || !['approve', 'reject'].includes(action)) {
|
||||
return c.json({ error: "action must be 'approve' or 'reject'" }, 400);
|
||||
}
|
||||
|
||||
const docId = socialsDocId(dataSpace);
|
||||
const doc = ensureDoc(dataSpace);
|
||||
const approval = doc.pendingApprovals?.[approvalId];
|
||||
if (!approval) return c.json({ error: "Approval not found" }, 404);
|
||||
if (approval.status !== 'pending') return c.json({ error: "Approval already resolved" }, 409);
|
||||
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `resolve approval ${approvalId}`, (d) => {
|
||||
const a = d.pendingApprovals[approvalId];
|
||||
if (!a) return;
|
||||
a.status = action === 'approve' ? 'approved' : 'rejected';
|
||||
a.resolvedAt = Date.now();
|
||||
});
|
||||
|
||||
return c.json({ ok: true, status: action === 'approve' ? 'approved' : 'rejected' });
|
||||
});
|
||||
|
||||
// ── AI Campaign Generator ──
|
||||
|
||||
routes.post("/api/campaign/generate", async (c) => {
|
||||
|
|
@ -746,18 +867,115 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
|
|||
const wf = doc.campaignWorkflows?.[id];
|
||||
if (!wf) return c.json({ error: "Campaign workflow not found" }, 404);
|
||||
|
||||
// Stub execution — topological walk, each node returns stub success
|
||||
// Execute workflow — topological walk with real Postiz integration
|
||||
const results: { nodeId: string; status: string; message: string; durationMs: number }[] = [];
|
||||
const sorted = topologicalSortCampaign(wf.nodes, wf.edges);
|
||||
const postizConfig = await getPostizConfig(dataSpace);
|
||||
let hasError = false;
|
||||
let paused = false;
|
||||
|
||||
for (const node of sorted) {
|
||||
if (paused) break;
|
||||
const start = Date.now();
|
||||
results.push({
|
||||
nodeId: node.id,
|
||||
status: 'success',
|
||||
message: `[stub] ${node.label} executed`,
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
const cfg = node.config || {};
|
||||
|
||||
try {
|
||||
switch (node.type) {
|
||||
case 'post-to-platform': {
|
||||
if (!postizConfig) throw new Error('Postiz not configured');
|
||||
const integrations = await getIntegrations(postizConfig);
|
||||
const platformName = (cfg.platform as string || '').toLowerCase();
|
||||
const match = (integrations || []).find((i: any) =>
|
||||
i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
|
||||
);
|
||||
const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
|
||||
const content = (cfg.content as string || '') + (cfg.hashtags ? '\n' + cfg.hashtags : '');
|
||||
await createPost(postizConfig, { content, integrationIds, type: 'draft' });
|
||||
results.push({ nodeId: node.id, status: 'success', message: `Draft created on ${cfg.platform || 'default'}`, durationMs: Date.now() - start });
|
||||
break;
|
||||
}
|
||||
case 'cross-post': {
|
||||
if (!postizConfig) throw new Error('Postiz not configured');
|
||||
const platforms = (cfg.platforms as string || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
const integrations = await getIntegrations(postizConfig);
|
||||
const content = (cfg.content as string || '');
|
||||
const posted: string[] = [];
|
||||
for (const plat of platforms) {
|
||||
const match = (integrations || []).find((i: any) =>
|
||||
i.name?.toLowerCase().includes(plat) || i.providerIdentifier?.toLowerCase().includes(plat)
|
||||
);
|
||||
if (match) {
|
||||
await createPost(postizConfig, { content, integrationIds: [match.id], type: 'draft' });
|
||||
posted.push(plat);
|
||||
}
|
||||
}
|
||||
results.push({ nodeId: node.id, status: 'success', message: `Cross-posted draft to: ${posted.join(', ') || 'none matched'}`, durationMs: Date.now() - start });
|
||||
break;
|
||||
}
|
||||
case 'publish-thread': {
|
||||
if (!postizConfig) throw new Error('Postiz not configured');
|
||||
const threadContent = cfg.threadContent as string || '';
|
||||
const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean);
|
||||
if (tweets.length === 0) throw new Error('No thread content');
|
||||
const integrations = await getIntegrations(postizConfig);
|
||||
const platformName = (cfg.platform as string || '').toLowerCase();
|
||||
const match = (integrations || []).find((i: any) =>
|
||||
i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
|
||||
);
|
||||
const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
|
||||
await createThread(postizConfig, tweets, { integrationIds, type: 'draft' });
|
||||
results.push({ nodeId: node.id, status: 'success', message: `Thread draft created (${tweets.length} tweets)`, durationMs: Date.now() - start });
|
||||
break;
|
||||
}
|
||||
case 'send-newsletter': {
|
||||
// Newsletter sending via Listmonk — log only for now
|
||||
results.push({ nodeId: node.id, status: 'success', message: `[listmonk] Newsletter node logged (subject: ${cfg.subject || 'N/A'})`, durationMs: Date.now() - start });
|
||||
break;
|
||||
}
|
||||
case 'post-webhook': {
|
||||
const webhookUrl = cfg.url as string;
|
||||
if (!webhookUrl) throw new Error('No webhook URL configured');
|
||||
const bodyTemplate = cfg.bodyTemplate as string || '{}';
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: bodyTemplate,
|
||||
});
|
||||
results.push({ nodeId: node.id, status: res.ok ? 'success' : 'error', message: `Webhook ${res.status}`, durationMs: Date.now() - start });
|
||||
if (!res.ok) hasError = true;
|
||||
break;
|
||||
}
|
||||
case 'wait-approval': {
|
||||
// Create a pending approval and pause execution
|
||||
const docId = socialsDocId(dataSpace);
|
||||
const approvalId = `apr-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
|
||||
_syncServer!.changeDoc<SocialsDoc>(docId, `create approval for ${node.id}`, (d) => {
|
||||
if (!d.pendingApprovals) (d as any).pendingApprovals = {};
|
||||
d.pendingApprovals[approvalId] = {
|
||||
id: approvalId,
|
||||
workflowId: id,
|
||||
nodeId: node.id,
|
||||
message: (cfg.message as string) || 'Approval required',
|
||||
approver: (cfg.approver as string) || '',
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
};
|
||||
});
|
||||
results.push({ nodeId: node.id, status: 'paused', message: `Awaiting approval (${approvalId})`, durationMs: Date.now() - start });
|
||||
paused = true;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Triggers, conditions, delays — pass through
|
||||
results.push({ nodeId: node.id, status: 'success', message: `${node.label} passed`, durationMs: Date.now() - start });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
hasError = true;
|
||||
results.push({ nodeId: node.id, status: 'error', message: err.message, durationMs: Date.now() - start });
|
||||
}
|
||||
}
|
||||
|
||||
// Update run metadata
|
||||
|
|
@ -766,11 +984,11 @@ routes.post("/api/campaign-workflows/:id/run", async (c) => {
|
|||
const w = d.campaignWorkflows[id];
|
||||
if (!w) return;
|
||||
w.lastRunAt = Date.now();
|
||||
w.lastRunStatus = 'success';
|
||||
w.lastRunStatus = hasError ? 'error' : 'success';
|
||||
w.runCount = (w.runCount || 0) + 1;
|
||||
});
|
||||
|
||||
return c.json({ results });
|
||||
return c.json({ results, paused });
|
||||
});
|
||||
|
||||
// POST /api/campaign-workflows/webhook/:hookId — external webhook trigger
|
||||
|
|
@ -1154,6 +1372,8 @@ export const socialsModule: RSpaceModule = {
|
|||
{ key: 'listmonkUrl', label: 'Listmonk URL', type: 'string', description: 'Base URL of your Listmonk instance (e.g. https://newsletter.example.com)' },
|
||||
{ key: 'listmonkUser', label: 'Listmonk Username', type: 'string', description: 'API username for Listmonk' },
|
||||
{ key: 'listmonkPassword', label: 'Listmonk Password', type: 'password', description: 'API password for Listmonk' },
|
||||
{ key: 'postizUrl', label: 'Postiz URL', type: 'string', description: 'Base URL of your Postiz instance (e.g. https://demo.rsocials.online)' },
|
||||
{ key: 'postizApiKey', label: 'Postiz API Key', type: 'password', description: 'API key from Postiz Settings > Developers' },
|
||||
],
|
||||
standaloneDomain: "rsocials.online",
|
||||
landingPage: renderLanding,
|
||||
|
|
|
|||
|
|
@ -359,6 +359,19 @@ export const CAMPAIGN_NODE_CATALOG: CampaignWorkflowNodeDef[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// ── Approval queue types ──
|
||||
|
||||
export interface PendingApproval {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
message: string;
|
||||
approver: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
createdAt: number;
|
||||
resolvedAt: number | null;
|
||||
}
|
||||
|
||||
// ── Document root ──
|
||||
|
||||
export interface SocialsDoc {
|
||||
|
|
@ -374,6 +387,7 @@ export interface SocialsDoc {
|
|||
campaignFlows: Record<string, CampaignFlow>;
|
||||
activeFlowId: string;
|
||||
campaignWorkflows: Record<string, CampaignWorkflow>;
|
||||
pendingApprovals: Record<string, PendingApproval>;
|
||||
}
|
||||
|
||||
// ── Schema registration ──
|
||||
|
|
@ -381,12 +395,12 @@ export interface SocialsDoc {
|
|||
export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||
module: 'socials',
|
||||
collection: 'data',
|
||||
version: 4,
|
||||
version: 5,
|
||||
init: (): SocialsDoc => ({
|
||||
meta: {
|
||||
module: 'socials',
|
||||
collection: 'data',
|
||||
version: 4,
|
||||
version: 5,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
|
|
@ -395,12 +409,14 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
|
|||
campaignFlows: {},
|
||||
activeFlowId: '',
|
||||
campaignWorkflows: {},
|
||||
pendingApprovals: {},
|
||||
}),
|
||||
migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => {
|
||||
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
|
||||
if (!doc.activeFlowId) (doc as any).activeFlowId = '';
|
||||
if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
|
||||
if (doc.meta) doc.meta.version = 4;
|
||||
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
|
||||
if (doc.meta) doc.meta.version = 5;
|
||||
return doc;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue