From 9c20e625f2ad4cf8083274b045048c791ba6fea8 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 26 Feb 2026 08:48:33 +0000 Subject: [PATCH] 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 --- .../api/campaigns/[id]/integrations/route.ts | 35 ++++ src/app/api/campaigns/[id]/metrics/route.ts | 58 ++++++ src/app/api/campaigns/[id]/sync/route.ts | 126 ++++++++++++ src/components/campaigns/EditorToolbar.tsx | 64 +++++- .../campaigns/PostizSettingsDialog.tsx | 188 ++++++++++++++++++ .../campaigns/details/ConditionForm.tsx | 74 ++++++- src/components/campaigns/details/PostForm.tsx | 77 ++++++- .../campaigns/graph/nodes/PostNode.tsx | 44 +++- src/lib/campaign-timeline.ts | 114 +++++++++++ src/lib/postiz-client.ts | 116 +++++++++++ src/lib/stores/campaign-store.ts | 70 +++++++ src/lib/types/campaign.ts | 44 ++++ 12 files changed, 998 insertions(+), 12 deletions(-) create mode 100644 src/app/api/campaigns/[id]/integrations/route.ts create mode 100644 src/app/api/campaigns/[id]/metrics/route.ts create mode 100644 src/app/api/campaigns/[id]/sync/route.ts create mode 100644 src/components/campaigns/PostizSettingsDialog.tsx create mode 100644 src/lib/campaign-timeline.ts create mode 100644 src/lib/postiz-client.ts diff --git a/src/app/api/campaigns/[id]/integrations/route.ts b/src/app/api/campaigns/[id]/integrations/route.ts new file mode 100644 index 0000000..37801ff --- /dev/null +++ b/src/app/api/campaigns/[id]/integrations/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/campaigns/[id]/metrics/route.ts b/src/app/api/campaigns/[id]/metrics/route.ts new file mode 100644 index 0000000..0599004 --- /dev/null +++ b/src/app/api/campaigns/[id]/metrics/route.ts @@ -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; + } + + 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", + }); +} diff --git a/src/app/api/campaigns/[id]/sync/route.ts b/src/app/api/campaigns/[id]/sync/route.ts new file mode 100644 index 0000000..3cd3e0f --- /dev/null +++ b/src/app/api/campaigns/[id]/sync/route.ts @@ -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; + } + + 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, + }); +} diff --git a/src/components/campaigns/EditorToolbar.tsx b/src/components/campaigns/EditorToolbar.tsx index 0be8ba1..f7cd4a2 100644 --- a/src/components/campaigns/EditorToolbar.tsx +++ b/src/components/campaigns/EditorToolbar.tsx @@ -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() {
+ + + {campaign.postiz && ( + <> + + + + +
+ + {campaign.postiz.instance} +
+ + )} + +
+
+ + + + Postiz Integration + + Connect this campaign to a Postiz instance to schedule and sync + posts. + + + +
+
+ + +
+ +
+ + { + 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" + /> +

+ Find your API key in Postiz Settings → API Keys +

+
+ + + + {testResult && ( +
+ {testResult.ok ? ( + + ) : ( + + )} + {testResult.message} +
+ )} +
+ + + {campaign?.postiz && ( + + )} + + +
+ + ); +} diff --git a/src/components/campaigns/details/ConditionForm.tsx b/src/components/campaigns/details/ConditionForm.tsx index 0c5e584..a53dc65 100644 --- a/src/components/campaigns/details/ConditionForm.tsx +++ b/src/components/campaigns/details/ConditionForm.tsx @@ -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 = { + 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(); + + 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 (
@@ -74,8 +136,18 @@ export function ConditionForm({ nodeId, data }: Props) {

- If {data.metric} {data.operator} {data.threshold} → true path, otherwise → false path + If {data.metric} {data.operator} {data.threshold} → true path, otherwise → false path

+ + {actualValue !== null && ( +
+ Actual value: + {actualValue} + + (from upstream post) + +
+ )}
); } diff --git a/src/components/campaigns/details/PostForm.tsx b/src/components/campaigns/details/PostForm.tsx index e7fea80..6023d10 100644 --- a/src/components/campaigns/details/PostForm.tsx +++ b/src/components/campaigns/details/PostForm.tsx @@ -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 = { + synced: , + failed: , + pending: , + published: , + }; + + return ( +
+ {icons[state.status]} + + {state.status} + + {state.error && ( + + {state.error} + + )} +
+ ); +} + +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 ( +
+ {parts.join(" · ")} + {metrics.fetchedAt && ( + + ({new Date(metrics.fetchedAt).toLocaleTimeString()}) + + )} +
+ ); +} + export function PostForm({ nodeId, data }: Props) { const updateNodeData = useCampaignStore((s) => s.updateNodeData); @@ -57,18 +111,24 @@ export function PostForm({ nodeId, data }: Props) { {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 (
{info?.label} - +
+ {syncState && } + +