# Marketing Command Center — Frontend Architecture **What you want:** A self-hosted web dashboard where you log in, see everything your agents produced, cherry-pick ads to push to Postiz, and drop into a Claude session to give feedback — all from the browser. Everything runs in Docker. No external services except Postiz and Claude API. --- ## System Overview ``` ┌─────────────────────────────────────────────────────────┐ │ YOUR BROWSER │ │ │ │ ┌──────────────┐ ┌──────────┐ ┌───────────────────┐ │ │ │ Campaign │ │ Asset │ │ Claude Chat │ │ │ │ Dashboard │ │ Gallery │ │ (feedback/edits) │ │ │ └──────┬───────┘ └────┬─────┘ └────────┬──────────┘ │ └─────────┼───────────────┼─────────────────┼──────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────┐ │ NEXT.JS APP (Docker Container) │ │ │ │ API Routes: │ │ /api/campaigns /api/assets /api/claude │ │ /api/publish /api/agents /api/auth │ │ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │ │ │ SQLite │ │ Local │ │ Claude Agent SDK (TS) │ │ │ │ Database │ │ File │ │ Spawns Claude Code │ │ │ │ (Prisma) │ │ Storage │ │ as subprocess │ │ │ └──────────┘ └──────────┘ └───────────┬───────────┘ │ └──────────────────────────────────────────┼───────────────┘ │ ┌──────────────────────┼──────────┐ │ │ │ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌────────┐ │ Postiz │ │ Tavily │ │Nextdoor│ │ (Docker) │ │ API │ │ API │ │ IG+TikTok│ │ Research │ │ Direct │ │ +Media │ └──────────┘ └────────┘ │ Hosting │ └──────────┘ ``` **Zero external services for core functionality.** Postiz and your Next.js app both run in Docker on your machine. The only outbound calls are to Tavily (research), Nextdoor API (publishing), and Anthropic (Claude). --- ## Tech Stack | Layer | Technology | Why | |-------|-----------|-----| | **Framework** | Next.js 15+ (App Router) | Full-stack React, API routes, SSR | | **UI** | shadcn/ui + Tailwind CSS | Clean components, fast to build | | **Auth** | NextAuth.js (Auth.js v5) | Self-contained — credentials provider, no external auth service | | **Database** | SQLite via Prisma | Zero infrastructure — single file, runs in Docker volume | | **File Storage** | Local filesystem (Docker volume) | Pipeline outputs stored locally, Postiz handles public hosting | | **Claude** | Claude Agent SDK (TypeScript) | Spawn Claude sessions, stream responses | | **Publishing** | Postiz (@postiz/node SDK) | Media upload + hosting + scheduling + IG/TikTok publishing | | **Realtime** | Server-Sent Events (SSE) | Pipeline progress streaming — no Redis/WebSocket service needed | | **Deployment** | Docker Compose | Everything self-hosted, single `docker compose up` | --- ## Database Schema (SQLite via Prisma) ```prisma // prisma/schema.prisma datasource db { provider = "sqlite" url = "file:./data/marketing.db" } generator client { provider = "prisma-client-js" } model User { id String @id @default(cuid()) email String @unique password String // bcrypt hashed name String? createdAt DateTime @default(now()) } model Campaign { id String @id @default(cuid()) name String status String @default("draft") // draft, running, review, approved, published prompt String? // the trigger prompt platforms String // JSON array: ["instagram","tiktok","nextdoor"] config String? // JSON: full campaign config from form outputPath String? // local path to outputs/ createdAt DateTime @default(now()) updatedAt DateTime @updatedAt agentRuns AgentRun[] assets Asset[] claudeSessions ClaudeSession[] } model AgentRun { id String @id @default(cuid()) campaignId String campaign Campaign @relation(fields: [campaignId], references: [id]) agentName String // trend-scout, research, script-writer, etc. status String @default("pending") // pending, running, completed, failed startedAt DateTime? completedAt DateTime? durationMs Int? outputSummary String? outputPath String? tokenUsage Int? error String? assets Asset[] createdAt DateTime @default(now()) } model Asset { id String @id @default(cuid()) campaignId String campaign Campaign @relation(fields: [campaignId], references: [id]) agentRunId String? agentRun AgentRun? @relation(fields: [agentRunId], references: [id]) type String // image, video, copy, research, script platform String? // instagram, tiktok, nextdoor, all format String? // png, mp4, json, txt, html, md filePath String // local path in outputs/ fileName String dimensions String? // 1080x1080, 1080x1920, etc. metadata String? // JSON: caption, hashtags, hook text, scene plan status String @default("draft") // draft, approved, rejected, published publishedTo String? // JSON array of platforms published to postizPostId String? // ID from Postiz after scheduling postizMediaId String? // media ID from Postiz upload createdAt DateTime @default(now()) } model ClaudeSession { id String @id @default(cuid()) campaignId String campaign Campaign @relation(fields: [campaignId], references: [id]) sessionId String? // Claude Code session ID for --resume messages String? // JSON array of conversation history createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model TrendReport { id String @id @default(cuid()) name String filePath String // path to the HTML/JSON report summary String? // brief description createdAt DateTime @default(now()) } ``` **Why SQLite over Postgres:** - Zero config — no separate database container - Single file — easy to backup (just copy `marketing.db`) - Fast enough for a single-user dashboard - Runs anywhere Docker runs - Prisma makes it trivial to switch to Postgres later if needed --- ## Authentication (NextAuth.js — Self-Contained) ```typescript // lib/auth.ts import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import { PrismaAdapter } from "@auth/prisma-adapter"; import bcrypt from "bcryptjs"; import { prisma } from "./prisma"; export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(prisma), 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 ? user : null; }, }), ], session: { strategy: "jwt" }, secret: process.env.NEXTAUTH_SECRET, }); ``` **First-run setup:** A seed script creates your admin user. No external auth provider needed. ```typescript // prisma/seed.ts import bcrypt from "bcryptjs"; import { prisma } from "../lib/prisma"; async function main() { await prisma.user.upsert({ where: { email: process.env.ADMIN_EMAIL! }, update: {}, create: { email: process.env.ADMIN_EMAIL!, password: await bcrypt.hash(process.env.ADMIN_PASSWORD!, 12), name: "Admin", }, }); } main(); ``` --- ## Real-Time Pipeline Progress (Server-Sent Events) No Redis, no WebSocket server, no Supabase Realtime. Just native SSE from a Next.js API route: ```typescript // app/api/campaigns/[id]/stream/route.ts export async function GET(req: Request, { params }: { params: { id: string } }) { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { // Subscribe to pipeline events for this campaign const unsubscribe = pipelineEvents.subscribe(params.id, (event) => { controller.enqueue( encoder.encode(`data: ${JSON.stringify(event)}\n\n`) ); }); // Keep connection alive const keepAlive = setInterval(() => { controller.enqueue(encoder.encode(`: keepalive\n\n`)); }, 15000); req.signal.addEventListener("abort", () => { unsubscribe(); clearInterval(keepAlive); controller.close(); }); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } ``` Frontend hook: ```typescript // hooks/usePipelineProgress.ts export function usePipelineProgress(campaignId: string) { const [agents, setAgents] = useState([]); useEffect(() => { const source = new EventSource(`/api/campaigns/${campaignId}/stream`); source.onmessage = (event) => { const data = JSON.parse(event.data); setAgents((prev) => updateAgentStatus(prev, data)); }; return () => source.close(); }, [campaignId]); return agents; } ``` --- ## Claude Integration ```typescript // lib/claude.ts import { spawn } from "child_process"; import { EventEmitter } from "events"; export const pipelineEvents = new EventEmitter(); export async function launchPipeline(campaignId: string, prompt: string, cwd: string) { // Update campaign status await prisma.campaign.update({ where: { id: campaignId }, data: { status: "running" }, }); const claude = spawn("claude", [ "-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", "Read,Edit,Write,Bash,Grep,Glob", ], { cwd, env: { ...process.env } }); claude.stdout.on("data", async (chunk) => { const lines = chunk.toString().split("\n").filter(Boolean); for (const line of lines) { try { const event = JSON.parse(line); // Detect agent starts/completions from tool use events const agentEvent = parseAgentEvent(event); if (agentEvent) { // Save to database await upsertAgentRun(campaignId, agentEvent); // Broadcast to SSE clients pipelineEvents.emit(campaignId, agentEvent); } // Detect new files created const fileEvent = parseFileCreatedEvent(event); if (fileEvent) { await createAsset(campaignId, fileEvent); pipelineEvents.emit(campaignId, { type: "asset_created", ...fileEvent }); } } catch {} } }); claude.on("close", async (code) => { const status = code === 0 ? "review" : "failed"; await prisma.campaign.update({ where: { id: campaignId }, data: { status }, }); pipelineEvents.emit(campaignId, { type: "pipeline_complete", status }); }); } // Claude chat session for feedback export async function sendChatMessage( sessionId: string | null, message: string, cwd: string ): AsyncGenerator { const args = ["-p", message, "--output-format", "stream-json", "--verbose"]; if (sessionId) args.push("--resume", sessionId); args.push("--allowedTools", "Read,Edit,Write,Bash,Grep,Glob"); const claude = spawn("claude", args, { cwd, env: { ...process.env } }); // Stream text deltas back for await (const chunk of claude.stdout) { yield chunk.toString(); } } ``` --- ## Postiz Integration (Media Upload + Publishing) ```typescript // lib/postiz.ts import { Postiz } from "@postiz/node"; import fs from "fs"; import path from "path"; import FormData from "form-data"; const postiz = new Postiz({ apiKey: process.env.POSTIZ_API_KEY!, baseUrl: process.env.POSTIZ_URL!, }); // Upload a local file to Postiz and get back a hosted URL + media ID export async function uploadToPostiz(filePath: string) { const form = new FormData(); form.append("file", fs.createReadStream(filePath)); const res = await fetch(`${process.env.POSTIZ_URL}/public/v1/upload`, { method: "POST", headers: { Authorization: process.env.POSTIZ_API_KEY! }, body: form, }); const { id, path: publicUrl } = await res.json(); return { mediaId: id, publicUrl }; } // Push an approved asset to Postiz for scheduling export async function pushToPostiz(asset: Asset, scheduledAt: string) { // Upload media first const { mediaId, publicUrl } = await uploadToPostiz(asset.filePath); // Get the right Postiz integration (channel) ID const integrations = await postiz.integrations.list(); const integration = integrations.find( (i) => i.providerIdentifier === asset.platform ); if (!integration) throw new Error(`No Postiz channel for ${asset.platform}`); // Parse caption from asset metadata const metadata = JSON.parse(asset.metadata || "{}"); // Create the scheduled post const post = await postiz.posts.create({ type: "schedule", date: scheduledAt, integration: integration.id, content: metadata.caption || "", image: [{ id: mediaId, path: publicUrl }], }); return post; } ``` --- ## File Serving (Local Assets → Browser Preview) Your pipeline outputs (images, videos) live on the local filesystem. The dashboard needs to serve them for preview: ```typescript // app/api/files/[...path]/route.ts import { readFile } from "fs/promises"; import path from "path"; import { auth } from "@/lib/auth"; const PIPELINE_ROOT = process.env.PIPELINE_ROOT || "/app/pipeline"; export async function GET(req: Request, { params }: { params: { path: string[] } }) { // Auth check — only logged-in users can view files const session = await auth(); if (!session) return new Response("Unauthorized", { status: 401 }); const filePath = path.join(PIPELINE_ROOT, ...params.path); // Security: prevent path traversal if (!filePath.startsWith(PIPELINE_ROOT)) { return new Response("Forbidden", { status: 403 }); } try { const buffer = await readFile(filePath); const ext = path.extname(filePath).toLowerCase(); const contentType = { ".png": "image/png", ".jpg": "image/jpeg", ".mp4": "video/mp4", ".html": "text/html", ".json": "application/json", ".md": "text/markdown", ".txt": "text/plain", ".css": "text/css" }[ext] || "application/octet-stream"; return new Response(buffer, { headers: { "Content-Type": contentType } }); } catch { return new Response("Not found", { status: 404 }); } } ``` Asset gallery uses: `` Video preview uses: `