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:
+31
@@ -0,0 +1,31 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email as string },
|
||||
});
|
||||
if (!user) return null;
|
||||
const valid = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.password
|
||||
);
|
||||
return valid ? { id: user.id, email: user.email, name: user.name } : null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: { strategy: "jwt" },
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
+727
@@ -0,0 +1,727 @@
|
||||
import { spawn } from "child_process";
|
||||
import { mkdirSync } from "fs";
|
||||
import path from "path";
|
||||
import { EventEmitter } from "events";
|
||||
import { prisma } from "./prisma";
|
||||
import { scanOutputDirectory } from "./scanner";
|
||||
import { getAllSettings } from "./settings";
|
||||
|
||||
export const pipelineEvents = new EventEmitter();
|
||||
pipelineEvents.setMaxListeners(50);
|
||||
|
||||
const AGENT_STEPS = [
|
||||
"trend-scout",
|
||||
"marketing-research-agent",
|
||||
"script-writer",
|
||||
"ad-creative-designer",
|
||||
"video-ad-producer",
|
||||
"copywriter-agent",
|
||||
"distribution-agent",
|
||||
] as const;
|
||||
|
||||
export type AgentName = (typeof AGENT_STEPS)[number];
|
||||
|
||||
interface CampaignConfig {
|
||||
name: string;
|
||||
platforms: string[];
|
||||
goal: string;
|
||||
keyMessage: string;
|
||||
socialProof?: string;
|
||||
variations?: number;
|
||||
useTrendReport?: boolean;
|
||||
targetAudience?: string;
|
||||
visualDirection?: string;
|
||||
competitorApps?: string;
|
||||
screenshots?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a detailed campaign prompt that gives each agent enough context.
|
||||
*/
|
||||
export function buildCampaignPrompt(config: CampaignConfig): string {
|
||||
const platforms = config.platforms.join(", ");
|
||||
const variations = config.variations ?? 5;
|
||||
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const taskName = config.name.replace(/\s+/g, "_").toLowerCase();
|
||||
const outputDir = `outputs/${taskName}_${dateStr}`;
|
||||
|
||||
return `# Campaign Brief: "${config.name}"
|
||||
|
||||
## Goal
|
||||
${config.goal === "app_downloads" ? "Drive app downloads" : config.goal === "brand_awareness" ? "Build brand awareness" : "Maximize engagement"}
|
||||
|
||||
## Target Platforms
|
||||
${platforms}
|
||||
|
||||
## Key Message
|
||||
${config.keyMessage}
|
||||
|
||||
${config.socialProof ? `## Social Proof\n${config.socialProof}` : ""}
|
||||
${config.targetAudience ? `## Target Audience\n${config.targetAudience}` : ""}
|
||||
${config.visualDirection ? `## Visual Direction\n${config.visualDirection}` : ""}
|
||||
${config.competitorApps ? `## Competitor Context\n${config.competitorApps}` : ""}
|
||||
${config.screenshots?.length ? `## App Screenshots\nThe user provided ${config.screenshots.length} app screenshot(s) to showcase in the ads. These are real screenshots of the app feature being promoted.\nScreenshot files:\n${config.screenshots.map((p) => `- ${p}`).join("\n")}\n\nIMPORTANT: The ad-creative-designer agent MUST incorporate these screenshots into the static ad layouts. Use them as the hero visual — frame them in a device mockup or place them prominently in the ad composition. The screenshots show the actual feature being advertised, so they should be the centerpiece of the ad creative, not the AI-generated imagery.` : ""}
|
||||
|
||||
## Content Requirements
|
||||
- ${variations} hook variations per platform
|
||||
- Static ads at exact platform dimensions (see knowledge/platform_guidelines.md)
|
||||
- Video ads with platform-appropriate styles:
|
||||
- Instagram: "polished" style — clean motion graphics
|
||||
- TikTok: "authentic" style — raw, native feel
|
||||
- Nextdoor: "local" style — warm, community-focused
|
||||
- Platform-tuned captions with hashtags (except Nextdoor)
|
||||
|
||||
## Output Directory
|
||||
All outputs MUST go to: ${outputDir}/
|
||||
- ${outputDir}/ads/ — static PNG images
|
||||
- ${outputDir}/scripts/ — ad scripts JSON + summary
|
||||
- ${outputDir}/video/ — rendered MP4 files
|
||||
- ${outputDir}/copy/ — platform caption JSON files
|
||||
- ${outputDir}/trend_report.json — trend analysis
|
||||
- ${outputDir}/research_results.json — research data
|
||||
- ${outputDir}/research_brief.md — research summary
|
||||
- ${outputDir}/Publish_${taskName}_${dateStr}.md — publish manifest
|
||||
|
||||
${config.useTrendReport ? "## Use Latest Trends\nCheck outputs/ for the most recent trend_report.json and use those hooks as inspiration.\n" : ""}
|
||||
|
||||
## Instructions
|
||||
Read CLAUDE.md first. Then execute each agent skill in order:
|
||||
1. Read knowledge files (brand_identity.md, platform_guidelines.md, product_campaign.md)
|
||||
2. Run trend-scout skill
|
||||
3. Run marketing-research-agent skill
|
||||
4. Run script-writer skill
|
||||
5. Run ad-creative-designer skill
|
||||
6. Run video-ad-producer skill
|
||||
7. Run copywriter-agent skill
|
||||
8. Run distribution-agent skill
|
||||
|
||||
CRITICAL: Read each skill's SKILL.md before executing. Follow the skill instructions exactly.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a focused prompt for a single agent step.
|
||||
*/
|
||||
function buildAgentPrompt(
|
||||
agentName: string,
|
||||
campaignBrief: string,
|
||||
outputDir: string,
|
||||
screenshots?: string[]
|
||||
): string {
|
||||
const agentInstructions: Record<string, string> = {
|
||||
"trend-scout": `You are the Trend Scout agent.
|
||||
|
||||
Read and follow the skill instructions in skills/trend-scout/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Then execute the Tavily research queries described in the skill.
|
||||
Use the Bash tool to run: npx tavily search "your query here" OR write a Node.js script using @tavily/core.
|
||||
|
||||
Save your output to: ${outputDir}/trend_report.json
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"marketing-research-agent": `You are the Marketing Research Agent.
|
||||
|
||||
Read and follow the skill instructions in skills/marketing-research-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Read the trend report from: ${outputDir}/trend_report.json (if it exists)
|
||||
|
||||
Then execute 5 deep Tavily research queries. Write a Node.js script using @tavily/core to run the searches.
|
||||
|
||||
Save outputs to:
|
||||
- ${outputDir}/research_results.json
|
||||
- ${outputDir}/research_brief.md
|
||||
- ${outputDir}/interactive_report.html
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"script-writer": `You are the Script Writer agent.
|
||||
|
||||
Read and follow the skill instructions in skills/script-writer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/trend_report.json
|
||||
- ${outputDir}/research_results.json
|
||||
- ${outputDir}/research_brief.md
|
||||
|
||||
Write ad scripts with hook-body-CTA structure, timed for video.
|
||||
|
||||
Save outputs to: ${outputDir}/scripts/
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
- ${outputDir}/scripts/scripts_summary.md
|
||||
- Individual script files per platform
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"ad-creative-designer": `You are the Ad Creative Designer agent.
|
||||
|
||||
Read and follow the skill instructions in skills/ad-creative-designer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
- ${outputDir}/research_brief.md
|
||||
|
||||
You MUST produce TWO SETS of image assets:
|
||||
|
||||
---
|
||||
|
||||
## SET 1: Gemini AI-Generated Ads (NanoBanana MCP)
|
||||
|
||||
Use the NanoBanana MCP tools to create polished ad images.${screenshots?.length ? `
|
||||
|
||||
CRITICAL — REFERENCE IMAGES:
|
||||
The user provided real app screenshots that MUST be used as reference images in every generate call.
|
||||
Screenshot absolute paths:
|
||||
${screenshots.map((p) => `- \${CWD}/${p}`).join("\n")}
|
||||
|
||||
Where \${CWD} is your current working directory (use pwd to get the absolute path).` : ""}
|
||||
|
||||
APP ICON: The honeyDue app icon is at assets/icon.png (honeycomb pattern with golden checkmark on dark navy). This icon MUST be visible in every ad — include it as a reference_image alongside the screenshot, and instruct Gemini to place the honeyDue icon prominently near the branding/CTA area of every ad.
|
||||
|
||||
For EACH ad, follow this EXACT sequence:
|
||||
1. First call mcp__nanobanana__set_aspect_ratio to set the correct ratio for the platform (1:1 for feed, 9:16 for stories/reels/tiktok)
|
||||
2. Then call mcp__nanobanana__gemini_generate_image with:
|
||||
- prompt: A detailed description of the ad layout, headline text, brand colors (#0079FF blue, #FF9400 orange), and style${screenshots?.length ? `
|
||||
- reference_images: ["/absolute/path/to/screenshot.png"] — use the REAL app screenshot so Gemini incorporates the actual UI, NOT a made-up version. The prompt should say "Use the provided reference image as the app screenshot shown on the phone screen in the ad. Do NOT change or recreate the app UI — use it exactly as provided."` : ""}
|
||||
- output_path: the destination file path
|
||||
3. Save to ${outputDir}/ads/gemini/
|
||||
4. Name files: gemini_{platform}_{hook}_{dimensions}.png
|
||||
|
||||
Generate exactly 4 Gemini ads with this mix:
|
||||
- 2 WITHOUT people (product-focused, phone mockup + environment/abstract):
|
||||
- 1x Instagram Feed 1080x1080
|
||||
- 1x TikTok 1080x1920
|
||||
- 2 WITH people (lifestyle, person interacting with the app):
|
||||
- 1x Instagram Stories 1080x1920
|
||||
- 1x TikTok 1080x1920
|
||||
|
||||
IMPORTANT: For ads WITH people, show real-looking people naturally using the app — not stock photo poses. For ads WITHOUT people, focus on the phone/app in an environment (floating over house, on a counter, etc.)
|
||||
|
||||
---
|
||||
|
||||
## SET 2: Canvas Design Posters (Museum-quality art)
|
||||
|
||||
Create poster ads using the /skill canvas-design approach. This is a TWO-STEP process:
|
||||
|
||||
### Step A: Design Philosophy
|
||||
First, create a visual design philosophy (.md file) for this campaign's poster aesthetic. Save it to ${outputDir}/ads/posters/design_philosophy.md
|
||||
|
||||
The philosophy should:
|
||||
- Name the aesthetic movement (1-2 words, e.g. "Domestic Geometry" or "Maintenance Modernism")
|
||||
- Be 4-6 paragraphs articulating how the philosophy manifests through space, form, color, scale, rhythm, composition
|
||||
- Emphasize: visual expression over text, spatial communication, artistic interpretation, minimal words
|
||||
- Stress meticulous craftsmanship — the final work must appear as though someone at the top of their field labored over every detail
|
||||
- Draw from the campaign's soul: home maintenance as an act of care, the quiet anxiety of forgetting, the relief of being organized
|
||||
- Brand palette as foundation: #0079FF blue, #FF9400 orange, warm off-white, dark navy
|
||||
|
||||
### Step B: Express the Philosophy as Poster Art
|
||||
Using the philosophy, create each poster as a .png file. For each:
|
||||
1. Write a Python or Node.js script that generates the poster programmatically (using canvas/PDF libraries, or build HTML and screenshot with Playwright)
|
||||
2. Treat each poster as an ART OBJECT — 90% visual design, 10% essential text
|
||||
3. Use repeating patterns, perfect geometric shapes, systematic visual language
|
||||
4. Typography is minimal and design-forward — sparse labels, bold single phrases, never paragraphs
|
||||
5. The campaign's hook text appears as a visual accent, not a headline block
|
||||
6. ${screenshots?.length ? `Incorporate the app screenshot (${screenshots.join(", ")}) inside the phone frame image at assets/phone.png — layer the screenshot BEHIND the phone frame PNG (which has a transparent screen area and an orange rim with dynamic island). The screenshot and phone frame dynamic islands must align. This creates a realistic device mockup. Treat this composite as part of the art, not just dropped in.` : "Use abstract visual representations of the app concept"}
|
||||
7. Every element contained within canvas boundaries with proper margins — nothing overlaps, everything breathes
|
||||
8. The result should look like it could hang in a gallery or appear in a design magazine
|
||||
9. The honeyDue app icon (assets/icon.png — honeycomb with golden checkmark on dark navy) MUST appear in every poster, placed near the branding or CTA area. Use it as an <img> element in the HTML.
|
||||
|
||||
### MANDATORY Typography & Sizing Rules (Social Media Readability)
|
||||
These are viewed on phones at arm's length. Text that looks fine on a monitor is INVISIBLE in a feed.
|
||||
|
||||
- **Minimum font size: 44px** — NO text below 44px, ever. Clamp anything smaller up to 44px.
|
||||
- **Text scale multiplier: 1.13x** — apply 113% to all font sizes before rendering to compensate for viewing distance.
|
||||
- **Hero headline: 75-100px effective** (66-88px raw × 1.13)
|
||||
- **Body/subheadline: 44px minimum** (even if design calls for smaller)
|
||||
- **Stat labels, CTA secondary text: 44px minimum**
|
||||
- **Brand name: ~50px effective** (44px × 1.13)
|
||||
- **Minimum gap between stacked text:**
|
||||
- Giant (100-140px) above + Large below: 50px gap
|
||||
- Large above + Medium below: 40px gap
|
||||
- Medium + Medium: 30px gap
|
||||
- Absolute minimum gap: 20px
|
||||
- **2x internal render recommended:** Render at 2160×3840 (for 9:16) or 2160×2160 (for 1:1), downsample to 1080 for export — produces cleaner anti-aliasing on text and curves.
|
||||
- **Export as JPEG quality=95, subsampling=0** (4:4:4 chroma) — NOT PNG. Instagram converts PNG to JPEG anyway.
|
||||
- **CTA block at fixed bottom position:** CTA_BOTTOM_MARGIN = 60px from bottom edge. All visual content centered between header bottom and CTA top.
|
||||
- **Overflow check:** Always calculate total vertical extent before rendering. If content exceeds canvas, reduce phone width first (most compressible element).
|
||||
|
||||
Save posters to ${outputDir}/ads/posters/
|
||||
Name files: poster_{platform}_{hook}_{dimensions}.png
|
||||
|
||||
Generate at least 4 posters:
|
||||
- 2x Instagram (1 feed 1080x1080, 1 stories 1080x1920)
|
||||
- 2x TikTok cover images (1080x1920)
|
||||
|
||||
---
|
||||
|
||||
Platform dimensions:
|
||||
- Instagram Feed: 1080x1080
|
||||
- Instagram Stories: 1080x1920
|
||||
- Nextdoor Spotlight: 1200x1200
|
||||
- Nextdoor Display: 1200x628
|
||||
|
||||
Save ${outputDir}/ads/ad_manifest.json listing ALL generated ads from BOTH sets, with fields: fileName, set ("gemini" or "poster"), hook, platform, dimensions, headline.
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"video-ad-producer": `You are the Video Ad Producer agent.
|
||||
|
||||
Read and follow the skill instructions in skills/video-ad-producer/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
- ${outputDir}/ads/ad_manifest.json
|
||||
- ${outputDir}/ads/gemini/manifest.json
|
||||
- ${outputDir}/ads/posters/manifest.json
|
||||
|
||||
## Phone Frame Asset
|
||||
A real iPhone frame PNG is at: assets/phone.png (orange rim, dynamic island, transparent screen)
|
||||
Also available in remotion-ad/public/phone.png for use as a Remotion staticFile.
|
||||
${screenshots?.length ? `
|
||||
## App Screenshots
|
||||
Screenshot files: ${screenshots.map((p) => `${p}`).join(", ")}
|
||||
Also available in remotion-ad/public/tasks_overdue.png
|
||||
|
||||
For phone mockup scenes: layer the screenshot BEHIND phone.png so the dynamic islands align. The phone frame has a transparent screen area.
|
||||
` : ""}
|
||||
## Video Creation Rules
|
||||
Create ONE video for EACH static ad in BOTH the Gemini and Canvas Poster sets. Read both manifests to get the full list.
|
||||
|
||||
Each video MUST match its corresponding static ad:
|
||||
- Same hook text, same message, same CTA
|
||||
- Same tone (if the ad is moody/dark, the video is moody/dark; if clean/minimal, the video is clean/minimal)
|
||||
- The static ad image can appear as a scene in the video (e.g., the final frame)
|
||||
|
||||
For each video:
|
||||
1. Create a Remotion composition in remotion-ad/src/ with a unique CompositionId
|
||||
2. Use the phone.png frame + screenshot for the phone reveal scene
|
||||
3. Structure: Hook text (2-3s) → Phone reveal with app screenshot (4-6s) → Social proof (2-3s) → CTA (2-3s)
|
||||
4. Render using: cd remotion-ad && npx remotion render src/index.ts CompositionId --output ../${outputDir}/video/filename.mp4
|
||||
|
||||
Video specs:
|
||||
- Instagram Reels: 1080x1920, 15s, 30fps
|
||||
- TikTok: 1080x1920, 12s, 30fps
|
||||
|
||||
Naming: video_{source}_{platform}_{hook}_{dimensions}.mp4
|
||||
Where source is "gemini" or "poster"
|
||||
|
||||
Save scene_plans.json and all MP4s to: ${outputDir}/video/
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"copywriter-agent": `You are the Copywriter agent.
|
||||
|
||||
Read and follow the skill instructions in skills/copywriter-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
- knowledge/product_campaign.md
|
||||
|
||||
Read the upstream outputs:
|
||||
- ${outputDir}/research_results.json
|
||||
- ${outputDir}/scripts/scripts_all.json
|
||||
- ${outputDir}/ads/ad_manifest.json
|
||||
|
||||
Write platform-tuned captions following the Hook → Value → CTA → Hashtags structure.
|
||||
|
||||
Save outputs to:
|
||||
- ${outputDir}/copy/instagram_captions.json
|
||||
- ${outputDir}/copy/tiktok_captions.json
|
||||
- ${outputDir}/copy/nextdoor_posts.json
|
||||
- ${outputDir}/copy/copy_matrix.json (maps captions to creatives)
|
||||
|
||||
${campaignBrief}`,
|
||||
|
||||
"distribution-agent": `You are the Distribution Agent.
|
||||
|
||||
Read and follow the skill instructions in skills/distribution-agent/SKILL.md exactly.
|
||||
|
||||
First, read these knowledge files:
|
||||
- knowledge/brand_identity.md
|
||||
- knowledge/platform_guidelines.md
|
||||
|
||||
Gather ALL outputs from the campaign:
|
||||
- ${outputDir}/ads/ (all PNG files)
|
||||
- ${outputDir}/video/ (all MP4 files)
|
||||
- ${outputDir}/copy/ (all caption JSON files)
|
||||
- ${outputDir}/scripts/ (scripts)
|
||||
|
||||
Create a publish manifest at: ${outputDir}/Publish_manifest.md
|
||||
|
||||
The manifest should include:
|
||||
- List of all assets with file paths
|
||||
- Recommended caption for each asset
|
||||
- Recommended posting schedule
|
||||
- Platform-specific notes
|
||||
- A review checklist
|
||||
|
||||
IMPORTANT: Do NOT publish anything. Only create the manifest for human review.
|
||||
|
||||
${campaignBrief}`,
|
||||
};
|
||||
|
||||
return agentInstructions[agentName] || `Execute the ${agentName} skill. ${campaignBrief}`;
|
||||
}
|
||||
|
||||
const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes per agent
|
||||
|
||||
const AGENT_LABELS: Record<string, string> = {
|
||||
"trend-scout": "Trend Scout",
|
||||
"marketing-research-agent": "Research Agent",
|
||||
"script-writer": "Script Writer",
|
||||
"ad-creative-designer": "Ad Creative Designer",
|
||||
"video-ad-producer": "Video Ad Producer",
|
||||
"copywriter-agent": "Copywriter",
|
||||
"distribution-agent": "Distribution Agent",
|
||||
};
|
||||
|
||||
function humanizeAgentError(agentName: string, code: number | null, stderr: string): string {
|
||||
const label = AGENT_LABELS[agentName] || agentName;
|
||||
|
||||
// Signal-based exits
|
||||
if (code === 143 || code === 137) {
|
||||
return `${label} was stopped (process terminated)`;
|
||||
}
|
||||
if (code === 130) {
|
||||
return `${label} was interrupted`;
|
||||
}
|
||||
|
||||
// Try to extract a meaningful error from stderr, ignoring noise
|
||||
const meaningful = stderr
|
||||
.split("\n")
|
||||
.filter((line) => {
|
||||
const l = line.trim().toLowerCase();
|
||||
if (!l) return false;
|
||||
if (l.includes("warning:")) return false;
|
||||
if (l.includes("stdin")) return false;
|
||||
if (l.includes("piping from")) return false;
|
||||
if (l.includes("deprecation")) return false;
|
||||
return true;
|
||||
})
|
||||
.slice(-3)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
if (meaningful) {
|
||||
// Cap length and clean up
|
||||
const cleaned = meaningful.slice(0, 200).replace(/\s+/g, " ");
|
||||
return `${label} failed: ${cleaned}`;
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
if (code !== null && code !== 0) {
|
||||
return `${label} failed (exit code ${code})`;
|
||||
}
|
||||
|
||||
return `${label} failed unexpectedly`;
|
||||
}
|
||||
|
||||
export async function runAgentStep(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
env: Record<string, string>
|
||||
): Promise<{ output: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"-p",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--allowedTools",
|
||||
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
|
||||
];
|
||||
|
||||
const claude = spawn("claude", args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
// Timeout watchdog
|
||||
const timeout = setTimeout(() => {
|
||||
claude.kill("SIGTERM");
|
||||
reject(new Error(`${agentName} timed out after ${AGENT_TIMEOUT_MS / 60000} minutes`));
|
||||
}, AGENT_TIMEOUT_MS);
|
||||
|
||||
claude.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
claude.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
claude.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
// Extract meaningful text from stream-json stdout
|
||||
let summary = "";
|
||||
const lines = stdout.split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.result?.text) summary = event.result.text;
|
||||
else if (event.type === "text" && event.text) summary += event.text;
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
resolve({ output: summary || stdout.slice(-1000) });
|
||||
} else {
|
||||
reject(new Error(humanizeAgentError(agentName, code, stderr)));
|
||||
}
|
||||
});
|
||||
|
||||
claude.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to start ${agentName}: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load API keys from settings DB to pass to Claude subprocess environment.
|
||||
*/
|
||||
async function loadPipelineEnv(): Promise<Record<string, string>> {
|
||||
const settings = await getAllSettings();
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
if (settings.TAVILY_API_KEY) env.TAVILY_API_KEY = settings.TAVILY_API_KEY;
|
||||
if (settings.POSTIZ_URL) env.POSTIZ_URL = settings.POSTIZ_URL;
|
||||
if (settings.POSTIZ_API_KEY) env.POSTIZ_API_KEY = settings.POSTIZ_API_KEY;
|
||||
if (settings.GEMINI_API_KEY) env.GEMINI_API_KEY = settings.GEMINI_API_KEY;
|
||||
if (settings.NEXTDOOR_API_TOKEN) env.NEXTDOOR_API_TOKEN = settings.NEXTDOOR_API_TOKEN;
|
||||
if (settings.NEXTDOOR_ADVERTISER_ID) env.NEXTDOOR_ADVERTISER_ID = settings.NEXTDOOR_ADVERTISER_ID;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
export async function launchPipeline(
|
||||
campaignId: string,
|
||||
prompt: string,
|
||||
cwd: string
|
||||
) {
|
||||
// Load API keys from settings
|
||||
const pipelineEnv = await loadPipelineEnv();
|
||||
|
||||
// Get campaign for output path and config
|
||||
const campaignData = await prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||
const outputDir = campaignData?.outputPath || "outputs/default";
|
||||
const campaignConfig = campaignData?.config ? JSON.parse(campaignData.config) : {};
|
||||
const screenshots: string[] = campaignConfig.screenshots || [];
|
||||
|
||||
// Create output directories
|
||||
const dirs = ["ads", "scripts", "video", "copy"];
|
||||
for (const dir of dirs) {
|
||||
mkdirSync(path.join(cwd, outputDir, dir), { recursive: true });
|
||||
}
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: { status: "running" },
|
||||
});
|
||||
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "pipeline_started",
|
||||
campaignId,
|
||||
});
|
||||
|
||||
for (const agentName of AGENT_STEPS) {
|
||||
const agentRun = await prisma.agentRun.create({
|
||||
data: {
|
||||
campaignId,
|
||||
agentName,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "agent_started",
|
||||
agentName,
|
||||
agentRunId: agentRun.id,
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const agentPrompt = buildAgentPrompt(agentName, prompt, outputDir, screenshots);
|
||||
const { output } = await runAgentStep(agentName, agentPrompt, cwd, pipelineEnv);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
await prisma.agentRun.update({
|
||||
where: { id: agentRun.id },
|
||||
data: {
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
durationMs,
|
||||
outputSummary: output.slice(0, 500),
|
||||
outputPath: outputDir,
|
||||
},
|
||||
});
|
||||
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "agent_completed",
|
||||
agentName,
|
||||
agentRunId: agentRun.id,
|
||||
durationMs,
|
||||
outputSummary: output.slice(0, 200),
|
||||
});
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
await prisma.agentRun.update({
|
||||
where: { id: agentRun.id },
|
||||
data: {
|
||||
status: "failed",
|
||||
completedAt: new Date(),
|
||||
durationMs,
|
||||
error: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "agent_failed",
|
||||
agentName,
|
||||
agentRunId: agentRun.id,
|
||||
error: errorMessage.slice(0, 200),
|
||||
});
|
||||
|
||||
// Continue to next agent even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Scan output directory for generated assets
|
||||
if (campaignData?.outputPath) {
|
||||
try {
|
||||
const result = await scanOutputDirectory(campaignId, campaignData.outputPath, cwd);
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "scan_complete",
|
||||
scanned: result.scanned,
|
||||
created: result.created,
|
||||
});
|
||||
} catch {
|
||||
// Scanner errors shouldn't block pipeline completion
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaignId },
|
||||
data: { status: "review" },
|
||||
});
|
||||
|
||||
pipelineEvents.emit(campaignId, {
|
||||
type: "pipeline_complete",
|
||||
status: "review",
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
sessionId: string | null,
|
||||
message: string,
|
||||
cwd: string
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
const pipelineEnv = await loadPipelineEnv();
|
||||
|
||||
const args = [
|
||||
"-p",
|
||||
message,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--allowedTools",
|
||||
"Read,Edit,Write,Bash,Grep,Glob,mcp__nanobanana__generate_image",
|
||||
];
|
||||
if (sessionId) args.push("--resume", sessionId);
|
||||
|
||||
const claude = spawn("claude", args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...pipelineEnv },
|
||||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
claude.stdout.on("data", (chunk: Buffer) => {
|
||||
const lines = chunk.toString().split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
// Handle various Claude CLI stream-json event types
|
||||
let text = "";
|
||||
if (event.type === "content_block_delta" && event.delta?.text) {
|
||||
text = event.delta.text;
|
||||
} else if (event.type === "assistant" && event.message) {
|
||||
text = event.message;
|
||||
} else if (event.type === "text" && event.text) {
|
||||
text = event.text;
|
||||
} else if (event.result?.text) {
|
||||
text = event.result.text;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
|
||||
);
|
||||
}
|
||||
|
||||
// Capture session ID if provided
|
||||
if (event.session_id || event.sessionId) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ sessionId: event.session_id || event.sessionId })}\n\n`
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON line — skip (likely progress output)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
claude.on("close", () => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
|
||||
);
|
||||
controller.close();
|
||||
});
|
||||
|
||||
claude.on("error", () => {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { getSetting } from "./settings";
|
||||
|
||||
const NEXTDOOR_API_URL = "https://ads.nextdoor.com/v1";
|
||||
|
||||
async function getNextdoorConfig() {
|
||||
const token = await getSetting("NEXTDOOR_API_TOKEN");
|
||||
const advertiserId = await getSetting("NEXTDOOR_ADVERTISER_ID");
|
||||
return { token, advertiserId };
|
||||
}
|
||||
|
||||
async function nextdoorFetch(query: string, variables: Record<string, unknown> = {}) {
|
||||
const { token } = await getNextdoorConfig();
|
||||
const res = await fetch(`${NEXTDOOR_API_URL}/graphql`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Nextdoor API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.errors?.length) {
|
||||
throw new Error(`Nextdoor GraphQL error: ${data.errors[0].message}`);
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createNextdoorCampaign(
|
||||
name: string,
|
||||
budget: number,
|
||||
schedule: { startDate: string; endDate: string }
|
||||
) {
|
||||
const mutation = `
|
||||
mutation CreateCampaign($input: CreateCampaignInput!) {
|
||||
createCampaign(input: $input) {
|
||||
campaign { id name status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return nextdoorFetch(mutation, {
|
||||
input: {
|
||||
advertiserId: (await getNextdoorConfig()).advertiserId,
|
||||
name,
|
||||
objective: "WEBSITE_CONVERSION",
|
||||
budget: { amount: budget, currency: "USD" },
|
||||
schedule,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNextdoorAdGroup(
|
||||
campaignId: string,
|
||||
targeting: Record<string, unknown>
|
||||
) {
|
||||
const mutation = `
|
||||
mutation CreateAdGroup($input: CreateAdGroupInput!) {
|
||||
createAdGroup(input: $input) {
|
||||
adGroup { id name status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return nextdoorFetch(mutation, {
|
||||
input: {
|
||||
campaignId,
|
||||
name: "Auto-generated Ad Group",
|
||||
targeting,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadNextdoorCreative(imageUrl: string) {
|
||||
const mutation = `
|
||||
mutation CreateCreative($input: CreateCreativeInput!) {
|
||||
createCreative(input: $input) {
|
||||
creative { id status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return nextdoorFetch(mutation, {
|
||||
input: {
|
||||
advertiserId: (await getNextdoorConfig()).advertiserId,
|
||||
imageUrl,
|
||||
type: "IMAGE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNextdoorAd(
|
||||
adGroupId: string,
|
||||
creativeId: string,
|
||||
copy: { headline: string; body: string; ctaText: string; destinationUrl: string }
|
||||
) {
|
||||
const mutation = `
|
||||
mutation CreateAd($input: CreateAdInput!) {
|
||||
createAd(input: $input) {
|
||||
ad { id name status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return nextdoorFetch(mutation, {
|
||||
input: {
|
||||
adGroupId,
|
||||
creativeId,
|
||||
headline: copy.headline,
|
||||
body: copy.body,
|
||||
callToAction: copy.ctaText,
|
||||
destinationUrl: copy.destinationUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
+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");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from "./generated/prisma/client";
|
||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaBetterSqlite3({
|
||||
url: process.env.DATABASE_URL || "file:./prisma/data/marketing.db",
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma || createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { readdirSync, readFileSync, statSync, existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
interface ScannedFile {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
platform: string | null;
|
||||
format: string;
|
||||
dimensions: string | null;
|
||||
metadata: string | null;
|
||||
}
|
||||
|
||||
const FORMAT_TO_TYPE: Record<string, string> = {
|
||||
png: "image",
|
||||
jpg: "image",
|
||||
jpeg: "image",
|
||||
webp: "image",
|
||||
gif: "image",
|
||||
mp4: "video",
|
||||
webm: "video",
|
||||
json: "copy",
|
||||
txt: "copy",
|
||||
md: "research",
|
||||
html: "research",
|
||||
};
|
||||
|
||||
function inferPlatform(fileName: string, filePath: string): string | null {
|
||||
const lower = (fileName + filePath).toLowerCase();
|
||||
if (lower.includes("instagram") || lower.includes("ig_") || lower.includes("ig ")) return "instagram";
|
||||
if (lower.includes("tiktok") || lower.includes("tt_") || lower.includes("tik_tok")) return "tiktok";
|
||||
if (lower.includes("nextdoor") || lower.includes("nd_")) return "nextdoor";
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferDimensions(fileName: string): string | null {
|
||||
const match = fileName.match(/(\d{3,4})x(\d{3,4})/);
|
||||
return match ? `${match[1]}x${match[2]}` : null;
|
||||
}
|
||||
|
||||
function inferTypeFromPath(filePath: string, format: string): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.includes("/ads/")) return "image";
|
||||
if (lower.includes("/video/")) return "video";
|
||||
if (lower.includes("/copy/")) return "copy";
|
||||
if (lower.includes("/scripts/")) return "script";
|
||||
return FORMAT_TO_TYPE[format] || "research";
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to read metadata from adjacent JSON files or manifest.
|
||||
* For an image "instagram_feed_hook_a_1080x1080.png", look for:
|
||||
* - "instagram_feed_hook_a_1080x1080.json" (same name, .json ext)
|
||||
* - "ad_manifest.json" in same directory
|
||||
* For copy JSON files, read the file itself as metadata.
|
||||
*/
|
||||
function loadMetadata(fullPath: string, format: string): string | null {
|
||||
try {
|
||||
// For JSON files, read the content as metadata
|
||||
if (format === "json") {
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
const parsed = JSON.parse(content);
|
||||
// Extract caption/summary if it's an array of captions
|
||||
if (Array.isArray(parsed)) {
|
||||
return JSON.stringify({ captions: parsed.slice(0, 3), totalVariations: parsed.length });
|
||||
}
|
||||
return content.slice(0, 2000);
|
||||
}
|
||||
|
||||
// For media files, look for adjacent JSON with same name
|
||||
const jsonPath = fullPath.replace(/\.[^.]+$/, ".json");
|
||||
if (existsSync(jsonPath)) {
|
||||
return readFileSync(jsonPath, "utf-8").slice(0, 2000);
|
||||
}
|
||||
|
||||
// Look for manifest in same directory
|
||||
const dir = path.dirname(fullPath);
|
||||
const manifestPath = path.join(dir, "ad_manifest.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
const fileName = path.basename(fullPath);
|
||||
// Find this file's entry in the manifest
|
||||
if (Array.isArray(manifest)) {
|
||||
const entry = manifest.find((e: { fileName?: string; file?: string }) =>
|
||||
e.fileName === fileName || e.file === fileName
|
||||
);
|
||||
if (entry) return JSON.stringify(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for scene_plans.json for video files
|
||||
if (format === "mp4" || format === "webm") {
|
||||
const scenePlansPath = path.join(dir, "scene_plans.json");
|
||||
if (existsSync(scenePlansPath)) {
|
||||
const plans = JSON.parse(readFileSync(scenePlansPath, "utf-8"));
|
||||
const fileName = path.basename(fullPath);
|
||||
if (plans[fileName]) return JSON.stringify(plans[fileName]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Metadata loading is best-effort
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanDirectory(dir: string, baseDir: string): ScannedFile[] {
|
||||
const files: ScannedFile[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(".")) continue;
|
||||
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...scanDirectory(fullPath, baseDir));
|
||||
} else {
|
||||
const ext = path.extname(entry).toLowerCase().slice(1);
|
||||
if (!ext || ext === "gitkeep") continue;
|
||||
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
const type = inferTypeFromPath(relativePath, ext);
|
||||
const metadata = loadMetadata(fullPath, ext);
|
||||
|
||||
files.push({
|
||||
filePath: relativePath,
|
||||
fileName: entry,
|
||||
type,
|
||||
platform: inferPlatform(entry, relativePath),
|
||||
format: ext,
|
||||
dimensions: inferDimensions(entry),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function scanOutputDirectory(
|
||||
campaignId: string,
|
||||
outputPath: string,
|
||||
pipelineRoot: string
|
||||
) {
|
||||
const fullOutputPath = path.join(pipelineRoot, outputPath);
|
||||
const files = scanDirectory(fullOutputPath, pipelineRoot);
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const existing = await prisma.asset.findFirst({
|
||||
where: {
|
||||
campaignId,
|
||||
filePath: file.filePath,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
campaignId,
|
||||
type: file.type,
|
||||
platform: file.platform,
|
||||
format: file.format,
|
||||
filePath: file.filePath,
|
||||
fileName: file.fileName,
|
||||
dimensions: file.dimensions,
|
||||
metadata: file.metadata,
|
||||
status: "draft",
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned: files.length, created };
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// Settings keys and their env var fallbacks
|
||||
const SETTINGS_KEYS = {
|
||||
// Postiz
|
||||
POSTIZ_URL: { envVar: "POSTIZ_URL", label: "Postiz URL", placeholder: "http://localhost:5000" },
|
||||
POSTIZ_API_KEY: { envVar: "POSTIZ_API_KEY", label: "Postiz API Key", placeholder: "your-postiz-api-key", secret: true },
|
||||
|
||||
// Tavily (Research)
|
||||
TAVILY_API_KEY: { envVar: "TAVILY_API_KEY", label: "Tavily API Key", placeholder: "tvly-...", secret: true },
|
||||
|
||||
// Google Gemini (NanoBanana image generation)
|
||||
GEMINI_API_KEY: { envVar: "GEMINI_API_KEY", label: "Google Gemini API Key", placeholder: "AIza...", secret: true },
|
||||
|
||||
// Nextdoor
|
||||
NEXTDOOR_API_TOKEN: { envVar: "NEXTDOOR_API_TOKEN", label: "Nextdoor API Token", placeholder: "your-nextdoor-token", secret: true },
|
||||
NEXTDOOR_ADVERTISER_ID: { envVar: "NEXTDOOR_ADVERTISER_ID", label: "Nextdoor Advertiser ID", placeholder: "your-advertiser-id" },
|
||||
} as const;
|
||||
|
||||
export type SettingKey = keyof typeof SETTINGS_KEYS;
|
||||
|
||||
export const SETTINGS_CONFIG = SETTINGS_KEYS;
|
||||
|
||||
// Grouped for UI
|
||||
export const SETTINGS_GROUPS = [
|
||||
{
|
||||
name: "Postiz",
|
||||
description: "Self-hosted social media scheduling. Handles Instagram and TikTok publishing.",
|
||||
docsUrl: "https://postiz.com",
|
||||
keys: ["POSTIZ_URL", "POSTIZ_API_KEY"] as SettingKey[],
|
||||
},
|
||||
{
|
||||
name: "Tavily",
|
||||
description: "AI-powered web research. Used by the Trend Scout and Research agents. Free tier: 1,000 searches/month.",
|
||||
docsUrl: "https://tavily.com",
|
||||
keys: ["TAVILY_API_KEY"] as SettingKey[],
|
||||
},
|
||||
{
|
||||
name: "Gemini",
|
||||
description: "Google Gemini powers NanoBanana MCP for AI image generation in static ads. ~$0.04-0.13/image.",
|
||||
docsUrl: "https://aistudio.google.com/apikey",
|
||||
keys: ["GEMINI_API_KEY"] as SettingKey[],
|
||||
},
|
||||
{
|
||||
name: "Nextdoor",
|
||||
description: "Direct Nextdoor Ads API integration for local advertising.",
|
||||
docsUrl: "https://developer.nextdoor.com",
|
||||
keys: ["NEXTDOOR_API_TOKEN", "NEXTDOOR_ADVERTISER_ID"] as SettingKey[],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a setting value. Checks DB first, falls back to env var.
|
||||
*/
|
||||
export async function getSetting(key: SettingKey): Promise<string> {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key } });
|
||||
if (setting?.value) return setting.value;
|
||||
} catch {
|
||||
// DB not available, fall through to env
|
||||
}
|
||||
|
||||
const config = SETTINGS_KEYS[key];
|
||||
return process.env[config.envVar] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings as a map.
|
||||
*/
|
||||
export async function getAllSettings(): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, config] of Object.entries(SETTINGS_KEYS)) {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({ where: { key } });
|
||||
result[key] = setting?.value || process.env[config.envVar] || "";
|
||||
} catch {
|
||||
result[key] = process.env[config.envVar] || "";
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a setting to the database.
|
||||
*/
|
||||
export async function saveSetting(key: SettingKey, value: string) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connectivity status for each integration.
|
||||
*/
|
||||
export async function checkIntegrationStatus(): Promise<Record<string, { connected: boolean; error?: string }>> {
|
||||
const settings = await getAllSettings();
|
||||
const status: Record<string, { connected: boolean; error?: string }> = {};
|
||||
|
||||
// Postiz
|
||||
if (settings.POSTIZ_URL && settings.POSTIZ_API_KEY) {
|
||||
try {
|
||||
const res = await fetch(`${settings.POSTIZ_URL}/public/v1/integrations`, {
|
||||
headers: { Authorization: `Bearer ${settings.POSTIZ_API_KEY}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
status.postiz = { connected: res.ok };
|
||||
if (!res.ok) status.postiz.error = `HTTP ${res.status}`;
|
||||
} catch (e) {
|
||||
status.postiz = { connected: false, error: e instanceof Error ? e.message : "Connection failed" };
|
||||
}
|
||||
} else {
|
||||
status.postiz = { connected: false, error: "Not configured" };
|
||||
}
|
||||
|
||||
// Tavily
|
||||
if (settings.TAVILY_API_KEY) {
|
||||
status.tavily = { connected: true }; // No ping endpoint, just check if key exists
|
||||
} else {
|
||||
status.tavily = { connected: false, error: "Not configured" };
|
||||
}
|
||||
|
||||
// Gemini
|
||||
if (settings.GEMINI_API_KEY) {
|
||||
status.gemini = { connected: true };
|
||||
} else {
|
||||
status.gemini = { connected: false, error: "Not configured" };
|
||||
}
|
||||
|
||||
// Nextdoor
|
||||
if (settings.NEXTDOOR_API_TOKEN && settings.NEXTDOOR_ADVERTISER_ID) {
|
||||
status.nextdoor = { connected: true };
|
||||
} else {
|
||||
status.nextdoor = { connected: false, error: "Not configured" };
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
Reference in New Issue
Block a user