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:
parent
3e7e57dd79
commit
9c20e625f2
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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 → 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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> → true path, otherwise → 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue