From 0c9b07525f84cc9b89e918ff1b9a17aff9aa8c38 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 20 Mar 2026 14:28:22 -0700 Subject: [PATCH] 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 --- .../components/folk-thread-builder.ts | 51 ++++ .../components/folk-thread-gallery.ts | 81 +++++- modules/rsocials/lib/postiz-client.ts | 98 +++++++ modules/rsocials/mod.ts | 240 +++++++++++++++++- modules/rsocials/schemas.ts | 22 +- 5 files changed, 474 insertions(+), 18 deletions(-) create mode 100644 modules/rsocials/lib/postiz-client.ts diff --git a/modules/rsocials/components/folk-thread-builder.ts b/modules/rsocials/components/folk-thread-builder.ts index 37ba06f..df71ecd 100644 --- a/modules/rsocials/components/folk-thread-builder.ts +++ b/modules/rsocials/components/folk-thread-builder.ts @@ -287,6 +287,7 @@ export class FolkThreadBuilder extends HTMLElement { +
Create Your Own Thread @@ -322,6 +323,7 @@ export class FolkThreadBuilder extends HTMLElement {
+
@@ -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 { diff --git a/modules/rsocials/components/folk-thread-gallery.ts b/modules/rsocials/components/folk-thread-gallery.ts index ac18100..2789bb3 100644 --- a/modules/rsocials/components/folk-thread-gallery.ts +++ b/modules/rsocials/components/folk-thread-gallery.ts @@ -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, '"'); } + private platformIcon(platform: string): string { + const icons: Record = { + 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 ? `
-

No threads yet. Create your first thread!

+

No posts or threads yet. Create your first thread!

Create Thread
` : `
+ ${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' + ? 'Scheduled' + : 'Draft'; + return `
+
+ ${statusBadge} + ${this.esc(p.campaignTitle)} +
+

${this.platformIcon(p.platform)} ${this.esc(p.platform)} Post

+

${preview}

+
+ ${p.hashtags.length ? `${p.hashtags.slice(0, 3).join(' ')}` : ''} + ${schedDate ? `${schedDate}` : ''} +
+
`; + }).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 { `; } diff --git a/modules/rsocials/lib/postiz-client.ts b/modules/rsocials/lib/postiz-client.ts new file mode 100644 index 0000000..b249e82 --- /dev/null +++ b/modules/rsocials/lib/postiz-client.ts @@ -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 { + 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 { + 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 }; +} diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index 4a3bbf8..b819fd8 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 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(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(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, diff --git a/modules/rsocials/schemas.ts b/modules/rsocials/schemas.ts index 7e0bc51..c17bc18 100644 --- a/modules/rsocials/schemas.ts +++ b/modules/rsocials/schemas.ts @@ -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; activeFlowId: string; campaignWorkflows: Record; + pendingApprovals: Record; } // โ”€โ”€ Schema registration โ”€โ”€ @@ -381,12 +395,12 @@ export interface SocialsDoc { export const socialsSchema: DocSchema = { 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 = { 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; }, };