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:
Trey t
2026-03-23 21:05:26 -05:00
parent 6b08cfb73a
commit 66c2bbec8b
113 changed files with 12741 additions and 138 deletions
+148
View File
@@ -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");
}