import * as http from "node:http"; import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { createDatabase } from "./db.js"; import { WHACK_CARDS, SUIT_META, getCardByNumber, drawCards, drawFourRoleSpread } from "./whack_cards.js"; import { NANO_BANANA_MODELS, SURFACE_PRESETS, buildNanoBananaPrompt, generateWithNanoBanana, isCodexConfigured } from "./nano_banana.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT_DIR = path.resolve(__dirname, ".."); const PUBLIC_DIR = path.join(ROOT_DIR, "public"); const BUNDLED_DATA_DIR = path.join(ROOT_DIR, "data"); const ACTIVE_DATA_DIR = process.env.DATA_DIR ? path.resolve(process.env.DATA_DIR) : BUNDLED_DATA_DIR; const DEFAULT_DB_PATH = path.join(ACTIVE_DATA_DIR, "ideas.db"); const DEFAULT_MOCKUPS_DIR = path.join(ACTIVE_DATA_DIR, "mockups"); const MIME_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon" }; function sendJson(res, statusCode, payload) { const body = JSON.stringify(payload); res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); res.end(body); } function readJsonBody(req, maxBytes = 25 * 1024 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; req.on("data", (chunk) => { total += chunk.length; if (total > maxBytes) { reject(new Error("Payload too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf-8"); resolve(raw ? JSON.parse(raw) : {}); } catch (err) { reject(new Error("Invalid JSON body")); } }); req.on("error", reject); }); } function serveStaticFile(res, filePath) { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { sendJson(res, 404, { error: "File not found" }); return; } const ext = path.extname(filePath).toLowerCase(); const contentType = MIME_TYPES[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": contentType }); fs.createReadStream(filePath).pipe(res); } function ensureSeedMockupsCopied(targetMockupsDir) { fs.mkdirSync(targetMockupsDir, { recursive: true }); const bundledMockupsDir = path.join(BUNDLED_DATA_DIR, "mockups"); if (!fs.existsSync(bundledMockupsDir) || path.resolve(bundledMockupsDir) === path.resolve(targetMockupsDir)) { return; } for (const file of fs.readdirSync(bundledMockupsDir)) { const src = path.join(bundledMockupsDir, file); const dest = path.join(targetMockupsDir, file); if (!fs.existsSync(dest) && fs.statSync(src).isFile()) { fs.copyFileSync(src, dest); } } } export function createAppServer({ dbPath = DEFAULT_DB_PATH, mockupsDir = DEFAULT_MOCKUPS_DIR } = {}) { ensureSeedMockupsCopied(mockupsDir); const store = createDatabase(dbPath); const server = http.createServer(async (req, res) => { try { const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); const pathname = decodeURIComponent(parsedUrl.pathname); const method = req.method.toUpperCase(); // --- HEALTHCHECK --- if (method === "GET" && pathname === "/api/health") { sendJson(res, 200, { status: "ok", service: "sparkvault", timestamp: new Date().toISOString() }); return; } // --- API ROUTES --- // 1. GET /api/ideas if (method === "GET" && pathname === "/api/ideas") { const status = parsedUrl.searchParams.get("status") || "all"; const category = parsedUrl.searchParams.get("category") || "all"; const tag = parsedUrl.searchParams.get("tag") || "all"; const search = parsedUrl.searchParams.get("search") || ""; const hasMockup = parsedUrl.searchParams.get("hasMockup") || ""; const sort = parsedUrl.searchParams.get("sort") || "score"; const ideas = store.listIdeas({ status, category, tag, search, hasMockup, sort }); const allIdeas = store.listIdeas({}); const tagCounts = {}; const statusCounts = { spark: 0, exploring: 0, building: 0, shipped: 0, archived: 0 }; let totalMockups = 0; let totalWhacks = 0; for (const item of allIdeas) { if (statusCounts[item.status] !== undefined) statusCounts[item.status]++; totalMockups += item.mockups.length; totalWhacks += item.whack_history.length; for (const t of item.tags) { tagCounts[t] = (tagCounts[t] || 0) + 1; } } sendJson(res, 200, { ideas, stats: { total: allIdeas.length, statusCounts, totalMockups, totalWhacks, tags: Object.entries(tagCounts) .sort((a, b) => b[1] - a[1]) .map(([name, count]) => ({ name, count })) } }); return; } // 2. POST /api/ideas if (method === "POST" && pathname === "/api/ideas") { const body = await readJsonBody(req); if (!body.title || !String(body.title).trim()) { sendJson(res, 400, { error: "Idea title is required" }); return; } const created = store.createIdea(body); sendJson(res, 201, { idea: created }); return; } // 3. GET / PATCH / DELETE /api/ideas/:id const ideaIdMatch = pathname.match(/^\/api\/ideas\/(\d+)$/); if (ideaIdMatch) { const id = Number(ideaIdMatch[1]); if (method === "GET") { const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea }); } if (method === "PATCH") { const body = await readJsonBody(req); const updated = store.updateIdea(id, body); if (!updated) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea: updated }); } if (method === "DELETE") { const deleted = store.deleteIdea(id); if (!deleted) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { deleted: true, id }); } } // 4. GET /api/whack-cards if (method === "GET" && pathname === "/api/whack-cards") { const allIdeas = store.listIdeas({}); const usageByCard = {}; for (const idea of allIdeas) { if (idea.inspired_by_card) { usageByCard[idea.inspired_by_card] = (usageByCard[idea.inspired_by_card] || 0) + 1; } for (const w of idea.whack_history) { usageByCard[w.cardNumber] = (usageByCard[w.cardNumber] || 0) + 1; } } const cards = WHACK_CARDS.map((c) => ({ ...c, usageCount: usageByCard[c.number] || 0 })); sendJson(res, 200, { suits: SUIT_META, cards }); return; } // 5. GET /api/whack-cards/draw if (method === "GET" && pathname === "/api/whack-cards/draw") { const mode = parsedUrl.searchParams.get("mode") || "single"; const suit = parsedUrl.searchParams.get("suit") || ""; const status = parsedUrl.searchParams.get("status") || ""; const ideaId = parsedUrl.searchParams.get("ideaId"); if (mode === "spread") { const spread = drawFourRoleSpread(); return sendJson(res, 200, { mode: "spread", cards: spread }); } if (mode === "collider") { const activeIdeas = store .listIdeas({}) .filter((i) => i.status !== "archived" && i.status !== "shipped"); let targetIdea = null; if (ideaId) { targetIdea = store.getIdea(Number(ideaId)); } if (!targetIdea && activeIdeas.length > 0) { targetIdea = activeIdeas[Math.floor(Math.random() * activeIdeas.length)]; } const drawn = drawCards({ suit: suit || undefined, status: targetIdea?.status || undefined, count: 1 })[0]; return sendJson(res, 200, { mode: "collider", card: drawn, idea: targetIdea }); } const drawn = drawCards({ suit: suit || undefined, status: status || undefined, count: 1 })[0]; sendJson(res, 200, { mode: "single", card: drawn }); return; } // 6. POST /api/ideas/:id/whack const whackMatch = pathname.match(/^\/api\/ideas\/(\d+)\/whack$/); if (method === "POST" && whackMatch) { const id = Number(whackMatch[1]); const body = await readJsonBody(req); if (!body.cardNumber) { return sendJson(res, 400, { error: "cardNumber is required" }); } const updated = store.logWhackOnIdea(id, { cardNumber: Number(body.cardNumber), reflection: body.reflection || "", addChecklistItem: Boolean(body.addChecklistItem), promoteStatus: body.promoteStatus || null }); if (!updated) return sendJson(res, 404, { error: "Idea not found" }); return sendJson(res, 200, { idea: updated }); } // 7. POST /api/ideas/:id/mockups/nano-banana const nbMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/nano-banana$/); if (method === "POST" && nbMatch) { const id = Number(nbMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); const template = body.template || "tshirt-front"; const garmentColor = body.garmentColor || "#F5E8D3"; const garmentColorName = body.garmentColorName || "Vintage Cream"; const placement = body.placement || (template === "tshirt-left-chest" ? "left-chest" : "center-chest"); const whackCard = body.whackCardNumber ? getCardByNumber(body.whackCardNumber) : null; // Check if editing an existing mockup let referenceImagePath = null; let parentMockupId = body.parentMockupId || null; if (parentMockupId) { const parentMockup = idea.mockups.find((m) => m.id === parentMockupId); if (parentMockup && parentMockup.imageUrl.startsWith("/mockups/")) { const candidate = path.join(mockupsDir, path.basename(parentMockup.imageUrl)); if (fs.existsSync(candidate)) { referenceImagePath = candidate; } } } const finalPrompt = buildNanoBananaPrompt({ rawPrompt: body.prompt, ideaTitle: idea.title, elevatorPitch: idea.elevator_pitch, template, garmentColorName, garmentHex: garmentColor, whackCard, editInstruction: body.editInstruction }); const storedKey = store.getSetting("gemini_api_key", ""); const selectedModel = body.model || store.getSetting("nano_banana_model", "codex-image-gen"); try { const genResult = await generateWithNanoBanana({ apiKey: body.apiKey || storedKey, model: selectedModel, prompt: finalPrompt, referenceImagePath, referenceImageBase64: body.referenceImageBase64 || null, outputDir: mockupsDir }); const updated = store.addMockupToIdea(id, { title: body.title || (body.editInstruction ? `Edited: ${body.editInstruction.slice(0, 40)}` : `${SURFACE_PRESETS[template]?.label || "Mockup"}`), template, garmentColor, garmentColorName, placement, prompt: finalPrompt, editInstruction: body.editInstruction || "", parentMockupId, modelUsed: genResult.modelUsed, imageUrl: genResult.imageUrl }); return sendJson(res, 201, { idea: updated, mockup: updated.mockups[0], textNotes: genResult.textNotes }); } catch (err) { const status = err.code === "MISSING_API_KEY" ? 400 : 502; return sendJson(res, status, { error: err.message || "Nano Banana image generation failed", code: err.code || "GENERATION_ERROR", promptBuilt: finalPrompt }); } } // 8. POST /api/ideas/:id/mockups/canvas const canvasMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/canvas$/); if (method === "POST" && canvasMatch) { const id = Number(canvasMatch[1]); const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); const body = await readJsonBody(req); if (!body.imageDataUrl || !String(body.imageDataUrl).startsWith("data:image/")) { return sendJson(res, 400, { error: "Valid imageDataUrl (data:image/png;base64,...) is required" }); } const base64Data = String(body.imageDataUrl).replace(/^data:image\/[a-zA-Z0-9+.-]+;base64,/, ""); const filename = `mockup-${id}-${Date.now()}.png`; const filePath = path.join(mockupsDir, filename); fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); const updated = store.addMockupToIdea(id, { title: body.title || `${idea.title} (${body.placement === "left-chest" ? "Left-Chest" : "Mockup"})`, template: body.template || "tshirt-front", garmentColor: body.garmentColor || "#F5E8D3", garmentColorName: body.garmentColorName || "Vintage Cream", placement: body.placement || "center-chest", prompt: body.prompt || "", topText: body.topText || "", bottomText: body.bottomText || "", modelUsed: body.modelUsed || "Interactive Mockup Compositor", imageUrl: `/mockups/${filename}` }); return sendJson(res, 201, { idea: updated, mockup: updated.mockups[0] }); } // 9. PATCH /api/ideas/:id/mockups/:mockupId/cover & DELETE /api/ideas/:id/mockups/:mockupId const mockupItemMatch = pathname.match(/^\/api\/ideas\/(\d+)\/mockups\/([^/]+)(?:\/(cover))?$/); if (mockupItemMatch) { const id = Number(mockupItemMatch[1]); const mockupId = mockupItemMatch[2]; const isCoverAction = mockupItemMatch[3] === "cover"; const idea = store.getIdea(id); if (!idea) return sendJson(res, 404, { error: "Idea not found" }); if (method === "PATCH" && isCoverAction) { const target = idea.mockups.find((m) => m.id === mockupId); if (!target) return sendJson(res, 404, { error: "Mockup not found" }); const updated = store.updateIdea(id, { cover_mockup_url: target.imageUrl }); return sendJson(res, 200, { idea: updated }); } if (method === "DELETE" && !isCoverAction) { const nextMockups = idea.mockups.filter((m) => m.id !== mockupId); const nextCover = idea.cover_mockup_url && nextMockups.some((m) => m.imageUrl === idea.cover_mockup_url) ? idea.cover_mockup_url : nextMockups[0]?.imageUrl || ""; const updated = store.updateIdea(id, { mockups: nextMockups, cover_mockup_url: nextCover }); return sendJson(res, 200, { idea: updated }); } } // 10. GET /api/review/next & POST /api/review/arena if (method === "GET" && pathname === "/api/review/next") { const allIdeas = store.listIdeas({}); const activeIdeas = allIdeas.filter((i) => i.status !== "archived" && i.status !== "shipped"); const triageQueue = [...activeIdeas].sort((a, b) => { if (!a.last_reviewed_at && b.last_reviewed_at) return -1; if (a.last_reviewed_at && !b.last_reviewed_at) return 1; if (a.last_reviewed_at && b.last_reviewed_at) { return a.last_reviewed_at.localeCompare(b.last_reviewed_at); } return a.created_at.localeCompare(b.created_at); }); let arenaPair = []; if (activeIdeas.length >= 2) { const shuffled = [...activeIdeas].sort(() => Math.random() - 0.5); arenaPair = [shuffled[0], shuffled[1]]; } return sendJson(res, 200, { triageQueue, arenaPair }); } if (method === "POST" && pathname === "/api/review/arena") { const body = await readJsonBody(req); if (!body.winnerId || !body.loserId) { return sendJson(res, 400, { error: "winnerId and loserId are required" }); } const result = store.recordArenaMatch({ winnerId: Number(body.winnerId), loserId: Number(body.loserId) }); if (!result) return sendJson(res, 404, { error: "Ideas not found" }); return sendJson(res, 200, result); } // 11. GET / PUT /api/settings if (pathname === "/api/settings") { if (method === "GET") { const savedKey = store.getSetting("gemini_api_key", ""); const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; const activeKey = savedKey || envKey; const codexReady = isCodexConfigured(); const model = store.getSetting("nano_banana_model", "codex-image-gen"); return sendJson(res, 200, { hasApiKey: Boolean(activeKey) || codexReady, codexReady, apiKeySource: codexReady ? "OpenAI Codex (OAuth)" : savedKey ? "settings" : envKey ? "environment" : "none", maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : codexReady ? "Authenticated via ~/.codex/auth.json" : "", nanoBananaModel: model, availableModels: NANO_BANANA_MODELS, surfacePresets: Object.entries(SURFACE_PRESETS).map(([id, val]) => ({ id, label: val.label })) }); } if (method === "PUT") { const body = await readJsonBody(req); if (body.geminiApiKey !== undefined) { store.setSetting("gemini_api_key", String(body.geminiApiKey).trim()); } if (body.nanoBananaModel !== undefined) { store.setSetting("nano_banana_model", String(body.nanoBananaModel).trim()); } const savedKey = store.getSetting("gemini_api_key", ""); const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; const activeKey = savedKey || envKey; const codexReady = isCodexConfigured(); return sendJson(res, 200, { saved: true, hasApiKey: Boolean(activeKey) || codexReady, codexReady, nanoBananaModel: store.getSetting("nano_banana_model", "codex-image-gen") }); } } // 12. GET /api/export if (method === "GET" && pathname === "/api/export") { const format = parsedUrl.searchParams.get("format") || "json"; const ideas = store.listIdeas({}); if (format === "markdown") { const mdLines = [ `# SparkVault Idea Export (${new Date().toISOString().slice(0, 10)})`, "" ]; for (const idea of ideas) { mdLines.push(`## [${idea.status.toUpperCase()}] ${idea.title} (Score: ${idea.composite_score})`); if (idea.elevator_pitch) mdLines.push(`> ${idea.elevator_pitch}`); mdLines.push(`- **Category**: ${idea.category} | **Tags**: ${idea.tags.map((t) => `#${t}`).join(" ") || "none"}`); mdLines.push(`- **Ratings**: Excitement ${idea.rating_excitement}/5, Impact ${idea.rating_impact}/5, Ease ${idea.rating_ease}/5 | **Arena Wins**: ${idea.arena_wins}`); if (idea.notes_markdown) { mdLines.push("", idea.notes_markdown); } if (idea.checklist.length > 0) { mdLines.push("", "### Checklist"); for (const item of idea.checklist) { mdLines.push(`- [${item.done ? "x" : " "}] ${item.text}`); } } if (idea.whack_history.length > 0) { mdLines.push("", "### Creative Whack Pack Reflections"); for (const w of idea.whack_history) { mdLines.push(`- **Card #${w.cardNumber} (${w.cardTitle})**: ${w.reflection}`); } } mdLines.push("", "---", ""); } res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": 'attachment; filename="sparkvault-ideas.md"' }); res.end(mdLines.join("\n")); return; } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Disposition": 'attachment; filename="sparkvault-ideas.json"' }); res.end(JSON.stringify({ exportedAt: new Date().toISOString(), ideas }, null, 2)); return; } // --- STATIC ASSETS --- if (pathname.startsWith("/mockups/")) { const rel = pathname.slice("/mockups/".length); const safePath = path.normalize(path.join(mockupsDir, rel)); if (!safePath.startsWith(mockupsDir)) { return sendJson(res, 403, { error: "Forbidden" }); } return serveStaticFile(res, safePath); } const relPath = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); const staticPath = path.normalize(path.join(PUBLIC_DIR, relPath)); if (!staticPath.startsWith(PUBLIC_DIR)) { return sendJson(res, 403, { error: "Forbidden" }); } if (fs.existsSync(staticPath) && fs.statSync(staticPath).isFile()) { return serveStaticFile(res, staticPath); } // SPA Fallback return serveStaticFile(res, path.join(PUBLIC_DIR, "index.html")); } catch (err) { console.error("Server error:", err); sendJson(res, 500, { error: err.message || "Internal server error" }); } }); return { server, store }; } // Start server if invoked directly if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { const port = Number(process.env.PORT || 3456); const { server } = createAppServer(); server.listen(port, () => { console.log(`✨ SparkVault Idea Tracker running at http://localhost:${port}`); }); }