66c2bbec8b
- 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>
149 lines
4.2 KiB
TypeScript
149 lines
4.2 KiB
TypeScript
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");
|
|
}
|