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>
This commit is contained in:
Bonna
2026-08-06 22:10:55 -05:00
parent f4c1d6ca94
commit c4ba1591c7
13 changed files with 1512 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
// One-shot: copy Bonna's laptop prototype data (SQLite) into the hosted
// Postgres. Run from app/ with DATABASE_URL set:
// node scripts/migrate-sqlite.mjs /path/to/prototype/data/studio.db
// Idempotent-ish: skips any table that already has rows in Postgres.
import { DatabaseSync } from "node:sqlite";
import { PrismaClient } from "@prisma/client";
const src = process.argv[2];
if (!src) { console.error("usage: node scripts/migrate-sqlite.mjs <studio.db>"); process.exit(1); }
const lite = new DatabaseSync(src);
const db = new PrismaClient();
const rows = (sql) => { try { return lite.prepare(sql).all(); } catch { return []; } };
const d = (s) => (s ? new Date(s.replace(" ", "T")) : new Date());
async function copy(name, model, sqliteSql, map) {
if ((await model.count()) > 0) return console.log(`${name}: postgres has rows — skipped`);
const data = rows(sqliteSql);
for (const r of data) await model.create({ data: map(r) });
console.log(`${name}: ${data.length} migrated`);
}
// order matters (children reference parents; ids preserved by inserting in id order)
await copy("suppliers", db.supplier, "SELECT * FROM suppliers ORDER BY id", (r) => ({
id: r.id, name: r.name, kind: r.kind, distance: r.distance, url: r.url, notes: r.notes,
searchUrl: r.search_url || "", created: d(r.created) }));
await copy("price_entries", db.priceEntry, "SELECT * FROM price_entries ORDER BY id", (r) => ({
id: r.id, item: r.item, supplierId: r.supplier_id, price: r.price, unit: r.unit,
shipping: r.shipping, note: r.note, created: d(r.created) }));
await copy("glazes", db.glaze, "SELECT * FROM glazes ORDER BY id", (r) => ({
id: r.id, name: r.name, kind: r.kind, cone: r.cone, atmosphere: r.atmosphere, surface: r.surface,
source: r.source, sg: r.sg, swatch: r.swatch, created: d(r.created) }));
await copy("glaze_materials", db.glazeMaterial, "SELECT * FROM glaze_materials ORDER BY id", (r) => ({
id: r.id, glazeId: r.glaze_id, name: r.name, pct: r.pct, addition: !!r.addition }));
await copy("glaze_journal", db.glazeJournal, "SELECT * FROM glaze_journal ORDER BY id", (r) => ({
id: r.id, glazeId: r.glaze_id, text: r.text, created: d(r.created) }));
await copy("firings", db.firing, "SELECT * FROM firings ORDER BY id", (r) => ({
id: r.id, type: r.type, cone: r.cone, schedule: r.schedule, status: r.status,
resultNotes: r.result_notes, started: d(r.started), unloaded: r.unloaded ? d(r.unloaded) : null }));
await copy("firing_items", db.firingItem, "SELECT * FROM firing_items ORDER BY id", (r) => ({
id: r.id, firingId: r.firing_id, piece: r.piece, clay: r.clay, glaze: r.glaze, rating: r.rating, note: r.note }));
await copy("products", db.product, "SELECT * FROM products ORDER BY id", (r) => ({
id: r.id, name: r.name, kind: r.kind, category: r.category, price: r.price, cost: r.cost,
qty: r.qty, status: r.status, notes: r.notes || "", created: d(r.created) }));
await copy("sales", db.sale, "SELECT * FROM sales ORDER BY id", (r) => ({
id: r.id, productId: r.product_id, qty: r.qty, price: r.price, cost: r.cost, created: d(r.created) }));
await copy("todos", db.todo, "SELECT * FROM todos ORDER BY id", (r) => ({
id: r.id, text: r.text, done: !!r.done, parentId: r.parent_id, created: d(r.created) }));
await copy("shopping", db.shopping, "SELECT * FROM shopping ORDER BY id", (r) => ({
id: r.id, text: r.text, done: !!r.done, created: d(r.created) }));
await copy("notes", db.note, "SELECT * FROM notes ORDER BY id", (r) => ({
id: r.id, text: r.text, tags: r.tags, created: d(r.created) }));
await copy("photos", db.photo, "SELECT * FROM photos ORDER BY id", (r) => ({
id: r.id, entity: r.entity, entityId: r.entity_id, filename: r.filename, created: d(r.created) }));
await copy("files", db.fileAsset, "SELECT * FROM files ORDER BY id", (r) => ({
id: r.id, entity: r.entity, entityId: r.entity_id, stored: r.stored, name: r.name,
tag: r.tag, size: r.size, created: d(r.created) }));
// bump Postgres sequences past the migrated ids
for (const t of ["User", "Note", "Todo", "Shopping", "Glaze", "GlazeMaterial", "GlazeJournal",
"Firing", "FiringItem", "Photo", "FileAsset", "Product", "Sale", "Supplier", "PriceEntry"]) {
await db.$executeRawUnsafe(
`SELECT setval(pg_get_serial_sequence('"${t}"','id'), COALESCE((SELECT MAX(id) FROM "${t}"), 0) + 1, false)`);
}
console.log("sequences bumped — done 🌙 (photos/files binaries: copy prototype/data/{photos,files} into the container's /app/data)");
await db.$disconnect();

View File

@@ -0,0 +1,18 @@
// OTM owner seed — runs at container start. Creates the owner ONLY when
// SEED_OWNER_EMAIL/SEED_OWNER_PASSWORD_HASH are set AND the User table is
// empty (the platform convention — see platform CLAUDE.md "Owner seed").
import { PrismaClient } from "@prisma/client";
const db = new PrismaClient();
const email = process.env.SEED_OWNER_EMAIL?.toLowerCase().trim();
const hash = process.env.SEED_OWNER_PASSWORD_HASH;
if (!email || !hash) {
console.log("seed-owner: SEED_OWNER_* not set — skipping");
} else if ((await db.user.count()) > 0) {
console.log("seed-owner: users exist — skipping");
} else {
await db.user.create({ data: { email, passwordHash: hash, role: "OWNER" } });
console.log(`seed-owner: created OWNER ${email}`);
}
await db.$disconnect();