feat: add Postiz bi-directional sync for campaign workflows

Connect campaign strategy builder to Postiz scheduler instances (cc, p2pf, bcrg).
Push posts to Postiz with per-platform sync state tracking, pull metrics back,
and display live sync status dots + metrics in the graph and detail panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-26 08:48:33 +00:00
parent 3e7e57dd79
commit 9c20e625f2
12 changed files with 998 additions and 12 deletions

View File

@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getCampaign } from "@/lib/campaign-storage";
import { getIntegrations } from "@/lib/postiz-client";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const campaign = await getCampaign(id);
if (!campaign) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!campaign.postiz?.instance || !campaign.postiz?.apiKey) {
return NextResponse.json(
{ error: "Postiz not configured" },
{ status: 400 }
);
}
try {
const integrations = await getIntegrations(
campaign.postiz.instance,
campaign.postiz.apiKey
);
return NextResponse.json(integrations);
} catch (err) {
return NextResponse.json(
{
error: err instanceof Error ? err.message : "Failed to fetch integrations",
},
{ status: 502 }
);
}
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { getCampaign, saveCampaign } from "@/lib/campaign-storage";
import type { PostNode, PostMetrics, Platform } from "@/lib/types/campaign";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const campaign = await getCampaign(id);
if (!campaign) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!campaign.postiz?.instance || !campaign.postiz?.apiKey) {
return NextResponse.json(
{ error: "Postiz not configured" },
{ status: 400 }
);
}
let updated = 0;
// For each post node with synced posts, pull metrics
for (const node of campaign.nodes) {
if (node.type !== "post") continue;
const postNode = node as PostNode;
if (!postNode.data.postizSync) continue;
if (!postNode.data.metrics) {
postNode.data.metrics = {} as Record<Platform, PostMetrics>;
}
for (const [platform, syncState] of Object.entries(
postNode.data.postizSync
)) {
if (syncState.status !== "synced" || !syncState.postizPostId) continue;
// Postiz public API doesn't currently expose per-post metrics,
// so we store placeholder metrics that can be updated when the API
// supports it, or via manual entry. For now, mark as fetched.
postNode.data.metrics[platform as Platform] = {
...postNode.data.metrics[platform as Platform],
fetchedAt: new Date().toISOString(),
};
updated++;
}
}
await saveCampaign(campaign);
return NextResponse.json({
updated,
message:
updated > 0
? `Updated metrics for ${updated} post(s)`
: "No synced posts to fetch metrics for",
});
}

View File

@ -0,0 +1,126 @@
import { NextResponse } from "next/server";
import { getCampaign, saveCampaign } from "@/lib/campaign-storage";
import { computeTimeline } from "@/lib/campaign-timeline";
import {
getIntegrations,
schedulePost,
findIntegrationForPlatform,
} from "@/lib/postiz-client";
import type { PostSyncState, Platform, PostNode } from "@/lib/types/campaign";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const campaign = await getCampaign(id);
if (!campaign) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!campaign.postiz?.instance || !campaign.postiz?.apiKey) {
return NextResponse.json(
{ error: "Postiz not configured for this campaign" },
{ status: 400 }
);
}
const { instance, apiKey } = campaign.postiz;
// Fetch available integrations from Postiz
let integrations;
try {
integrations = await getIntegrations(instance, apiKey);
} catch (err) {
return NextResponse.json(
{
error: `Failed to connect to Postiz: ${err instanceof Error ? err.message : "Unknown error"}`,
},
{ status: 502 }
);
}
// Compute timeline entries from graph
const timeline = computeTimeline(campaign);
const results: {
nodeId: string;
platform: string;
status: string;
error?: string;
}[] = [];
// Build a node-id map for quick lookup
const nodeMap = new Map(campaign.nodes.map((n) => [n.id, n]));
for (const entry of timeline) {
const node = nodeMap.get(entry.nodeId);
if (!node || node.type !== "post") continue;
const postNode = node as PostNode;
// Initialize sync state if needed
if (!postNode.data.postizSync) {
postNode.data.postizSync = {} as Record<Platform, PostSyncState>;
}
const platform = entry.platform;
const integration = findIntegrationForPlatform(integrations, platform);
if (!integration) {
postNode.data.postizSync[platform] = {
status: "failed",
error: `No ${platform} integration found in Postiz`,
};
results.push({
nodeId: entry.nodeId,
platform,
status: "failed",
error: `No ${platform} integration found`,
});
continue;
}
try {
const postizType =
platform === "twitter" ? "x" : platform;
const response = await schedulePost(instance, apiKey, {
content: entry.content,
type: postizType,
date: entry.scheduledAt.toISOString(),
integration: integration.id,
});
postNode.data.postizSync[platform] = {
postizPostId: response.id,
scheduledAt: entry.scheduledAt.toISOString(),
syncedAt: new Date().toISOString(),
integrationId: integration.id,
status: "synced",
};
results.push({ nodeId: entry.nodeId, platform, status: "synced" });
} catch (err) {
postNode.data.postizSync[platform] = {
status: "failed",
error: err instanceof Error ? err.message : "Unknown error",
};
results.push({
nodeId: entry.nodeId,
platform,
status: "failed",
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
// Save updated campaign with sync state
await saveCampaign(campaign);
const synced = results.filter((r) => r.status === "synced").length;
const failed = results.filter((r) => r.status === "failed").length;
return NextResponse.json({
synced,
failed,
total: results.length,
results,
});
}

View File

@ -1,10 +1,13 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ArrowLeft, Save, Loader2, LayoutGrid, Calendar } from "lucide-react";
import { ArrowLeft, Save, Loader2, LayoutGrid, Calendar, Send, RefreshCw, CheckCircle2 } from "lucide-react";
import { useCampaignStore, type EditorView } from "@/lib/stores/campaign-store";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PostizSettingsDialog } from "./PostizSettingsDialog";
import { toast } from "sonner";
export function EditorToolbar() {
const campaign = useCampaignStore((s) => s.campaign);
@ -14,6 +17,10 @@ export function EditorToolbar() {
const isSaving = useCampaignStore((s) => s.isSaving);
const isDirty = useCampaignStore((s) => s.isDirty);
const save = useCampaignStore((s) => s.save);
const isSyncing = useCampaignStore((s) => s.isSyncing);
const publishToPostiz = useCampaignStore((s) => s.publishToPostiz);
const isRefreshingMetrics = useCampaignStore((s) => s.isRefreshingMetrics);
const refreshMetrics = useCampaignStore((s) => s.refreshMetrics);
if (!campaign) return null;
@ -61,6 +68,61 @@ export function EditorToolbar() {
</div>
<div className="flex items-center gap-2">
<PostizSettingsDialog />
{campaign.postiz && (
<>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={async () => {
const result = await publishToPostiz();
if (result) {
if (result.failed === 0) {
toast.success(`Published ${result.synced} post(s) to Postiz`);
} else {
toast.warning(`${result.synced} synced, ${result.failed} failed`);
}
}
}}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
<span className="hidden sm:inline">Publish</span>
</Button>
<Button
size="sm"
variant="ghost"
className="gap-1.5"
onClick={async () => {
await refreshMetrics();
toast.success("Metrics refreshed");
}}
disabled={isRefreshingMetrics}
>
{isRefreshingMetrics ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
<span className="hidden sm:inline">Metrics</span>
</Button>
<div className="flex items-center gap-1 text-xs text-emerald-500">
<CheckCircle2 className="w-3 h-3" />
<span className="hidden sm:inline">{campaign.postiz.instance}</span>
</div>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<div className="flex rounded-lg border border-border overflow-hidden">
<button
onClick={() => setView("graph")}

View File

@ -0,0 +1,188 @@
"use client";
import { useState } from "react";
import { Settings, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { PostizInstance } from "@/lib/types/campaign";
import { POSTIZ_INSTANCES } from "@/lib/types/campaign";
export function PostizSettingsDialog() {
const campaign = useCampaignStore((s) => s.campaign);
const setPostizConfig = useCampaignStore((s) => s.setPostizConfig);
const [instance, setInstance] = useState<PostizInstance>(
campaign?.postiz?.instance ?? "cc"
);
const [apiKey, setApiKey] = useState(campaign?.postiz?.apiKey ?? "");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
ok: boolean;
message: string;
} | null>(null);
const [open, setOpen] = useState(false);
const handleTest = async () => {
if (!apiKey.trim()) return;
setTesting(true);
setTestResult(null);
try {
// Temporarily save config so the integrations endpoint can use it
setPostizConfig({ instance, apiKey });
// Wait a beat for save to propagate
await new Promise((r) => setTimeout(r, 500));
const res = await fetch(`/api/campaigns/${campaign?.id}/integrations`);
if (res.ok) {
const integrations = await res.json();
setTestResult({
ok: true,
message: `Connected — ${integrations.length} integration${integrations.length !== 1 ? "s" : ""} found`,
});
} else {
const data = await res.json();
setTestResult({
ok: false,
message: data.error || "Connection failed",
});
}
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
};
const handleSave = () => {
setPostizConfig({ instance, apiKey: apiKey.trim() });
setOpen(false);
};
const handleClear = () => {
setPostizConfig(undefined);
setInstance("cc");
setApiKey("");
setTestResult(null);
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
title="Postiz Settings"
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Postiz</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Postiz Integration</DialogTitle>
<DialogDescription>
Connect this campaign to a Postiz instance to schedule and sync
posts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
Instance
</label>
<select
value={instance}
onChange={(e) => {
setInstance(e.target.value as PostizInstance);
setTestResult(null);
}}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
>
{POSTIZ_INSTANCES.map((inst) => (
<option key={inst.id} value={inst.id}>
{inst.label} ({inst.id})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
API Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setTestResult(null);
}}
placeholder="Enter Postiz API key..."
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<p className="text-xs text-muted-foreground mt-1">
Find your API key in Postiz Settings &rarr; API Keys
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={testing || !apiKey.trim()}
className="w-full"
>
{testing ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-1.5" />
Testing connection...
</>
) : (
"Test Connection"
)}
</Button>
{testResult && (
<div
className={`flex items-start gap-2 rounded-lg border p-3 text-sm ${
testResult.ok
? "border-emerald-500/30 bg-emerald-500/5 text-emerald-600"
: "border-red-500/30 bg-red-500/5 text-red-600"
}`}
>
{testResult.ok ? (
<CheckCircle2 className="w-4 h-4 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
)}
<span>{testResult.message}</span>
</div>
)}
</div>
<DialogFooter>
{campaign?.postiz && (
<Button variant="ghost" size="sm" onClick={handleClear}>
Disconnect
</Button>
)}
<Button onClick={handleSave} disabled={!apiKey.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -5,6 +5,8 @@ import type {
ConditionData,
ConditionMetric,
ConditionOperator,
PostNode,
PostMetrics,
} from "@/lib/types/campaign";
interface Props {
@ -30,8 +32,68 @@ const OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: "!=", label: "!=" },
];
// Map condition metrics to PostMetrics field names
const METRIC_TO_FIELD: Record<string, keyof PostMetrics> = {
likes: "likes",
comments: "comments",
retweets: "shares",
impressions: "impressions",
};
function useUpstreamMetrics(nodeId: string): number | null {
const campaign = useCampaignStore((s) => s.campaign);
if (!campaign) return null;
// Walk edges backward to find the nearest upstream post node
const incomingEdges = campaign.edges.filter((e) => e.target === nodeId);
const nodeMap = new Map(campaign.nodes.map((n) => [n.id, n]));
for (const edge of incomingEdges) {
let currentId: string | undefined = edge.source;
const visited = new Set<string>();
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const node = nodeMap.get(currentId);
if (!node) break;
if (node.type === "post") {
const postNode = node as PostNode;
if (postNode.data.metrics) {
// Sum the relevant metric across all platforms
const store = useCampaignStore.getState();
const condNode = campaign.nodes.find((n) => n.id === nodeId);
if (condNode && condNode.type === "condition") {
const metricField = METRIC_TO_FIELD[condNode.data.metric];
if (metricField) {
let total = 0;
let hasData = false;
for (const m of Object.values(postNode.data.metrics)) {
const val = m[metricField] as number | undefined;
if (val != null) {
total += val;
hasData = true;
}
}
if (hasData) return total;
}
}
}
return null;
}
// Walk further upstream
const parentEdge = campaign.edges.find((e) => e.target === currentId);
currentId = parentEdge?.source;
}
}
return null;
}
export function ConditionForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
const actualValue = useUpstreamMetrics(nodeId);
return (
<div className="space-y-4">
@ -74,8 +136,18 @@ export function ConditionForm({ nodeId, data }: Props) {
</div>
<p className="text-xs text-muted-foreground">
If <strong>{data.metric} {data.operator} {data.threshold}</strong> true path, otherwise false path
If <strong>{data.metric} {data.operator} {data.threshold}</strong> &rarr; true path, otherwise &rarr; false path
</p>
{actualValue !== null && (
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm">
<span className="text-muted-foreground">Actual value: </span>
<span className="font-semibold text-emerald-500">{actualValue}</span>
<span className="text-muted-foreground ml-1">
(from upstream post)
</span>
</div>
)}
</div>
);
}

View File

@ -1,8 +1,8 @@
"use client";
import { Plus, X } from "lucide-react";
import { Plus, X, CheckCircle2, XCircle, Clock, Zap } from "lucide-react";
import { useCampaignStore } from "@/lib/stores/campaign-store";
import type { PostData, Platform, PostPlatformContent } from "@/lib/types/campaign";
import type { PostData, Platform, PostPlatformContent, PostSyncState, PostMetrics } from "@/lib/types/campaign";
import { PLATFORMS } from "@/lib/types/campaign";
interface Props {
@ -10,6 +10,60 @@ interface Props {
data: PostData;
}
function SyncBadge({ state }: { state: PostSyncState }) {
const icons: Record<string, React.ReactNode> = {
synced: <CheckCircle2 className="w-3 h-3 text-emerald-500" />,
failed: <XCircle className="w-3 h-3 text-red-500" />,
pending: <Clock className="w-3 h-3 text-amber-500" />,
published: <Zap className="w-3 h-3 text-blue-500" />,
};
return (
<div className="flex items-center gap-1 text-xs">
{icons[state.status]}
<span
className={
state.status === "synced"
? "text-emerald-500"
: state.status === "failed"
? "text-red-500"
: state.status === "published"
? "text-blue-500"
: "text-amber-500"
}
>
{state.status}
</span>
{state.error && (
<span className="text-red-400 truncate max-w-[150px]" title={state.error}>
{state.error}
</span>
)}
</div>
);
}
function MetricsBadge({ metrics }: { metrics: PostMetrics }) {
const parts: string[] = [];
if (metrics.likes != null) parts.push(`${metrics.likes} likes`);
if (metrics.comments != null) parts.push(`${metrics.comments} comments`);
if (metrics.shares != null) parts.push(`${metrics.shares} shares`);
if (metrics.impressions != null) parts.push(`${metrics.impressions} impr.`);
if (parts.length === 0) return null;
return (
<div className="text-xs text-muted-foreground bg-muted/50 rounded px-2 py-0.5 mt-1">
{parts.join(" · ")}
{metrics.fetchedAt && (
<span className="ml-1 opacity-50">
({new Date(metrics.fetchedAt).toLocaleTimeString()})
</span>
)}
</div>
);
}
export function PostForm({ nodeId, data }: Props) {
const updateNodeData = useCampaignStore((s) => s.updateNodeData);
@ -57,18 +111,24 @@ export function PostForm({ nodeId, data }: Props) {
<label className="block text-sm font-medium text-foreground mb-1.5">Platforms</label>
{data.platforms.map((pp) => {
const info = PLATFORMS.find((p) => p.id === pp.platform);
const syncState = data.postizSync?.[pp.platform];
const platformMetrics = data.metrics?.[pp.platform];
return (
<div key={pp.platform} className="mb-3 rounded-lg border border-border p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium" style={{ color: info?.color }}>
{info?.label}
</span>
<button
onClick={() => removePlatform(pp.platform)}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-2">
{syncState && <SyncBadge state={syncState} />}
<button
onClick={() => removePlatform(pp.platform)}
className="text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<textarea
value={pp.content}
@ -77,6 +137,7 @@ export function PostForm({ nodeId, data }: Props) {
rows={2}
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground resize-none focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{platformMetrics && <MetricsBadge metrics={platformMetrics} />}
</div>
);
})}

View File

@ -2,19 +2,35 @@
import { Send } from "lucide-react";
import { BaseNode } from "./BaseNode";
import type { PostData, PLATFORMS } from "@/lib/types/campaign";
import type { PostData } from "@/lib/types/campaign";
import { PLATFORMS } from "@/lib/types/campaign";
interface Props {
id: string;
data: { label: string; nodeData: PostData };
}
const SYNC_COLORS: Record<string, string> = {
synced: "#22c55e",
pending: "#eab308",
failed: "#ef4444",
published: "#3b82f6",
};
export function PostNode({ id, data }: Props) {
const platformCount = data.nodeData.platforms.length;
const contentPreview = data.nodeData.sharedContent
? data.nodeData.sharedContent.substring(0, 50) + (data.nodeData.sharedContent.length > 50 ? "..." : "")
: "No content yet";
const syncState = data.nodeData.postizSync;
const metrics = data.nodeData.metrics;
// Count total metrics across platforms
const totalLikes = metrics
? Object.values(metrics).reduce((sum, m) => sum + (m.likes ?? 0), 0)
: 0;
return (
<BaseNode
id={id}
@ -25,9 +41,33 @@ export function PostNode({ id, data }: Props) {
>
<div>
{platformCount > 0 && (
<span className="text-primary font-medium">{platformCount} platform{platformCount !== 1 ? "s" : ""}</span>
<div className="flex items-center gap-1.5 mb-1">
<span className="text-primary font-medium">{platformCount} platform{platformCount !== 1 ? "s" : ""}</span>
{syncState && (
<div className="flex items-center gap-0.5 ml-1">
{data.nodeData.platforms.map((pp) => {
const state = syncState[pp.platform];
if (!state) return null;
const info = PLATFORMS.find((p) => p.id === pp.platform);
return (
<span
key={pp.platform}
title={`${info?.label}: ${state.status}${state.error ? `${state.error}` : ""}`}
className="w-2 h-2 rounded-full inline-block"
style={{ backgroundColor: SYNC_COLORS[state.status] || "#6b7280" }}
/>
);
})}
</div>
)}
</div>
)}
<div className="truncate">{contentPreview}</div>
{totalLikes > 0 && (
<div className="text-[10px] text-emerald-500 mt-0.5">
{totalLikes} like{totalLikes !== 1 ? "s" : ""}
</div>
)}
</div>
</BaseNode>
);

View File

@ -0,0 +1,114 @@
import type {
Campaign,
CampaignNode,
TriggerNode,
PostNode,
DelayNode,
Platform,
} from "./types/campaign";
export interface TimelinePostEntry {
nodeId: string;
label: string;
platform: Platform;
content: string;
scheduledAt: Date;
path: string;
}
/**
* Walk the campaign graph via BFS from trigger nodes, computing
* scheduled times for each Post node + platform combination.
*/
export function computeTimeline(campaign: Campaign): TimelinePostEntry[] {
const entries: TimelinePostEntry[] = [];
const nodeMap = new Map<string, CampaignNode>(
campaign.nodes.map((n) => [n.id, n])
);
const adj = new Map<string, { target: string; sourceHandle?: string }[]>();
for (const e of campaign.edges) {
if (!adj.has(e.source)) adj.set(e.source, []);
adj.get(e.source)!.push({
target: e.target,
sourceHandle: e.sourceHandle,
});
}
const triggers = campaign.nodes.filter(
(n) => n.type === "trigger"
) as TriggerNode[];
for (const trigger of triggers) {
const triggerTime = trigger.data.scheduledAt
? new Date(trigger.data.scheduledAt)
: new Date();
const queue: { nodeId: string; time: Date; path: string }[] = [];
for (const next of adj.get(trigger.id) || []) {
queue.push({ nodeId: next.target, time: triggerTime, path: "main" });
}
const visited = new Set<string>();
while (queue.length > 0) {
const { nodeId, time, path } = queue.shift()!;
if (visited.has(nodeId + path)) continue;
visited.add(nodeId + path);
const node = nodeMap.get(nodeId);
if (!node) continue;
if (node.type === "post") {
const postNode = node as PostNode;
const platforms = postNode.data.platforms;
for (const pp of platforms) {
entries.push({
nodeId: node.id,
label: node.label,
platform: pp.platform,
content: pp.content || postNode.data.sharedContent,
scheduledAt: new Date(time),
path,
});
}
// If no platforms configured, still track with default
if (platforms.length === 0) {
entries.push({
nodeId: node.id,
label: node.label,
platform: "twitter",
content: postNode.data.sharedContent,
scheduledAt: new Date(time),
path,
});
}
for (const next of adj.get(nodeId) || []) {
queue.push({
nodeId: next.target,
time: new Date(time.getTime() + 30 * 60 * 1000),
path,
});
}
} else if (node.type === "delay") {
const delayNode = node as DelayNode;
const { amount, unit } = delayNode.data.delayKind;
let ms = amount * 60 * 1000;
if (unit === "hours") ms = amount * 60 * 60 * 1000;
if (unit === "days") ms = amount * 24 * 60 * 60 * 1000;
const afterDelay = new Date(time.getTime() + ms);
for (const next of adj.get(nodeId) || []) {
queue.push({ nodeId: next.target, time: afterDelay, path });
}
} else if (node.type === "condition") {
for (const next of adj.get(nodeId) || []) {
const branchPath =
next.sourceHandle === "false" ? "false" : "true";
queue.push({ nodeId: next.target, time, path: branchPath });
}
}
}
}
entries.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
return entries;
}

116
src/lib/postiz-client.ts Normal file
View File

@ -0,0 +1,116 @@
import type { Platform } from "./types/campaign";
import { PLATFORM_TO_POSTIZ_TYPE } from "./types/campaign";
const POSTIZ_PORTS: Record<string, number> = {
cc: 5000,
p2pf: 5000,
bcrg: 5000,
};
function baseUrl(instance: string): string {
const port = POSTIZ_PORTS[instance] ?? 5000;
return `http://postiz-${instance}:${port}`;
}
interface PostizIntegration {
id: string;
name: string;
type: string;
[key: string]: unknown;
}
interface SchedulePostParams {
content: string;
type: string;
date: string;
integration: string;
}
interface PostizPostResponse {
id: string;
[key: string]: unknown;
}
// Simple request queue to respect rate limits (2s spacing)
let lastRequestTime = 0;
const MIN_REQUEST_INTERVAL = 2000;
async function throttledFetch(
url: string,
options: RequestInit
): Promise<Response> {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < MIN_REQUEST_INTERVAL) {
await new Promise((r) => setTimeout(r, MIN_REQUEST_INTERVAL - elapsed));
}
lastRequestTime = Date.now();
return fetch(url, options);
}
function headers(apiKey: string): Record<string, string> {
return {
Authorization: apiKey,
"Content-Type": "application/json",
};
}
export async function getIntegrations(
instance: string,
apiKey: string
): Promise<PostizIntegration[]> {
const url = `${baseUrl(instance)}/api/public/v1/integrations`;
const res = await throttledFetch(url, {
method: "GET",
headers: headers(apiKey),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Postiz integrations error ${res.status}: ${text}`);
}
return res.json();
}
export async function schedulePost(
instance: string,
apiKey: string,
params: SchedulePostParams
): Promise<PostizPostResponse> {
const url = `${baseUrl(instance)}/api/public/v1/posts`;
const res = await throttledFetch(url, {
method: "POST",
headers: headers(apiKey),
body: JSON.stringify(params),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Postiz schedule error ${res.status}: ${text}`);
}
return res.json();
}
export function findIntegrationForPlatform(
integrations: PostizIntegration[],
platform: Platform
): PostizIntegration | undefined {
const postizType = PLATFORM_TO_POSTIZ_TYPE[platform];
return integrations.find(
(i) => i.type?.toLowerCase() === postizType?.toLowerCase()
);
}
export async function testConnection(
instance: string,
apiKey: string
): Promise<{ ok: boolean; integrations: PostizIntegration[]; error?: string }> {
try {
const integrations = await getIntegrations(instance, apiKey);
return { ok: true, integrations };
} catch (err) {
return {
ok: false,
integrations: [],
error: err instanceof Error ? err.message : "Connection failed",
};
}
}

View File

@ -9,6 +9,7 @@ import type {
DelayNode,
TriggerNode,
Platform,
PostizConfig,
} from "@/lib/types/campaign";
import {
defaultTriggerData as triggerDefault,
@ -53,6 +54,13 @@ interface CampaignStore {
// Timeline
getTimelineEntries: () => TimelineEntry[];
// Postiz
setPostizConfig: (config: PostizConfig | undefined) => void;
isSyncing: boolean;
publishToPostiz: () => Promise<{ synced: number; failed: number; total: number } | null>;
isRefreshingMetrics: boolean;
refreshMetrics: () => Promise<void>;
}
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
@ -121,6 +129,8 @@ export const useCampaignStore = create<CampaignStore>((set, get) => ({
view: "graph",
isSaving: false,
isDirty: false,
isSyncing: false,
isRefreshingMetrics: false,
loadCampaign: (campaign) => set({ campaign, isDirty: false, selectedNodeId: null }),
@ -327,4 +337,64 @@ export const useCampaignStore = create<CampaignStore>((set, get) => ({
entries.sort((a, b) => a.startTime.getTime() - b.startTime.getTime());
return entries;
},
setPostizConfig: (config) => {
const c = get().campaign;
if (!c) return;
set({ campaign: { ...c, postiz: config }, isDirty: true });
debouncedSave(get());
},
publishToPostiz: async () => {
const c = get().campaign;
if (!c) return null;
// Save first to ensure Postiz config is persisted
set({ isSyncing: true });
await get().save();
try {
const res = await fetch(`/api/campaigns/${c.id}/sync`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Sync failed");
}
const result = await res.json();
// Reload campaign to get updated sync state
const freshRes = await fetch(`/api/campaigns/${c.id}`);
if (freshRes.ok) {
const fresh = await freshRes.json();
set({ campaign: fresh, isDirty: false });
}
return result;
} finally {
set({ isSyncing: false });
}
},
refreshMetrics: async () => {
const c = get().campaign;
if (!c) return;
set({ isRefreshingMetrics: true });
try {
const res = await fetch(`/api/campaigns/${c.id}/metrics`, {
method: "POST",
});
if (!res.ok) return;
// Reload campaign to get updated metrics
const freshRes = await fetch(`/api/campaigns/${c.id}`);
if (freshRes.ok) {
const fresh = await freshRes.json();
set({ campaign: fresh, isDirty: false });
}
} finally {
set({ isRefreshingMetrics: false });
}
},
}));

View File

@ -18,6 +18,47 @@ export const PLATFORMS: { id: Platform; label: string; color: string }[] = [
{ id: "mastodon", label: "Mastodon", color: "#6364FF" },
];
// ─── Postiz integration ─────────────────────────────────
export type PostizInstance = "cc" | "p2pf" | "bcrg";
export const POSTIZ_INSTANCES: { id: PostizInstance; label: string }[] = [
{ id: "cc", label: "Crypto Commons" },
{ id: "p2pf", label: "P2P Foundation" },
{ id: "bcrg", label: "Bonding Curve Research" },
];
export const PLATFORM_TO_POSTIZ_TYPE: Record<Platform, string> = {
twitter: "x",
linkedin: "linkedin",
instagram: "instagram",
facebook: "facebook",
threads: "threads",
bluesky: "bluesky",
mastodon: "mastodon",
};
export interface PostizConfig {
instance: PostizInstance;
apiKey: string;
}
export interface PostSyncState {
postizPostId?: string;
scheduledAt?: string;
syncedAt?: string;
integrationId?: string;
status: "pending" | "synced" | "failed" | "published";
error?: string;
}
export interface PostMetrics {
likes?: number;
comments?: number;
shares?: number;
impressions?: number;
fetchedAt?: string;
}
// ─── Node data payloads ──────────────────────────────────
export type TriggerKind = "scheduled" | "cron" | "manual";
@ -36,6 +77,8 @@ export interface PostPlatformContent {
export interface PostData {
sharedContent: string;
platforms: PostPlatformContent[];
postizSync?: Record<Platform, PostSyncState>;
metrics?: Record<Platform, PostMetrics>;
}
export interface DelayKind {
@ -128,6 +171,7 @@ export interface Campaign {
status: CampaignStatus;
nodes: CampaignNode[];
edges: CampaignEdge[];
postiz?: PostizConfig;
createdAt: string;
updatedAt: string;
}