// Studio Notebook — bms-backbone server (Postgres + Prisma + OTM-account auth) import { createServer } from "node:http"; import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; import { join, dirname, extname } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID, createHmac, createHash, timingSafeEqual } from "node:crypto"; import { PrismaClient } from "@prisma/client"; import bcrypt from "bcryptjs"; import { APP_VERSION } from "./lib/version.mjs"; const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA = process.env.DATA_DIR || join(ROOT, "data"); const PHOTOS = join(DATA, "photos"); const FILES = join(DATA, "files"); mkdirSync(PHOTOS, { recursive: true }); mkdirSync(FILES, { recursive: true }); const db = new PrismaClient(); const PORT = Number(process.env.PORT || 3000); const SESSION_SECRET = process.env.SESSION_SECRET || createHash("sha256").update("bms:" + (process.env.DATABASE_URL || "dev")).digest("hex"); const SESSION_DAYS = 30; // ── helpers ────────────────────────────────────────────────────── const fmtDate = (d) => { if (!d) return null; const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; }; // session cookie: base64(payload).hmac const sign = (payload) => { const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); const mac = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url"); return `${body}.${mac}`; }; const verify = (token) => { if (!token || !token.includes(".")) return null; const [body, mac] = token.split("."); const expect = createHmac("sha256", SESSION_SECRET).update(body).digest("base64url"); try { if (!timingSafeEqual(Buffer.from(mac), Buffer.from(expect))) return null; const p = JSON.parse(Buffer.from(body, "base64url").toString()); return p.exp > Date.now() ? p : null; } catch { return null; } }; const cookieOf = (req) => Object.fromEntries((req.headers.cookie || "").split(";").map((c) => c.trim().split("=").map(decodeURIComponent)).filter((p) => p[0])); // ── serializers (keep the frontend's field names) ──────────────── const sNote = (n) => ({ id: n.id, text: n.text, tags: n.tags, created: fmtDate(n.created) }); const sTodo = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, parent_id: t.parentId, created: fmtDate(t.created) }); const sShopping = sTodo; const sGlaze = (g) => ({ id: g.id, name: g.name, kind: g.kind, cone: g.cone, atmosphere: g.atmosphere, surface: g.surface, source: g.source, sg: g.sg, swatch: g.swatch, created: fmtDate(g.created) }); const sMat = (m) => ({ id: m.id, glaze_id: m.glazeId, name: m.name, pct: m.pct, addition: m.addition ? 1 : 0 }); const sJournal = (j) => ({ id: j.id, glaze_id: j.glazeId, text: j.text, created: fmtDate(j.created) }); const sFiring = (f) => ({ id: f.id, type: f.type, cone: f.cone, schedule: f.schedule, status: f.status, result_notes: f.resultNotes, started: fmtDate(f.started), unloaded: fmtDate(f.unloaded) }); const sItem = (i) => ({ id: i.id, firing_id: i.firingId, piece: i.piece, clay: i.clay, glaze: i.glaze, rating: i.rating, note: i.note }); const sPhoto = (p) => ({ id: p.id, entity: p.entity, entity_id: p.entityId, filename: p.filename, created: fmtDate(p.created) }); const sFile = (f) => ({ id: f.id, entity: f.entity, entity_id: f.entityId, stored: f.stored, name: f.name, tag: f.tag, size: f.size, created: fmtDate(f.created) }); const sProduct = (p) => ({ id: p.id, name: p.name, kind: p.kind, category: p.category, price: p.price, cost: p.cost, qty: p.qty, status: p.status, notes: p.notes, created: fmtDate(p.created) }); const sSale = (s) => ({ id: s.id, product_id: s.productId, qty: s.qty, price: s.price, cost: s.cost, created: fmtDate(s.created) }); const sSupplier = (s) => ({ id: s.id, name: s.name, kind: s.kind, distance: s.distance, url: s.url, notes: s.notes, search_url: s.searchUrl, created: fmtDate(s.created) }); // ── the "plays well with" evidence table (unload ratings only) ─── async function compatibility(glazeName) { const rows = await db.firingItem.findMany({ where: { glaze: glazeName, NOT: { rating: "" }, clay: { not: "" }, firing: { status: "unloaded" } }, }); const grouped = {}; for (const r of rows) { const key = `${r.clay}${r.rating}`; grouped[key] ??= { clay: r.clay, rating: r.rating, n: 0, notes: [] }; grouped[key].n++; if (r.note) grouped[key].notes.push(r.note); } return Object.values(grouped) .map((g) => ({ clay: g.clay, rating: g.rating, n: g.n, notes: g.notes.join(" · ") || null })) .sort((a, b) => a.clay.localeCompare(b.clay) || b.n - a.n); } // ── routes ─────────────────────────────────────────────────────── const routes = { "GET /api/health": async () => ({ ok: true, version: APP_VERSION }), "POST /api/login": async (b) => { const user = await db.user.findUnique({ where: { email: String(b.email || "").toLowerCase().trim() } }); if (!user || !(await bcrypt.compare(String(b.password || ""), user.passwordHash))) return { error: "wrong email or password" }; return { _setSession: { email: user.email, exp: Date.now() + SESSION_DAYS * 86400e3 }, ok: true }; }, "POST /api/logout": async () => ({ _clearSession: true, ok: true }), "GET /api/me": async (_b, _q, session) => ({ email: session.email }), "GET /api/summary": async () => ({ todos: (await db.todo.findMany({ orderBy: { id: "desc" }, take: 60 })).map(sTodo), shopping: (await db.shopping.findMany({ orderBy: [{ done: "asc" }, { id: "desc" }], take: 12 })).map(sShopping), notes: (await db.note.findMany({ orderBy: { id: "desc" }, take: 6 })).map(sNote), activeFiring: await db.firing.findFirst({ where: { status: "firing" }, orderBy: { id: "desc" } }).then((f) => f ? sFiring(f) : null), lastUnload: await db.firing.findFirst({ where: { status: "unloaded" }, orderBy: { unloaded: "desc" } }).then((f) => f ? sFiring(f) : null), }), "POST /api/jot": async (b) => { const kind = b.kind || "note"; const lines = String(b.text).split("\n").map((l) => l.replace(/^[\s\-•↳]+/, "").trim()).filter(Boolean); if (kind === "todo") { const parent = await db.todo.create({ data: { text: lines[0] } }); for (const sub of lines.slice(1)) await db.todo.create({ data: { text: sub, parentId: parent.id } }); return { ok: true }; } if (kind === "shopping") { for (const l of lines) await db.shopping.create({ data: { text: l } }); return { ok: true }; } await db.note.create({ data: { text: b.text, tags: b.tags || (kind !== "note" ? kind : "") } }); return { ok: true }; }, "POST /api/todos/toggle": async (b) => { const t = await db.todo.findUnique({ where: { id: b.id } }); if (t) await db.todo.update({ where: { id: b.id }, data: { done: !t.done } }); return { ok: true }; }, "POST /api/todos/add": async (b) => { const t = await db.todo.create({ data: { text: b.text, parentId: b.parent_id ?? null } }); return { id: t.id }; }, "POST /api/todos/delete": async (b) => { await db.todo.deleteMany({ where: { parentId: b.id } }); await db.todo.deleteMany({ where: { id: b.id } }); return { ok: true }; }, "POST /api/shopping/toggle": async (b) => { const t = await db.shopping.findUnique({ where: { id: b.id } }); if (t) await db.shopping.update({ where: { id: b.id }, data: { done: !t.done } }); return { ok: true }; }, "GET /api/notes": async () => (await db.note.findMany({ orderBy: { id: "desc" } })).map(sNote), "GET /api/glazes": async () => (await db.glaze.findMany({ orderBy: { name: "asc" }, include: { materials: { orderBy: [{ addition: "asc" }, { pct: "desc" }] } } })) .map((g) => ({ ...sGlaze(g), materials: g.materials.map(sMat) })), "GET /api/glaze": async (_b, q) => { const g = await db.glaze.findUnique({ where: { id: Number(q.get("id")) }, include: { materials: { orderBy: [{ addition: "asc" }, { pct: "desc" }] }, journal: { orderBy: { id: "desc" } } } }); if (!g) return null; return { ...sGlaze(g), materials: g.materials.map(sMat), journal: g.journal.map(sJournal), compatibility: await compatibility(g.name), photos: (await db.photo.findMany({ where: { entity: "glaze", entityId: g.id } })).map(sPhoto) }; }, "POST /api/glazes": async (b) => { const g = await db.glaze.create({ data: { name: b.name, kind: b.kind || "glaze", cone: b.cone || "", atmosphere: b.atmosphere || "", surface: b.surface || "", source: b.source || "mine", sg: b.sg || "", swatch: b.swatch || "", materials: { create: (b.materials || []).map((m) => ({ name: m.name, pct: m.pct, addition: !!m.addition })) }, } }); return { id: g.id }; }, "POST /api/glaze/journal": async (b) => { await db.glazeJournal.create({ data: { glazeId: b.glaze_id, text: b.text } }); return { ok: true }; }, "GET /api/firings": async () => (await db.firing.findMany({ orderBy: { id: "desc" }, include: { items: true } })) .map((f) => ({ ...sFiring(f), items: f.items.map(sItem) })), "POST /api/firings": async (b) => { const f = await db.firing.create({ data: { type: b.type || "glaze", cone: b.cone || "∆6", schedule: b.schedule || "", items: { create: (b.items || []).map((it) => ({ piece: it.piece, clay: it.clay || "", glaze: it.glaze || "" })) } } }); return { id: f.id }; }, "POST /api/firing/items": async (b) => { await db.firingItem.create({ data: { firingId: b.firing_id, piece: b.piece, clay: b.clay || "", glaze: b.glaze || "" } }); return { ok: true }; }, "POST /api/firing/rate": async (b) => { await db.firingItem.update({ where: { id: b.id }, data: { rating: b.rating, note: b.note || "" } }); return { ok: true }; }, "POST /api/firing/unload": async (b) => { await db.firing.update({ where: { id: b.id }, data: { status: "unloaded", unloaded: new Date(), resultNotes: b.result_notes || "" } }); return { ok: true }; }, "GET /api/products": async () => { const sales = await db.sale.findMany(); const monthStart = new Date(); monthStart.setDate(1); monthStart.setHours(0, 0, 0, 0); return { products: (await db.product.findMany({ orderBy: [{ kind: "asc" }, { category: "asc" }, { name: "asc" }] })).map(sProduct), money: { revenue: sales.reduce((a, s) => a + s.qty * s.price, 0), cogs: sales.reduce((a, s) => a + s.qty * s.cost, 0), monthRevenue: sales.filter((s) => s.created >= monthStart).reduce((a, s) => a + s.qty * s.price, 0), salesCount: sales.reduce((a, s) => a + s.qty, 0), }, }; }, "GET /api/product": async (_b, q) => { const p = await db.product.findUnique({ where: { id: Number(q.get("id")) }, include: { sales: { orderBy: { id: "desc" }, take: 10 } } }); if (!p) return null; return { ...sProduct(p), sales: p.sales.map(sSale), photos: (await db.photo.findMany({ where: { entity: "product", entityId: p.id } })).map(sPhoto), files: (await db.fileAsset.findMany({ where: { entity: "product", entityId: p.id }, orderBy: { id: "desc" } })).map(sFile) }; }, "POST /api/products": async (b) => { const p = await db.product.create({ data: { name: b.name, kind: b.kind || "core line", category: b.category || "", price: Number(b.price) || 0, cost: Number(b.cost) || 0, qty: Number(b.qty) || 0 } }); return { id: p.id }; }, "POST /api/products/notes": async (b) => { await db.product.update({ where: { id: b.id }, data: { notes: b.notes } }); return { ok: true }; }, "POST /api/products/qty": async (b) => { const p = await db.product.findUnique({ where: { id: b.id } }); if (p) await db.product.update({ where: { id: b.id }, data: { qty: Math.max(0, p.qty + b.delta) } }); return { ok: true }; }, "POST /api/products/status": async (b) => { await db.product.update({ where: { id: b.id }, data: { status: b.status } }); return { ok: true }; }, "POST /api/sales": async (b) => { const p = await db.product.findUnique({ where: { id: b.product_id } }); if (!p) return { error: "no such product" }; await db.sale.create({ data: { productId: p.id, qty: b.qty || 1, price: b.price ?? p.price, cost: p.cost } }); if (p.kind === "core line" || p.kind === "tiles") await db.product.update({ where: { id: p.id }, data: { qty: Math.max(0, p.qty - (b.qty || 1)) } }); else await db.product.update({ where: { id: p.id }, data: { status: "sold" } }); return { ok: true }; }, "GET /api/sourcing": async () => { const entries = await db.priceEntry.findMany({ orderBy: [{ item: "asc" }, { id: "desc" }], include: { supplier: true } }); return { suppliers: (await db.supplier.findMany({ orderBy: { name: "asc" } })).map(sSupplier), entries: entries.map((e) => ({ id: e.id, item: e.item, supplier_id: e.supplierId, price: e.price, unit: e.unit, shipping: e.shipping, note: e.note, created: fmtDate(e.created), supplier: e.supplier.name, supplier_kind: e.supplier.kind, distance: e.supplier.distance })), }; }, "POST /api/suppliers": async (b) => { const s = await db.supplier.create({ data: { name: b.name, kind: b.kind || "online", distance: b.distance || "", url: b.url || "", notes: b.notes || "", searchUrl: b.search_url || "" } }); return { id: s.id }; }, "POST /api/price-entries": async (b) => { await db.priceEntry.create({ data: { item: b.item, supplierId: b.supplier_id, price: Number(b.price) || 0, unit: b.unit || "", shipping: Number(b.shipping) || 0, note: b.note || "" } }); return { ok: true }; }, "GET /api/files": async () => { const files = await db.fileAsset.findMany({ orderBy: { id: "desc" } }); const productIds = [...new Set(files.filter((f) => f.entity === "product").map((f) => f.entityId))]; const products = await db.product.findMany({ where: { id: { in: productIds } } }); const nameOf = Object.fromEntries(products.map((p) => [p.id, p.name])); return files.map((f) => ({ ...sFile(f), product_name: f.entity === "product" ? nameOf[f.entityId] ?? null : null })); }, "POST /api/files/delete": async (b) => { const f = await db.fileAsset.findUnique({ where: { id: b.id } }); if (f) { try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ } await db.fileAsset.delete({ where: { id: f.id } }); } return { ok: true }; }, }; const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".heic": "image/heic" }; const PUBLIC_PATHS = new Set(["/api/health", "/api/login", "/login", "/login.html"]); createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const send = (code, body, type = "application/json", extraHeaders = {}) => { res.writeHead(code, { "content-type": type, ...extraHeaders }); res.end(type === "application/json" ? JSON.stringify(body) : body); }; try { const session = verify(cookieOf(req).bms_session); const isPublic = PUBLIC_PATHS.has(url.pathname); if (url.pathname === "/login" || url.pathname === "/login.html") return send(200, readFileSync(join(ROOT, "public", "login.html")), "text/html"); if (!session && !isPublic) { if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/photos/") || url.pathname.startsWith("/file/")) return send(401, { error: "not logged in" }); return send(302, "", "text/plain", { location: "/login" }); } // uploads (auth required — session checked above) if (req.method === "POST" && url.pathname === "/api/photos") { const chunks = []; for await (const c of req) chunks.push(c); const ext = (MIME[extname(url.searchParams.get("name") || "").toLowerCase()] ? extname(url.searchParams.get("name")) : ".jpg").toLowerCase(); const filename = randomUUID() + ext; writeFileSync(join(PHOTOS, filename), Buffer.concat(chunks)); await db.photo.create({ data: { entity: url.searchParams.get("entity") || "note", entityId: Number(url.searchParams.get("id") || 0), filename } }); return send(200, { ok: true, filename }); } if (req.method === "POST" && url.pathname === "/api/files") { const chunks = []; for await (const c of req) chunks.push(c); const buf = Buffer.concat(chunks); const orig = (url.searchParams.get("name") || "file").replace(/[\/\\]/g, "_"); const stored = randomUUID() + extname(orig).toLowerCase(); writeFileSync(join(FILES, stored), buf); await db.fileAsset.create({ data: { entity: url.searchParams.get("entity") || "", entityId: Number(url.searchParams.get("id") || 0), stored, name: orig, tag: url.searchParams.get("tag") || "", size: buf.length } }); return send(200, { ok: true }); } if (url.pathname.startsWith("/file/")) { const stored = url.pathname.slice(6).replace(/[^A-Za-z0-9.\-]/g, ""); const f = await db.fileAsset.findFirst({ where: { stored } }); if (!f || !existsSync(join(FILES, stored))) return send(404, { error: "not found" }); return send(200, readFileSync(join(FILES, stored)), MIME[extname(stored)] || "application/octet-stream", { "content-disposition": `attachment; filename="${f.name.replace(/"/g, "")}"` }); } if (url.pathname.startsWith("/photos/")) { const f = join(PHOTOS, url.pathname.slice(8).replace(/[^A-Za-z0-9.\-]/g, "")); if (existsSync(f)) return send(200, readFileSync(f), MIME[extname(f).toLowerCase()] || "application/octet-stream"); return send(404, { error: "not found" }); } const handler = routes[`${req.method} ${url.pathname}`]; if (handler) { let body = {}; if (req.method === "POST") { const chunks = []; for await (const c of req) chunks.push(c); body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; } const result = (await handler(body, url.searchParams, session)) ?? { ok: true }; const headers = {}; if (result._setSession) { headers["set-cookie"] = `bms_session=${sign(result._setSession)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${SESSION_DAYS * 86400}`; delete result._setSession; } if (result._clearSession) { headers["set-cookie"] = "bms_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0"; delete result._clearSession; } return send(200, result, "application/json", headers); } // static app const file = url.pathname === "/" ? "index.html" : url.pathname.slice(1).replace(/[^A-Za-z0-9.\-\/]/g, ""); const path = join(ROOT, "public", file); if (existsSync(path) && !path.includes("..")) return send(200, readFileSync(path), MIME[extname(path).toLowerCase()] || "text/plain"); send(404, { error: "not found" }); } catch (e) { console.error(e); send(500, { error: String(e.message || e) }); } }).listen(PORT, "0.0.0.0", () => { console.log(`🌙 bms-backbone ${APP_VERSION} listening on :${PORT}`); });