Two new tabs in the Studio Notebook: - 📚 Classes: a binder for courses, workshops, books, and video series. Each class has a subject, source, link, status (taking / want / done), a what-it's-about note, photos, handout files, and dated pages of class notes that can be edited in place. - 💭 Mind Dump: idea capture filed by category (things to make — with a medium: clay / wood / silver / metal / mixed — marketing, packaging, display & booth, shop & site, other) with spark / trying / did it / parked status, sketch photos, one-tap 'make it a to-do', and filters. The Today jot box gains a 💭 idea chip; those land as 'unsorted' to file later. Schema: Course, CoursePage, Idea (additive; prisma db push at boot). Files tab now labels attachments that belong to a class. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
625 lines
35 KiB
JavaScript
625 lines
35 KiB
JavaScript
// 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);
|
|
// Session-signing key: explicit env wins; else derive from the provision-injected
|
|
// OPERATOR_SHARED_SECRET (per-slug, known only to OTM and this container —
|
|
// domain-separated so the two uses can't collide). The old DATABASE_URL fallback
|
|
// was derivable by anyone who ever saw the connection string; it survives ONLY
|
|
// for unmanaged local dev — a managed instance with neither secret must not boot.
|
|
const OP_SECRET = (process.env.OPERATOR_SHARED_SECRET || "").trim();
|
|
const SESSION_SECRET =
|
|
process.env.SESSION_SECRET ||
|
|
(OP_SECRET ? createHmac("sha256", OP_SECRET).update("bms-session").digest("hex") : null) ||
|
|
(process.env.OTM_MANAGED === "true"
|
|
? (() => { throw new Error("managed instance without SESSION_SECRET/OPERATOR_SHARED_SECRET — refusing a derivable session key"); })()
|
|
: createHash("sha256").update("bms:" + (process.env.DATABASE_URL || "dev")).digest("hex"));
|
|
const SESSION_DAYS = 30;
|
|
const OPERATOR_SESSION_MS = 3600e3; // impersonation lives 1h (contract), not 30 days
|
|
|
|
// ── 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]));
|
|
|
|
// Compact HS256 JWS verify (signature + alg allowlist only — claim rules are
|
|
// per-flow). Signed with the UTF-8 bytes of the shared secret string.
|
|
const verifyJws = (token, secret) => {
|
|
const parts = String(token).split(".");
|
|
if (parts.length !== 3) return null;
|
|
const expect = createHmac("sha256", secret).update(`${parts[0]}.${parts[1]}`).digest("base64url");
|
|
try {
|
|
if (parts[2].length !== expect.length || !timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expect))) return null;
|
|
const head = JSON.parse(Buffer.from(parts[0], "base64url").toString());
|
|
if (head.alg !== "HS256") return null; // no alg:none / RS256 confusion
|
|
return JSON.parse(Buffer.from(parts[1], "base64url").toString());
|
|
} catch { return null; }
|
|
};
|
|
// OTM's operator-magic token. OPERATOR_SHARED_SECRET also signs handoff and
|
|
// module-sync tokens, which carry an `action` claim — refuse those so a
|
|
// non-login token can never become a session (flow separation is OUR job).
|
|
const verifyOtmToken = (token, secret) => {
|
|
const p = verifyJws(token, secret);
|
|
if (!p || p.action) return null;
|
|
if (typeof p.exp !== "number" || p.exp < Math.floor(Date.now() / 1000)) return null;
|
|
if (!p.slug || !p.operatorEmail || !p.jti) return null; // slug is bound by the signature, not compared
|
|
return p;
|
|
};
|
|
// OTM account-SSO ticket — stricter by contract (sso-ticket.ts): exp MANDATORY,
|
|
// audience compared explicitly, sub/role/email required.
|
|
const verifySsoTicket = (token, secret, aud) => {
|
|
const p = verifyJws(token, secret);
|
|
if (!p) return null;
|
|
if (typeof p.exp !== "number" || Math.floor(Date.now() / 1000) >= p.exp) return null;
|
|
for (const k of ["jti", "sub", "aud", "role", "email"]) if (typeof p[k] !== "string" || !p[k]) return null;
|
|
if (p.aud !== aud) return null;
|
|
return p;
|
|
};
|
|
|
|
// ── 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, started: t.started ? 1 : 0, parent_id: t.parentId, created: fmtDate(t.created) });
|
|
const sShopping = (t) => ({ id: t.id, text: t.text, done: t.done ? 1 : 0, created: fmtDate(t.created) });
|
|
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 sCourse = (c) => ({ id: c.id, title: c.title, subject: c.subject, source: c.source, url: c.url, status: c.status,
|
|
notes: c.notes, created: fmtDate(c.created), page_count: c._count?.pages ?? 0 });
|
|
const sPage = (p) => ({ id: p.id, course_id: p.courseId, title: p.title, text: p.text, created: fmtDate(p.created), updated: fmtDate(p.updated) });
|
|
const sIdea = (i) => ({ id: i.id, text: i.text, category: i.category, medium: i.medium, status: i.status, created: fmtDate(i.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}\u0000${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);
|
|
}
|
|
|
|
// ── OTM integrations (suggestions → Gitea, self-update) ──────────
|
|
// All three env vars or nothing — a half-configured instance must not half-work.
|
|
const giteaConfig = () => {
|
|
const [baseUrl, token, repo] = [process.env.GITEA_URL, process.env.GITEA_TOKEN, process.env.GITEA_REPO];
|
|
return baseUrl && token && repo ? { baseUrl, token, repo } : null;
|
|
};
|
|
// same three-slot shape as @otm/account-panel's suggestionIssueFooter, so
|
|
// issues on the repo read like every other consumer's
|
|
const suggestionFooter = (name, email, label) =>
|
|
`\n\n---\n_Filed by ${name}${email ? ` (${email})` : ""}${label ? ` · ${label}` : ""}_`;
|
|
async function fileSuggestion(text, actorEmail) {
|
|
const cfg = giteaConfig();
|
|
if (!cfg) return null;
|
|
const trimmed = String(text).trim().slice(0, 8000); // cap what we forward under OTM's token
|
|
const first = trimmed.split("\n")[0].trim() || "Suggestion";
|
|
const title = first.length > 80 ? first.slice(0, 79) + "…" : first;
|
|
const name = actorEmail ? actorEmail.split("@")[0] : "the studio";
|
|
const body = `${trimmed}${suggestionFooter(name, actorEmail || "", `Studio Notebook ${APP_VERSION}`)}`;
|
|
const res = await fetch(`${cfg.baseUrl.replace(/\/$/, "")}/api/v1/repos/${cfg.repo}/issues`, {
|
|
method: "POST",
|
|
headers: { authorization: `token ${cfg.token}`, "content-type": "application/json" },
|
|
body: JSON.stringify({ title, body }),
|
|
signal: AbortSignal.timeout(10_000), // a hung Gitea must not defeat the note fallback
|
|
});
|
|
if (!res.ok) throw new Error(`Gitea ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
return res.json();
|
|
}
|
|
|
|
// self-update credentials: a fresh ts/sig per request (OTM's window is ±2 min)
|
|
const otmCreds = () => {
|
|
const base = (process.env.OTM_API_BASE || "").replace(/\/+$/, "");
|
|
const serviceId = process.env.OTM_SERVICE_ID;
|
|
const secret = (process.env.OPERATOR_SHARED_SECRET || "").trim();
|
|
if (!base || !serviceId || !secret) return null;
|
|
const ts = Date.now().toString();
|
|
return { base, serviceId, ts, sig: createHmac("sha256", secret).update(`${serviceId}.${ts}`).digest("base64url") };
|
|
};
|
|
// Pick OTM's response fields EXPLICITLY — the response layer acts on _-prefixed
|
|
// control keys (_setSession/_redirect), and a spread would hand that namespace
|
|
// to whatever the upstream body contains. Unreachable OTM → 502.
|
|
async function otmProxy(url, init = {}) {
|
|
try {
|
|
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(10_000) });
|
|
const j = await res.json().catch(() => ({}));
|
|
return { _status: res.status, ok: j.ok, latest: j.latest, status: j.status, error: j.error, message: j.message };
|
|
} catch (e) { return { _status: 502, error: String(e.message || "otm_unreachable") }; }
|
|
}
|
|
|
|
// ── 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 }),
|
|
|
|
// OTM "Log in as admin" — a 60s single-use token from the control plane.
|
|
// Every failure is a 302 to /login (never a body, never a 4xx) — the operator reads the reason.
|
|
"GET /api/auth/operator-magic": async (_b, q) => {
|
|
const fail = (reason) => ({ _redirect: reason ? `/login?reason=${reason}` : "/login" });
|
|
const token = q.get("token");
|
|
if (!token) return fail("");
|
|
const secret = (process.env.OPERATOR_SHARED_SECRET || "").trim();
|
|
if (!secret) return fail("not_enabled");
|
|
const claims = verifyOtmToken(token, secret);
|
|
if (!claims) return fail("invalid_token");
|
|
try { await db.operatorMagicConsumed.create({ data: { jti: claims.jti, operatorEmail: claims.operatorEmail } }); } // claim it BEFORE any session
|
|
catch (e) { return fail(e.code === "P2002" ? "link_already_used" : "server_error"); }
|
|
if (Math.random() < 0.01) // best-effort prune, nothing depends on it
|
|
void db.operatorMagicConsumed.deleteMany({ where: { consumedAt: { lt: new Date(Date.now() - 86400e3) } } }).catch(() => {});
|
|
const owner = await db.user.findFirst({ where: { role: "OWNER" }, orderBy: { id: "asc" } });
|
|
if (!owner) return fail("no_admin");
|
|
console.log(`operator-magic: ${claims.operatorEmail} signed in as ${owner.email} (jti ${claims.jti})`);
|
|
return { _setSession: { email: owner.email, operator: claims.operatorEmail, operatorId: claims.operatorId || null,
|
|
exp: Date.now() + OPERATOR_SESSION_MS }, _redirect: "/" };
|
|
},
|
|
|
|
// OTM account SSO — the /account tile's door. Ticket carries the OTM identity
|
|
// (which may not be a local email): exact-email match first, else the first
|
|
// user holding the ticket's role — the site template's actor mapping.
|
|
"GET /api/auth/otm-sso": async (_b, q) => {
|
|
const fail = (reason) => ({ _redirect: `/login?reason=${reason}` });
|
|
const secret = (process.env.OTM_SSO_SECRET || "").trim();
|
|
const audience = process.env.OTM_SSO_AUDIENCE || "";
|
|
if (!secret || !audience) return fail("not_enabled");
|
|
const p = verifySsoTicket(q.get("token"), secret, audience);
|
|
if (!p) return fail("invalid_token");
|
|
try { await db.operatorMagicConsumed.create({ data: { jti: p.jti, operatorEmail: p.email } }); } // shared single-use table
|
|
catch (e) { return fail(e.code === "P2002" ? "link_already_used" : "server_error"); }
|
|
let user = await db.user.findFirst({ where: { email: { equals: p.email, mode: "insensitive" } } });
|
|
if (!user) user = await db.user.findFirst({ where: { role: p.role }, orderBy: { id: "asc" } });
|
|
if (!user) return fail("no_account");
|
|
const next = p.next && p.next.startsWith("/") && !p.next.startsWith("//") ? p.next : "/";
|
|
return { _setSession: { email: user.email, exp: Date.now() + SESSION_DAYS * 86400e3 }, _redirect: next };
|
|
},
|
|
"GET /api/me": async (_b, _q, session) => ({ email: session.email, operator: session.operator ?? null }),
|
|
|
|
"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, _q, session) => {
|
|
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 };
|
|
}
|
|
if (kind === "idea") { // → mind dump, unsorted
|
|
await db.idea.create({ data: { text: b.text } });
|
|
return { ok: true };
|
|
}
|
|
if (kind === "suggestion") {
|
|
try {
|
|
const issue = await fileSuggestion(b.text, session?.email || "");
|
|
if (issue) {
|
|
await db.note.create({ data: { text: `#${issue.number} — ${b.text}`, tags: "suggestion" } }); // her own record of what she sent
|
|
return { ok: true, issue: issue.number, url: issue.html_url };
|
|
}
|
|
} catch (e) { console.error("suggestion → Gitea failed:", e.message); }
|
|
await db.note.create({ data: { text: b.text, tags: "suggestion" } }); // a jot is never lost
|
|
return { ok: true, fallback: 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: t.done ? { done: false } : { done: true, started: false } });
|
|
return { ok: true };
|
|
},
|
|
"POST /api/todos/start": async (b) => {
|
|
const t = await db.todo.findUnique({ where: { id: b.id } });
|
|
if (t) await db.todo.update({ where: { id: b.id }, data: { started: !t.started } });
|
|
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 idsOf = (entity) => [...new Set(files.filter((f) => f.entity === entity).map((f) => f.entityId))];
|
|
const products = await db.product.findMany({ where: { id: { in: idsOf("product") } } });
|
|
const courses = await db.course.findMany({ where: { id: { in: idsOf("course") } } });
|
|
const nameOf = Object.fromEntries(products.map((p) => [p.id, p.name]));
|
|
const courseOf = Object.fromEntries(courses.map((c) => [c.id, c.title]));
|
|
return files.map((f) => ({ ...sFile(f), product_name: f.entity === "product" ? nameOf[f.entityId] ?? null : null,
|
|
course_name: f.entity === "course" ? courseOf[f.entityId] ?? null : null }));
|
|
},
|
|
// ── Classes binder ─────────────────────────────────────────────
|
|
"GET /api/courses": async () =>
|
|
(await db.course.findMany({ orderBy: [{ status: "asc" }, { title: "asc" }], include: { _count: { select: { pages: true } } } })).map(sCourse),
|
|
"GET /api/course": async (_b, q) => {
|
|
const id = Number(q.get("id"));
|
|
const c = await db.course.findUnique({ where: { id }, include: { pages: { orderBy: { id: "desc" } } } });
|
|
if (!c) return null;
|
|
return { ...sCourse(c), pages: c.pages.map(sPage),
|
|
photos: (await db.photo.findMany({ where: { entity: "course", entityId: id } })).map(sPhoto),
|
|
files: (await db.fileAsset.findMany({ where: { entity: "course", entityId: id }, orderBy: { id: "desc" } })).map(sFile) };
|
|
},
|
|
"POST /api/courses": async (b) => {
|
|
const c = await db.course.create({ data: { title: b.title, subject: b.subject || "", source: b.source || "",
|
|
url: b.url || "", status: b.status || "taking", notes: b.notes || "" } });
|
|
return { id: c.id };
|
|
},
|
|
"POST /api/course/update": async (b) => {
|
|
const data = {};
|
|
for (const k of ["title", "subject", "source", "url", "status", "notes"]) if (typeof b[k] === "string") data[k] = b[k];
|
|
await db.course.update({ where: { id: b.id }, data });
|
|
return { ok: true };
|
|
},
|
|
"POST /api/course/delete": async (b) => {
|
|
await db.course.delete({ where: { id: b.id } }); // pages cascade
|
|
await db.photo.deleteMany({ where: { entity: "course", entityId: b.id } });
|
|
for (const f of await db.fileAsset.findMany({ where: { entity: "course", entityId: b.id } })) {
|
|
try { unlinkSync(join(FILES, f.stored)); } catch { /* already gone */ }
|
|
}
|
|
await db.fileAsset.deleteMany({ where: { entity: "course", entityId: b.id } });
|
|
return { ok: true };
|
|
},
|
|
"POST /api/course/pages": async (b) => {
|
|
const p = await db.coursePage.create({ data: { courseId: b.course_id, title: b.title || "", text: b.text || "" } });
|
|
return { id: p.id };
|
|
},
|
|
"POST /api/course/pages/update": async (b) => {
|
|
await db.coursePage.update({ where: { id: b.id }, data: {
|
|
...(typeof b.title === "string" ? { title: b.title } : {}), ...(typeof b.text === "string" ? { text: b.text } : {}), updated: new Date() } });
|
|
return { ok: true };
|
|
},
|
|
"POST /api/course/pages/delete": async (b) => { await db.coursePage.delete({ where: { id: b.id } }); return { ok: true }; },
|
|
|
|
// ── Mind dump ──────────────────────────────────────────────────
|
|
"GET /api/ideas": async () => {
|
|
const list = await db.idea.findMany({ orderBy: { id: "desc" } });
|
|
const photos = await db.photo.findMany({ where: { entity: "idea" } });
|
|
return list.map((i) => ({ ...sIdea(i), photos: photos.filter((p) => p.entityId === i.id).map(sPhoto) }));
|
|
},
|
|
"POST /api/ideas": async (b) => {
|
|
const i = await db.idea.create({ data: { text: b.text, category: b.category || "", medium: b.medium || "", status: b.status || "spark" } });
|
|
return { id: i.id };
|
|
},
|
|
"POST /api/ideas/update": async (b) => {
|
|
const data = {};
|
|
for (const k of ["text", "category", "medium", "status"]) if (typeof b[k] === "string") data[k] = b[k];
|
|
await db.idea.update({ where: { id: b.id }, data });
|
|
return { ok: true };
|
|
},
|
|
"POST /api/ideas/delete": async (b) => {
|
|
await db.idea.delete({ where: { id: b.id } });
|
|
await db.photo.deleteMany({ where: { entity: "idea", entityId: b.id } });
|
|
return { ok: true };
|
|
},
|
|
|
|
"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 };
|
|
},
|
|
|
|
// self-update: trigger the rebuild, and poll status + "what's the latest version"
|
|
// (one OTM endpoint answers both). The build outlives the POST — expect 202, then poll.
|
|
// Owner-gated like every danger-tier action (an operator session impersonates
|
|
// the owner, so it passes) — matches the shared factory's mandatory guard.
|
|
"POST /api/account/self-update": async (_b, _q, session) => {
|
|
if (!(await isOwner(session))) return { _status: 401, error: "unauthorized" };
|
|
const c = otmCreds();
|
|
if (!c) return { _status: 503, error: "not_configured" };
|
|
return otmProxy(`${c.base}/api/instance/self-update`, { method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ serviceId: c.serviceId, ts: c.ts, sig: c.sig }) });
|
|
},
|
|
"GET /api/account/self-update": async (_b, _q, session) => {
|
|
if (!(await isOwner(session))) return { _status: 401, error: "unauthorized" };
|
|
const c = otmCreds();
|
|
if (!c) return { _status: 503, error: "not_configured" };
|
|
const q = new URLSearchParams({ serviceId: c.serviceId, ts: c.ts, sig: c.sig });
|
|
return otmProxy(`${c.base}/api/instance/self-update?${q}`);
|
|
},
|
|
};
|
|
|
|
const isOwner = async (session) => session?.operator
|
|
? true
|
|
: !!(await db.user.findFirst({ where: { email: session?.email || "", role: "OWNER" } }));
|
|
|
|
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", "/api/auth/operator-magic", "/api/auth/otm-sso"]);
|
|
|
|
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 = [];
|
|
let size = 0;
|
|
for await (const c of req) { // JSON routes only — photo/file uploads have their own readers
|
|
size += c.length;
|
|
if (size > 1e6) { req.destroy(); return send(413, { error: "too large" }); }
|
|
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) {
|
|
// Max-Age follows the payload's own exp — a 1h operator session must not
|
|
// leave a 30-day cookie behind.
|
|
const maxAge = Math.max(0, Math.floor((result._setSession.exp - Date.now()) / 1000));
|
|
headers["set-cookie"] = `bms_session=${sign(result._setSession)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
|
|
delete result._setSession;
|
|
}
|
|
if (result._clearSession) {
|
|
headers["set-cookie"] = "bms_session=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0";
|
|
delete result._clearSession;
|
|
}
|
|
if (result._redirect) return send(302, "", "text/plain", { ...headers, location: result._redirect });
|
|
const code = result._status ?? 200; // OTM-proxy routes pass their upstream status through
|
|
delete result._status;
|
|
return send(code, 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}`);
|
|
});
|