Files
sparkvault/server/index.js
T

600 lines
22 KiB
JavaScript

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
} 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", "gemini-nano-banana-2.1");
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 model = store.getSetting("nano_banana_model", "gemini-nano-banana-2.1");
return sendJson(res, 200, {
hasApiKey: Boolean(activeKey),
apiKeySource: savedKey ? "settings" : envKey ? "environment" : "none",
maskedKey: activeKey ? `${activeKey.slice(0, 4)}...${activeKey.slice(-4)}` : "",
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;
return sendJson(res, 200, {
saved: true,
hasApiKey: Boolean(activeKey),
nanoBananaModel: store.getSetting("nano_banana_model", "gemini-nano-banana-2.1")
});
}
}
// 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}`);
});
}