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 {
+
@@ -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 {
- ${cardsHTML}
+ ${threadCardsHTML}
`;
}
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;
},
};