Files
Bonna-Moon-Studio/app/server.mjs
Bonna c4ba1591c7 app/: production port — Postgres + Prisma + OTM-account auth (v0.2.0)
Prototype stays untouched (Bonna's laptop daily driver until cutover).
app/ is the deployable bms-backbone: same routes/frontend, Prisma models
replacing node:sqlite, session-cookie login backed by a User table seeded
via the platform SEED_OWNER_* convention (bcryptjs), /api/health with
APP_VERSION, photos/files under DATA_DIR (/app/data uploads mount),
Dockerfile + entrypoint (prisma db push + owner seed + serve), and a
one-shot sqlite→postgres migration script for Bonna's existing data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 22:10:55 -05:00

367 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}`);
});