feat: complete marketing command center with pipeline, UI, and asset generation
- Dashboard with campaign management, asset gallery, and publishing queue - 7-agent pipeline: trend scout, research, scripts, ad creative, video, copy, distribution - Campaign form with screenshot upload, goal picker, platform selection - Campaign detail view with Details/Pipeline/Assets/Chat tabs - Two-set image generation: Gemini AI (NanoBanana MCP) + Canvas Design posters - Remotion video rendering with phone.png frame and real screenshot alignment - honeyDue branding: blue #0079FF, orange #FF9400, Inter font, warm off-white - Asset cards with source badges (Gemini/Canvas/Remotion/Playwright) - Markdown/JSON render endpoint for viewing pipeline outputs as HTML - Settings page with Tavily, Gemini, Postiz, Nextdoor integration management - Claude Chat for campaign feedback loop with streaming SSE - Postiz publishing modal with scheduling - Auth with NextAuth credentials + JWT sessions - SQLite via Prisma with better-sqlite3 adapter Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+148
@@ -0,0 +1,148 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getSetting } from "./settings";
|
||||
|
||||
const PIPELINE_ROOT = process.env.PIPELINE_ROOT || path.join(process.cwd(), "pipeline");
|
||||
|
||||
async function getPostizConfig() {
|
||||
const url = (await getSetting("POSTIZ_URL")) || "http://localhost:5000";
|
||||
const apiKey = await getSetting("POSTIZ_API_KEY");
|
||||
return { url, apiKey };
|
||||
}
|
||||
|
||||
async function postizFetch(endpoint: string, options: RequestInit = {}) {
|
||||
const { url, apiKey } = await getPostizConfig();
|
||||
const res = await fetch(`${url}/public/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Postiz API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an asset's relative filePath to an absolute path.
|
||||
*/
|
||||
function resolveAssetPath(filePath: string): string {
|
||||
if (path.isAbsolute(filePath)) return filePath;
|
||||
return path.join(PIPELINE_ROOT, filePath);
|
||||
}
|
||||
|
||||
export async function uploadToPostiz(filePath: string) {
|
||||
const absolutePath = resolveAssetPath(filePath);
|
||||
const fileBuffer = fs.readFileSync(absolutePath);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob([fileBuffer]), fileName);
|
||||
|
||||
const { url, apiKey } = await getPostizConfig();
|
||||
const res = await fetch(`${url}/public/v1/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: apiKey },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Postiz upload error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data?.id || !data?.path) {
|
||||
throw new Error(`Postiz upload returned unexpected shape: ${JSON.stringify(data)}`);
|
||||
}
|
||||
return { mediaId: data.id, publicUrl: data.path };
|
||||
}
|
||||
|
||||
// Map our internal platform names to Postiz provider identifiers
|
||||
const PLATFORM_ALIASES: Record<string, string[]> = {
|
||||
instagram: ["instagram", "instagram-standalone", "ig"],
|
||||
tiktok: ["tiktok", "tt"],
|
||||
nextdoor: ["nextdoor"],
|
||||
};
|
||||
|
||||
export async function pushToPostiz(
|
||||
asset: {
|
||||
filePath: string;
|
||||
platform?: string | null;
|
||||
metadata?: string | null;
|
||||
},
|
||||
scheduledAt: string
|
||||
) {
|
||||
const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath);
|
||||
|
||||
const integrations = await getPostizIntegrations();
|
||||
const platform = (asset.platform || "").toLowerCase();
|
||||
const aliases = PLATFORM_ALIASES[platform] || [platform];
|
||||
|
||||
const integration = integrations.find(
|
||||
(i: { identifier?: string; providerIdentifier?: string }) => {
|
||||
const id = (i.identifier || i.providerIdentifier || "").toLowerCase();
|
||||
return aliases.includes(id);
|
||||
}
|
||||
);
|
||||
|
||||
if (!integration) {
|
||||
const available = integrations
|
||||
.map((i: { identifier?: string }) => i.identifier)
|
||||
.join(", ");
|
||||
throw new Error(
|
||||
`No Postiz channel for "${platform}". Available: ${available || "none"}`
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(asset.metadata || "{}");
|
||||
|
||||
// Postiz v1 API post structure
|
||||
const platformSettings: Record<string, unknown> = { __type: platform };
|
||||
if (platform === "instagram") {
|
||||
platformSettings.post_type = "post";
|
||||
} else if (platform === "tiktok") {
|
||||
platformSettings.privacy_level = "PUBLIC_TO_EVERYONE";
|
||||
platformSettings.comment = true;
|
||||
platformSettings.duet = false;
|
||||
platformSettings.stitch = false;
|
||||
platformSettings.content_posting_method = "DIRECT_POST";
|
||||
platformSettings.autoAddMusic = "no";
|
||||
platformSettings.brand_content_toggle = false;
|
||||
platformSettings.brand_organic_toggle = false;
|
||||
}
|
||||
|
||||
const post = await postizFetch("/posts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "schedule",
|
||||
date: scheduledAt,
|
||||
shortLink: false,
|
||||
tags: [],
|
||||
posts: [
|
||||
{
|
||||
integration: { id: integration.id },
|
||||
value: [
|
||||
{
|
||||
content: metadata.caption || "",
|
||||
image: [{ id: mediaId, path: publicUrl }],
|
||||
},
|
||||
],
|
||||
settings: platformSettings,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
export async function getPostizIntegrations() {
|
||||
return postizFetch("/integrations");
|
||||
}
|
||||
Reference in New Issue
Block a user