diff --git a/.gitignore b/.gitignore
index d0fefbf..6d8cbe9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
prototype/data/
.claude/settings.local.json
.DS_Store
+app/node_modules/
+app/data/
diff --git a/app/Dockerfile b/app/Dockerfile
new file mode 100644
index 0000000..243f7e2
--- /dev/null
+++ b/app/Dockerfile
@@ -0,0 +1,15 @@
+# bms-backbone — Studio Notebook (Postgres + Prisma, plain Node server)
+FROM node:22-bookworm
+
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+COPY prisma ./prisma
+RUN npx prisma generate
+
+COPY . .
+
+ENV NODE_ENV=production
+EXPOSE 3000
+ENTRYPOINT ["./docker-entrypoint.sh"]
diff --git a/app/docker-entrypoint.sh b/app/docker-entrypoint.sh
new file mode 100755
index 0000000..3662af7
--- /dev/null
+++ b/app/docker-entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+set -e
+echo "==> prisma db push (schema sync)"
+npx prisma db push --skip-generate
+echo "==> owner seed"
+node scripts/seed-owner.mjs
+echo "==> starting bms-backbone"
+exec node server.mjs
diff --git a/app/lib/version.mjs b/app/lib/version.mjs
new file mode 100644
index 0000000..0ba6e37
--- /dev/null
+++ b/app/lib/version.mjs
@@ -0,0 +1 @@
+export const APP_VERSION = "0.2.0";
diff --git a/app/package-lock.json b/app/package-lock.json
new file mode 100644
index 0000000..db159bf
--- /dev/null
+++ b/app/package-lock.json
@@ -0,0 +1,451 @@
+{
+ "name": "bms-backbone",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bms-backbone",
+ "version": "0.2.0",
+ "dependencies": {
+ "@prisma/client": "^6.7.0",
+ "bcryptjs": "^3.0.2"
+ },
+ "devDependencies": {
+ "prisma": "^6.7.0"
+ }
+ },
+ "node_modules/@prisma/client": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
+ "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "prisma": "*",
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/config": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
+ "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "c12": "3.1.0",
+ "deepmerge-ts": "7.1.5",
+ "effect": "3.21.0",
+ "empathic": "2.0.0"
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
+ "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
+ "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/fetch-engine": "6.19.3",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
+ "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
+ "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3",
+ "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
+ "@prisma/get-platform": "6.19.3"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
+ "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "6.19.3"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
+ "node_modules/c12": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
+ "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.3",
+ "confbox": "^0.2.2",
+ "defu": "^6.1.4",
+ "dotenv": "^16.6.1",
+ "exsolve": "^1.0.7",
+ "giget": "^2.0.0",
+ "jiti": "^2.4.2",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "perfect-debounce": "^1.0.0",
+ "pkg-types": "^2.2.0",
+ "rc9": "^2.1.2"
+ },
+ "peerDependencies": {
+ "magicast": "^0.3.5"
+ },
+ "peerDependenciesMeta": {
+ "magicast": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/citty": {
+ "version": "0.1.6",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
+ "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "consola": "^3.2.3"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
+ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/consola": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
+ "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.18.0 || >=16.10.0"
+ }
+ },
+ "node_modules/deepmerge-ts": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
+ "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
+ "devOptional": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/destr": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
+ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "devOptional": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/effect": {
+ "version": "3.21.0",
+ "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
+ "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "fast-check": "^3.23.1"
+ }
+ },
+ "node_modules/empathic": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
+ "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-check": {
+ "version": "3.23.2",
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
+ "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
+ "devOptional": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "pure-rand": "^6.1.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/giget": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
+ "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.1.6",
+ "consola": "^3.4.0",
+ "defu": "^6.1.4",
+ "node-fetch-native": "^1.6.6",
+ "nypm": "^0.6.0",
+ "pathe": "^2.0.3"
+ },
+ "bin": {
+ "giget": "dist/cli.mjs"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/node-fetch-native": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
+ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/nypm": {
+ "version": "0.6.9",
+ "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz",
+ "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "citty": "^0.2.2",
+ "pathe": "^2.0.3",
+ "tinyexec": "^1.2.4"
+ },
+ "bin": {
+ "nypm": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nypm/node_modules/citty": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
+ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/pkg-types": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
+ "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.2.4",
+ "exsolve": "^1.0.8",
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/prisma": {
+ "version": "6.19.3",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
+ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/config": "6.19.3",
+ "@prisma/engines": "6.19.3"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=18.18"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.1.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "devOptional": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/rc9": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
+ "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "destr": "^2.0.3"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/app/package.json b/app/package.json
new file mode 100644
index 0000000..d139ec8
--- /dev/null
+++ b/app/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "bms-backbone",
+ "version": "0.2.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "node server.mjs",
+ "db:push": "prisma db push"
+ },
+ "dependencies": {
+ "@prisma/client": "^6.7.0",
+ "bcryptjs": "^3.0.2"
+ },
+ "devDependencies": {
+ "prisma": "^6.7.0"
+ }
+}
diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma
new file mode 100644
index 0000000..23dc4e8
--- /dev/null
+++ b/app/prisma/schema.prisma
@@ -0,0 +1,160 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id Int @id @default(autoincrement())
+ email String @unique
+ passwordHash String
+ role String @default("OWNER")
+ created DateTime @default(now())
+}
+
+model Note {
+ id Int @id @default(autoincrement())
+ text String
+ tags String @default("")
+ created DateTime @default(now())
+}
+
+model Todo {
+ id Int @id @default(autoincrement())
+ text String
+ done Boolean @default(false)
+ parentId Int?
+ created DateTime @default(now())
+}
+
+model Shopping {
+ id Int @id @default(autoincrement())
+ text String
+ done Boolean @default(false)
+ created DateTime @default(now())
+}
+
+model Glaze {
+ id Int @id @default(autoincrement())
+ name String
+ kind String @default("glaze")
+ cone String @default("")
+ atmosphere String @default("")
+ surface String @default("")
+ source String @default("mine")
+ sg String @default("")
+ swatch String @default("")
+ created DateTime @default(now())
+ materials GlazeMaterial[]
+ journal GlazeJournal[]
+}
+
+model GlazeMaterial {
+ id Int @id @default(autoincrement())
+ glazeId Int
+ glaze Glaze @relation(fields: [glazeId], references: [id], onDelete: Cascade)
+ name String
+ pct Float
+ addition Boolean @default(false)
+}
+
+model GlazeJournal {
+ id Int @id @default(autoincrement())
+ glazeId Int
+ glaze Glaze @relation(fields: [glazeId], references: [id], onDelete: Cascade)
+ text String
+ created DateTime @default(now())
+}
+
+model Firing {
+ id Int @id @default(autoincrement())
+ type String @default("glaze")
+ cone String @default("∆6")
+ schedule String @default("")
+ status String @default("firing")
+ resultNotes String @default("")
+ started DateTime @default(now())
+ unloaded DateTime?
+ items FiringItem[]
+}
+
+model FiringItem {
+ id Int @id @default(autoincrement())
+ firingId Int
+ firing Firing @relation(fields: [firingId], references: [id], onDelete: Cascade)
+ piece String
+ clay String @default("")
+ glaze String @default("")
+ rating String @default("")
+ note String @default("")
+}
+
+model Photo {
+ id Int @id @default(autoincrement())
+ entity String
+ entityId Int
+ filename String
+ created DateTime @default(now())
+}
+
+model FileAsset {
+ id Int @id @default(autoincrement())
+ entity String @default("")
+ entityId Int @default(0)
+ stored String
+ name String
+ tag String @default("")
+ size Int @default(0)
+ created DateTime @default(now())
+}
+
+model Product {
+ id Int @id @default(autoincrement())
+ name String
+ kind String @default("core line")
+ category String @default("")
+ price Float @default(0)
+ cost Float @default(0)
+ qty Int @default(0)
+ status String @default("in progress")
+ notes String @default("")
+ created DateTime @default(now())
+ sales Sale[]
+}
+
+model Sale {
+ id Int @id @default(autoincrement())
+ productId Int
+ product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
+ qty Int @default(1)
+ price Float @default(0)
+ cost Float @default(0)
+ created DateTime @default(now())
+}
+
+model Supplier {
+ id Int @id @default(autoincrement())
+ name String
+ kind String @default("online")
+ distance String @default("")
+ url String @default("")
+ notes String @default("")
+ searchUrl String @default("")
+ created DateTime @default(now())
+ prices PriceEntry[]
+}
+
+model PriceEntry {
+ id Int @id @default(autoincrement())
+ item String
+ supplierId Int
+ supplier Supplier @relation(fields: [supplierId], references: [id], onDelete: Cascade)
+ price Float
+ unit String @default("")
+ shipping Float @default(0)
+ note String @default("")
+ created DateTime @default(now())
+}
diff --git a/app/public/app.js b/app/public/app.js
new file mode 100644
index 0000000..00e2d2f
--- /dev/null
+++ b/app/public/app.js
@@ -0,0 +1,618 @@
+// Studio Notebook prototype — vanilla JS, no build step
+const $ = (s, el = document) => el.querySelector(s);
+const view = $("#view");
+const api = async (path, body) => {
+ const r = await fetch(path, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined);
+ if (r.status === 401) { location.href = "/login"; return new Promise(() => {}); }
+ return r.json();
+};
+const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+const hour = new Date().getHours();
+$("#greeting").textContent = (hour < 12 ? "good morning" : hour < 17 ? "good afternoon" : "good evening") + ", Bonna";
+
+let current = "today";
+let subState = null; // inline to-do entry: {anchor: parent todo id, last: last-added id}
+document.querySelectorAll("#nav button").forEach((b) =>
+ b.addEventListener("click", () => {
+ document.querySelectorAll("#nav button").forEach((x) => x.classList.toggle("on", x === b));
+ current = b.dataset.v;
+ render();
+ }));
+
+function render() { ({ today, glazes, firings, pieces, files, sourcing, notes })[current](); }
+
+// ── jot box (shared) ─────────────────────────────────────────────
+function captureHTML(kinds) {
+ const chips = kinds.map(([k, label], i) => `${label}`).join("");
+ return `
+
+
+
file it as…${chips}
+
⌨️ Enter saves · Shift+Enter = subtask line · Tab switches type · press / from anywhere to jot
`;
+}
+function wireCapture(after) {
+ const jot = $("#jot");
+ const chips = [...document.querySelectorAll(".capture .chip")];
+ const select = (c) => chips.forEach((x) => x.classList.toggle("on", x === c));
+ chips.forEach((c) => c.addEventListener("click", () => select(c)));
+ const save = async () => {
+ const text = jot.value.trim();
+ if (!text) return;
+ const kind = $(".capture .chip.on")?.dataset.kind || "note";
+ await api("/api/jot", { text, kind });
+ await after();
+ const j = $("#jot");
+ if (j) { j.value = ""; j.focus(); } // stay in the box — rapid-fire jotting
+ };
+ $("#jot-save").addEventListener("click", save);
+ jot.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); }
+ else if (e.key === "Enter" && e.shiftKey) { autogrow(); }
+ else if (e.key === "Tab") {
+ e.preventDefault();
+ const i = chips.findIndex((c) => c.classList.contains("on"));
+ select(chips[(i + (e.shiftKey ? chips.length - 1 : 1)) % chips.length]);
+ }
+ });
+ const autogrow = () => setTimeout(() => { jot.style.height = "auto"; jot.style.height = jot.scrollHeight + "px"; });
+ jot.addEventListener("input", autogrow);
+}
+// "/" from anywhere jumps to the jot box
+document.addEventListener("keydown", (e) => {
+ if (e.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) {
+ const j = $("#jot");
+ if (j) { e.preventDefault(); j.focus(); }
+ }
+});
+
+// ── Today ────────────────────────────────────────────────────────
+function todoRow(t, sub) {
+ return `
+
+ ${sub ? "↳ " : ""}${esc(t.text)}
+ ${sub ? "" : ``}
+ ${sub ? "" : `
+
`}`;
+}
+async function today() {
+ const s = await api("/api/summary");
+ const parents = s.todos.filter((t) => !t.parent_id);
+ const kids = (id) => s.todos.filter((t) => t.parent_id === id).sort((a, b) => a.id - b.id);
+ const open = parents.filter((t) => !t.done || kids(t.id).some((k) => !k.done));
+ const rows = (list) => list.map((t) => todoRow(t) + kids(t.id).map((k) => todoRow(k, true)).join("")).join("");
+ const todoRows = rows(open.slice(0, 14)) || `nothing yet — jot one above ✨
`;
+ const shopRows = s.shopping.map((t) =>
+ `${esc(t.text)}
`).join("") || `list is empty — lucky you 🧺
`;
+ const noteRows = s.notes.map((n) => `“${esc(n.text.slice(0, 80))}${n.text.length > 80 ? "…" : ""}”
`).join("") || `no notes yet
`;
+ const kiln = s.activeFiring
+ ? `🔥 Firing #${s.activeFiring.id} in progress — ${esc(s.activeFiring.type)} ${esc(s.activeFiring.cone)}
+ `
+ : `Kiln idle${s.lastUnload ? ` — last unload ${esc(s.lastUnload.unloaded?.slice(0, 10))}` : ""}
`;
+ view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"], ["glaze idea", "🧪 glaze idea"]]) +
+ `
+
To-do
${todoRows}
+
Shopping list 🧺
${shopRows}
+
Kiln
${kiln}
+
Recent notes
${noteRows}
+
`;
+ wireCapture(today);
+ // outliner-style inline entry: ⇧Enter = another subtask, Enter = new top-level
+ // task (input follows it), ⌫ on empty = undo last add, Esc closes
+ const openForm = () => {
+ if (!subState) return;
+ const form = view.querySelector(`[data-subform="${subState.anchor}"]`);
+ if (!form) { subState = null; return; }
+ form.hidden = false;
+ form.querySelector("input").focus();
+ };
+ openForm();
+ view.querySelectorAll("[data-sub]").forEach((b) =>
+ b.addEventListener("click", () => {
+ const id = Number(b.dataset.sub);
+ subState = subState?.anchor === id ? null : { anchor: id, last: null };
+ view.querySelectorAll(".subform").forEach((f) => (f.hidden = true));
+ openForm();
+ }));
+ view.querySelectorAll("[data-subform] input").forEach((inp) =>
+ inp.addEventListener("keydown", async (e) => {
+ const text = inp.value.trim();
+ if (e.key === "Enter" && e.shiftKey && text) {
+ e.preventDefault();
+ const r = await api("/api/todos/add", { text, parent_id: subState.anchor });
+ subState.last = r.id;
+ today();
+ } else if (e.key === "Enter" && !e.shiftKey && text) {
+ e.preventDefault();
+ const r = await api("/api/todos/add", { text });
+ subState = { anchor: r.id, last: r.id };
+ today();
+ } else if (e.key === "Backspace" && !inp.value && subState?.last) {
+ e.preventDefault();
+ const undoing = subState.last;
+ await api("/api/todos/delete", { id: undoing });
+ subState = undoing === subState.anchor ? null : { ...subState, last: null };
+ today();
+ } else if (e.key === "Escape" || (e.key === "Enter" && !text)) {
+ subState = null;
+ today();
+ }
+ }));
+ view.querySelectorAll("[data-t]").forEach((c) => c.addEventListener("change", async () => { await api("/api/todos/toggle", { id: Number(c.dataset.t) }); today(); }));
+ view.querySelectorAll("[data-s]").forEach((c) => c.addEventListener("change", async () => { await api("/api/shopping/toggle", { id: Number(c.dataset.s) }); today(); }));
+ $("#goto-firing")?.addEventListener("click", () => $('#nav button[data-v="firings"]').click());
+}
+
+// ── Glazes ───────────────────────────────────────────────────────
+const SWATCHES = ["#8fb0a3", "#c98d5f", "#cfc8ba", "#7f9bb3", "#b3849a", "#8a8f6a", "#6e4f78", "#c9b8a4"];
+async function glazes() {
+ const list = await api("/api/glazes");
+ const card = (g) => `
+
+
+
${esc(g.name)}
+
${esc([g.cone, g.atmosphere, g.surface].filter(Boolean).join(" · ") || g.kind)}
+
`;
+ const section = (label) => `${label}
`;
+ const glazeCards = list.filter((g) => !g.kind || g.kind === "glaze").map(card).join("");
+ const slipCards = list.filter((g) => g.kind === "casting slip").map(card).join("");
+ const clayCards = list.filter((g) => g.kind === "clay body").map(card).join("");
+ view.innerHTML = `
+
+ ${glazeCards || '
no glazes yet — add your first 🧪
'}
+ ${slipCards ? section("Casting slips 🏺") + `${slipCards}
` : ""}
+ ${clayCards ? section("Clay bodies 🧱") + `${clayCards}
` : ""}`;
+ $("#new-glaze").addEventListener("click", newGlazeForm);
+ view.querySelectorAll("[data-g]").forEach((c) => c.addEventListener("click", () => glazeDetail(Number(c.dataset.g))));
+}
+
+function newGlazeForm() {
+ const chip = (group, vals, on) => vals.map((v) => `${v}`).join("");
+ view.innerHTML = `
+
+
New glaze
+
Only the name is required — everything else can arrive later.
+
+
+
${chip("kind", ["glaze", "casting slip", "clay body"], "glaze")}
+
${chip("cone", ["∆04", "∆6", "∆10"], "∆6")}
+
${chip("atmosphere", ["oxidation", "reduction"], "oxidation")}
+
${chip("surface", ["glossy", "satin", "matte"], "")}
+
${chip("source", ["mine", "Glazy", "book / friend"], "mine")}
+
${SWATCHES.map((s, i) => `●`).join("")}
+
+
+
+
+
+
`;
+ const mats = $("#mats");
+ const addRow = (addition) => {
+ const row = document.createElement("div");
+ row.className = "matrow";
+ row.innerHTML = ``;
+ mats.appendChild(row);
+ };
+ addRow(false); addRow(false); addRow(false);
+ $("#add-mat").addEventListener("click", () => addRow(false));
+ $("#add-addition").addEventListener("click", () => addRow(true));
+ view.querySelectorAll(".chip[data-group]").forEach((c) =>
+ c.addEventListener("click", () => {
+ view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c));
+ }));
+ $("#back").addEventListener("click", glazes);
+ $("#save-glaze").addEventListener("click", async () => {
+ const name = $("#g-name").value.trim();
+ if (!name) return alert("A name is all it needs 🙂");
+ const pick = (g) => view.querySelector(`.chip[data-group="${g}"].on`)?.dataset.val || "";
+ const materials = [...mats.querySelectorAll(".matrow")].map((r) => {
+ const [n, p] = r.querySelectorAll("input");
+ return n.value.trim() && p.value.trim() ? { name: n.value.trim(), pct: parseFloat(p.value), addition: n.dataset.add === "1" } : null;
+ }).filter(Boolean);
+ await api("/api/glazes", { name, kind: pick("kind"), cone: pick("cone"), atmosphere: pick("atmosphere"), surface: pick("surface"), source: pick("source"), swatch: pick("swatch"), materials });
+ glazes();
+ });
+}
+
+async function glazeDetail(id) {
+ const g = await api(`/api/glaze?id=${id}`);
+ const batchSizes = [500, 1000, 2000, 5000];
+ const batch = (pct, size) => Math.round(pct * size) / 100;
+ let size = 2000;
+ const recipeTable = () => `
+ | Material | % | ${size >= 1000 ? size / 1000 + " kg" : size + " g"} batch |
+ ${g.materials.map((m) => `| ${m.addition ? "+ " : ""}${esc(m.name)} | ${m.pct} | ${batch(m.pct, size)} g |
`).join("") || '| no recipe yet |
'}
`;
+ const compat = g.compatibility.length
+ ? `| Clay body | Result | Evidence |
+ ${g.compatibility.map((c) => `| ${esc(c.clay)} | ${esc(c.rating)} | ${c.n} firing${c.n > 1 ? "s" : ""}${c.notes ? " · " + esc(c.notes) : ""} |
`).join("")}
`
+ : `no evidence yet — it builds itself from unload days 🌱
`;
+ view.innerHTML = `
+
+
+
${esc(g.name)}
+
${esc([g.kind !== "glaze" ? g.kind : "", g.cone, g.atmosphere, g.surface, g.source].filter(Boolean).join(" · "))}
+
Recipe
${recipeTable()}
+
${batchSizes.map((b) => `${b >= 1000 ? b / 1000 + " kg" : b + " g"}`).join("")}
+
Plays well with…
${compat}
+
Journal
+ ${g.journal.map((j) => `
📒 ${esc(j.created.slice(0, 10))} — ${esc(j.text)}
`).join("") || '
no entries yet
'}
+
+
Photos
+ ${g.photos.map((p) => `

`).join("")}
+
+
+
`;
+ $("#back").addEventListener("click", glazes);
+ view.querySelectorAll("[data-b]").forEach((c) =>
+ c.addEventListener("click", () => { size = Number(c.dataset.b); $("#recipe").innerHTML = recipeTable();
+ view.querySelectorAll("[data-b]").forEach((x) => x.classList.toggle("on", x === c)); }));
+ $("#j-save").addEventListener("click", async () => {
+ const text = $("#j-text").value.trim();
+ if (text) { await api("/api/glaze/journal", { glaze_id: id, text }); glazeDetail(id); }
+ });
+ $("#ph-add").addEventListener("click", () => $("#ph-file").click());
+ $("#ph-file").addEventListener("change", async (e) => {
+ const f = e.target.files[0];
+ if (!f) return;
+ await fetch(`/api/photos?entity=glaze&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f });
+ glazeDetail(id);
+ });
+}
+
+// ── Firing Log ───────────────────────────────────────────────────
+async function firings() {
+ const list = await api("/api/firings");
+ const library = await api("/api/glazes");
+ const glazeNames = library.filter((g) => g.kind !== "clay body").map((g) => g.name);
+ const clayNames = library.filter((g) => g.kind === "clay body").map((g) => g.name);
+ const active = list.find((f) => f.status === "firing");
+ let html = "";
+ if (!active) {
+ html += `Start a firing 🔥
+
Fill-the-kiln-and-go: pick a type and hit start. Load list is optional, always.
+
${["bisque", "glaze"].map((t, i) => `${t}`).join("")}
+ ${["∆04", "∆6", "∆10"].map((c, i) => `${c}`).join("")}
+
`;
+ } else {
+ const items = active.items.map((it) => `${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}
`).join("");
+ html += `Firing #${active.id} — in progress 🔥
+
${esc(active.type)} ${esc(active.cone)} · started ${esc(active.started.slice(0, 16))}
+
${items || '
no load list — totally fine! add pieces whenever, even at unload
'}
+
+
`;
+ }
+ html += list.filter((f) => f.status === "unloaded").map((f) => `
+ Firing #${f.id} — ${esc(f.type)} ${esc(f.cone)}
+
${esc(f.started.slice(0, 10))} → unloaded ${esc((f.unloaded || "").slice(0, 10))}
+ ${f.items.map((it) => `
${it.rating || ""} ${esc(it.piece)}${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}${it.note ? " — " + esc(it.note) : ""}
`).join("")}
+ ${f.result_notes ? `
✨ ${esc(f.result_notes)}
` : ""}
`).join("");
+ view.innerHTML = html || 'no firings yet
';
+ view.querySelectorAll("[data-ft],[data-fc]").forEach((c) =>
+ c.addEventListener("click", () => {
+ const sel = c.dataset.ft !== undefined ? "[data-ft]" : "[data-fc]";
+ view.querySelectorAll(sel).forEach((x) => x.classList.toggle("on", x === c));
+ }));
+ $("#start-firing")?.addEventListener("click", async () => {
+ await api("/api/firings", { type: view.querySelector("[data-ft].on").dataset.ft, cone: view.querySelector("[data-fc].on").dataset.fc });
+ firings();
+ });
+ $("#fi-add")?.addEventListener("click", async () => {
+ const piece = $("#fi-piece").value.trim();
+ if (!piece) return;
+ await api("/api/firing/items", { firing_id: active.id, piece, clay: $("#fi-clay").value.trim(), glaze: $("#fi-glaze").value });
+ firings();
+ });
+ $("#unload")?.addEventListener("click", () => unloadDay(active));
+}
+
+async function unloadDay(f) {
+ const fresh = (await api("/api/firings")).find((x) => x.id === f.id);
+ const EMOJI = ["😍", "🙂", "😕", "💔"];
+ view.innerHTML = `
+ Firing #${fresh.id} — unload day! ✨
+
Rate each thing as it comes out. One tap each.
+ ${fresh.items.map((it) => `
${esc(it.piece)} ${esc([it.clay, it.glaze].filter(Boolean).join(" · "))}
+ ${EMOJI.map((e) => ``).join("")}
`).join("") ||
+ '
no items listed — add what came out below, then rate it
'}
+
+
+
`;
+ $("#back").addEventListener("click", firings);
+ view.querySelectorAll(".verdict").forEach((v) =>
+ v.querySelectorAll("button").forEach((b) =>
+ b.addEventListener("click", async () => {
+ await api("/api/firing/rate", { id: Number(v.dataset.item), rating: b.dataset.e });
+ v.querySelectorAll("button").forEach((x) => x.classList.toggle("on", x === b));
+ })));
+ $("#fi-add").addEventListener("click", async () => {
+ const piece = $("#fi-piece").value.trim();
+ if (piece) { await api("/api/firing/items", { firing_id: fresh.id, piece }); unloadDay(fresh); }
+ });
+ $("#save-unload").addEventListener("click", async () => {
+ await api("/api/firing/unload", { id: fresh.id, result_notes: $("#f-notes").value.trim() });
+ firings();
+ });
+}
+
+// ── Pieces ───────────────────────────────────────────────────────
+const money = (n) => "$" + (Math.round(n * 100) / 100).toLocaleString();
+async function pieces() {
+ const { products, money: m } = await api("/api/products");
+ const core = products.filter((p) => p.kind === "core line");
+ const tiles = products.filter((p) => p.kind === "tiles");
+ const oneoff = products.filter((p) => p.kind !== "core line" && p.kind !== "tiles");
+ const catPill = (p) => (p.category ? `${esc(p.category)}` : "");
+ const coreCard = (p) => `
+ ${esc(p.name)} ›
+
${catPill(p)} ${money(p.price)}
+
on hand: ${p.qty}
+
+
+
`;
+ const STATUSES = ["in progress", "for sale", "sold"];
+ const oneoffCard = (p) => `
+ ${esc(p.name)} ›
+
${catPill(p)} ${esc(p.kind)} ${money(p.price)}
+
${STATUSES.map((s) => `${s}`).join("")}
+ ${p.status !== "sold" ? `` : ""}
`;
+ const section = (label) => `${label}
`;
+ view.innerHTML = `
+ Money 💰
+
+
this month
${money(m.monthRevenue)}
+
all-time sales (${m.salesCount})
${money(m.revenue)}
+
est. profit
${money(m.revenue - m.cogs)}
+
+
profit = sales minus each piece's cost estimate · real materials-based costing comes later
+
+ ${section("Core line ✨")}
+ ${core.map(coreCard).join("") || '
the reintroduced line starts here — kitchen tools, serveware…
'}
+ ${tiles.length ? section("Tiles 🔷") + `${tiles.map(coreCard).join("")}
` : ""}
+ ${section("Small batch & one of a kind 🌙")}
+ ${oneoff.map(oneoffCard).join("") || '
sculptural pieces, home scenting, experiments…
'}
`;
+ $("#new-piece").addEventListener("click", newPieceForm);
+ view.querySelectorAll("[data-open]").forEach((h) =>
+ h.addEventListener("click", () => pieceDetail(Number(h.dataset.open))));
+ view.querySelectorAll("[data-q]").forEach((b) =>
+ b.addEventListener("click", async () => { await api("/api/products/qty", { id: Number(b.dataset.q), delta: Number(b.dataset.d) }); pieces(); }));
+ view.querySelectorAll("[data-st]").forEach((c) =>
+ c.addEventListener("click", async () => { await api("/api/products/status", { id: Number(c.dataset.st), status: c.dataset.v }); pieces(); }));
+ view.querySelectorAll("[data-sell]").forEach((b) =>
+ b.addEventListener("click", async () => { await api("/api/sales", { product_id: Number(b.dataset.sell) }); pieces(); }));
+}
+
+function newPieceForm() {
+ const chip = (group, vals, on) => vals.map((v) => `${v}`).join("");
+ view.innerHTML = `
+ New piece
+
Name is enough — price, cost, and count can arrive later.
+
+
+
`;
+ view.querySelectorAll(".chip[data-group]").forEach((c) =>
+ c.addEventListener("click", () => view.querySelectorAll(`.chip[data-group="${c.dataset.group}"]`).forEach((x) => x.classList.toggle("on", x === c))));
+ $("#back").addEventListener("click", pieces);
+ $("#save-piece").addEventListener("click", async () => {
+ const name = $("#p-name").value.trim();
+ if (!name) return alert("Just a name is fine 🙂");
+ await api("/api/products", {
+ name, kind: view.querySelector('.chip[data-group="kind"].on')?.dataset.val,
+ category: $("#p-cat").value.trim(), price: $("#p-price").value, cost: $("#p-cost").value, qty: $("#p-qty").value });
+ pieces();
+ });
+}
+
+// ── Files ────────────────────────────────────────────────────────
+const FILE_TAGS = ["master molds", "foam forms", "prototypes", "other"];
+const fmtSize = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1024)) + " KB";
+const fileIcon = (name) => /\.(stl|obj|3mf|step|stp)$/i.test(name) ? "🧊" : /\.(svg|dxf|ai|eps)$/i.test(name) ? "✂️" : /\.(png|jpe?g|webp|heic)$/i.test(name) ? "🖼️" : /\.(pdf)$/i.test(name) ? "📄" : "📁";
+function fileRow(f, showHome) {
+ return `${fileIcon(f.name)} ${esc(f.name)}
+ ${fmtSize(f.size)} · ${esc(f.created.slice(0, 10))}
+
${f.tag ? `${esc(f.tag)}` : ""}
+ ${showHome && f.product_name ? `🏺 ${esc(f.product_name)}` : ""}
+ `;
+}
+function uploadWidget(entity, entityId, after) {
+ return {
+ html: `
+
+
+
tag as…${FILE_TAGS.map((t, i) => `${t}`).join("")}
`,
+ wire() {
+ view.querySelectorAll("[data-ftag]").forEach((c) =>
+ c.addEventListener("click", () => view.querySelectorAll("[data-ftag]").forEach((x) => x.classList.toggle("on", x === c))));
+ $("#file-pick").addEventListener("click", () => $("#file-input").click());
+ $("#file-input").addEventListener("change", async (e) => {
+ const tag = view.querySelector("[data-ftag].on")?.dataset.ftag || "";
+ for (const f of e.target.files) {
+ const q = new URLSearchParams({ name: f.name, tag, ...(entity ? { entity, id: entityId } : {}) });
+ await fetch(`/api/files?${q}`, { method: "POST", body: f });
+ }
+ after();
+ });
+ },
+ };
+}
+function wireFileDeletes(after) {
+ view.querySelectorAll("[data-fdel]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ if (confirm("Delete this file?")) { await api("/api/files/delete", { id: Number(b.dataset.fdel) }); after(); }
+ }));
+}
+async function files() {
+ const list = await api("/api/files");
+ const up = uploadWidget("", 0, files);
+ const loose = list.filter((f) => !f.entity_id);
+ const attached = list.filter((f) => f.entity_id);
+ const section = (label) => `${label}
`;
+ view.innerHTML = `${up.html}
+ ${section("Studio files 🗂️")}
+ ${loose.map((f) => fileRow(f, false)).join("") || '
STLs for master molds, foam form templates, cut files… upload anything ⬆️
'}
+ ${attached.length ? `${section("Attached to pieces 🏺")}${attached.map((f) => fileRow(f, true)).join("")}
` : ""}`;
+ up.wire();
+ wireFileDeletes(files);
+}
+
+// ── Piece detail ─────────────────────────────────────────────────
+async function pieceDetail(id) {
+ const p = await api(`/api/product?id=${id}`);
+ if (!p) return pieces();
+ const counted = p.kind === "core line" || p.kind === "tiles";
+ const STATUSES = ["in progress", "for sale", "sold"];
+ const up = uploadWidget("product", id, () => pieceDetail(id));
+ view.innerHTML = `
+
+
${esc(p.name)}
+
${p.category ? `${esc(p.category)}` : ""}
+ ${esc(p.kind)} ${money(p.price)}
+ ${p.cost ? `cost ~${money(p.cost)}` : ""}
+
${counted
+ ? `on hand: ${p.qty}
+
+ `
+ : `${STATUSES.map((s) => `${s}`).join("")}
+ ${p.status !== "sold" ? `` : ""}`}
+ ${p.sales.length ? `
💰 ${p.sales.length} recent sale${p.sales.length > 1 ? "s" : ""} — last ${esc(p.sales[0].created.slice(0, 10))}
` : ""}
+
+
+
+
+
Files — molds, templates, cut forms
+ ${p.files.map((f) => fileRow(f, false)).join("") || '
nothing attached yet
'}
+ ${up.html}
+
`;
+ $("#back").addEventListener("click", pieces);
+ view.querySelectorAll("[data-q]").forEach((b) =>
+ b.addEventListener("click", async () => { await api("/api/products/qty", { id, delta: Number(b.dataset.q) }); pieceDetail(id); }));
+ view.querySelectorAll("[data-st]").forEach((c) =>
+ c.addEventListener("click", async () => { await api("/api/products/status", { id, status: c.dataset.st }); pieceDetail(id); }));
+ $("#sell")?.addEventListener("click", async () => { await api("/api/sales", { product_id: id }); pieceDetail(id); });
+ $("#save-notes").addEventListener("click", async () => { await api("/api/products/notes", { id, notes: $("#p-notes").value }); });
+ $("#ph-add").addEventListener("click", () => $("#ph-file").click());
+ $("#ph-file").addEventListener("change", async (e) => {
+ const f = e.target.files[0];
+ if (f) { await fetch(`/api/photos?entity=product&id=${id}&name=${encodeURIComponent(f.name)}`, { method: "POST", body: f }); pieceDetail(id); }
+ });
+ up.wire();
+ wireFileDeletes(() => pieceDetail(id));
+}
+
+// ── Sourcing ─────────────────────────────────────────────────────
+async function sourcing() {
+ const { suppliers, entries } = await api("/api/sourcing");
+ const section = (label) => `${label}
`;
+ const total = (e) => e.price + e.shipping;
+ // group price entries by item (case-insensitive)
+ const items = {};
+ for (const e of entries) (items[e.item.toLowerCase()] ??= { name: e.item, entries: [] }).entries.push(e);
+ const itemCard = (it) => {
+ // newest entry per supplier decides "current" prices; lowest total wins
+ const latestBySupplier = {};
+ for (const e of it.entries) latestBySupplier[e.supplier_id] ??= e; // entries are newest-first
+ const currents = Object.values(latestBySupplier).sort((a, b) => total(a) - total(b));
+ const bestId = currents[0]?.id;
+ const rows = it.entries.map((e) => {
+ // trend vs previous entry from the same supplier
+ const prev = it.entries.find((x) => x.supplier_id === e.supplier_id && x.id < e.id);
+ const trend = prev ? (total(e) > total(prev) ? ` ▲ costs more now`
+ : total(e) < total(prev) ? ` ▼ cheaper` : "") : "";
+ const drivable = e.supplier_kind === "local" ? ` 🚗 ${esc(e.distance || "drivable")}` : "";
+ return `${esc(e.supplier)}${drivable} ${esc(e.created.slice(0, 10))}${e.unit ? " · " + esc(e.unit) : ""}${e.note ? " · " + esc(e.note) : ""}
+ ${money(e.price)}${e.shipping ? ` + ${money(e.shipping)} ship = ${money(total(e))}` : ""}${trend}
+ ${e.id === bestId && currents.length > 1 ? ' best 🏆' : ""}
`;
+ }).join("");
+ return `${esc(it.name)}
${rows}`;
+ };
+ const supplierCard = (s) => `
+ ${esc(s.name)}
+
${s.kind === "local" ? "🚗 " + esc(s.distance || "drivable") : "📦 online"}
+ ${s.url ? `
visit site ↗` : ""}
+ ${s.notes ? `
${esc(s.notes)}
` : ""}
`;
+ const searchable = suppliers.filter((s) => s.search_url);
+ view.innerHTML = `
+ ${searchable.length ? `
+
+
+
opens each supplier's live search — real prices, real stock, then log what you see
` : ""}
+
+ ${Object.values(items).map(itemCard).join("") || 'no prices logged yet — start with your next order 🧭
'}
+ ${section("Suppliers")}
+ ${suppliers.map(supplierCard).join("") || '
MN Clay, the online places…
'}
+ + New supplier
+
+
+
`;
+ view.querySelectorAll("[data-skind]").forEach((c) =>
+ c.addEventListener("click", () => view.querySelectorAll("[data-skind]").forEach((x) => x.classList.toggle("on", x === c))));
+ $("#s-save").addEventListener("click", async () => {
+ const name = $("#s-name").value.trim();
+ if (!name) return alert("Name is all it needs 🙂");
+ await api("/api/suppliers", { name, kind: view.querySelector("[data-skind].on").dataset.skind,
+ distance: $("#s-dist").value.trim(), url: $("#s-url").value.trim(), notes: $("#s-notes").value.trim(),
+ search_url: $("#s-search").value.trim() });
+ sourcing();
+ });
+ const priceCheck = () => {
+ const q = $("#pc-q")?.value.trim();
+ if (!q) return;
+ $("#pc-links").innerHTML = searchable.map((s) =>
+ `🔎 ${esc(s.name)} ↗`).join("");
+ };
+ $("#pc-go")?.addEventListener("click", priceCheck);
+ $("#pc-q")?.addEventListener("keydown", (e) => e.key === "Enter" && priceCheck());
+ const logPrice = async () => {
+ const item = $("#pe-item").value.trim(), sup = $("#pe-supplier").value;
+ if (!item || !sup) return;
+ await api("/api/price-entries", { item, supplier_id: Number(sup), price: $("#pe-price").value, shipping: $("#pe-ship").value });
+ sourcing();
+ };
+ $("#pe-save").addEventListener("click", logPrice);
+ ["pe-item", "pe-price", "pe-ship"].forEach((id) =>
+ $("#" + id).addEventListener("keydown", (e) => e.key === "Enter" && logPrice()));
+}
+
+// ── Notes ────────────────────────────────────────────────────────
+async function notes() {
+ const list = await api("/api/notes");
+ view.innerHTML = captureHTML([["note", "✨ note"], ["todo", "✓ to-do"], ["shopping", "🧺 shopping"]]) +
+ `${list.map((n) => `
+
${esc(n.text)}
+
${esc(n.created.slice(0, 16))}
+ ${n.tags ? `${esc(n.tags)}` : ""}
`).join("") ||
+ '
no notes yet — the jot box awaits ✨
'}
`;
+ wireCapture(notes);
+}
+
+document.getElementById("signout")?.addEventListener("click", async () => {
+ await api("/api/logout", {});
+ location.href = "/login";
+});
+
+render();
diff --git a/app/public/index.html b/app/public/index.html
new file mode 100644
index 0000000..75b4552
--- /dev/null
+++ b/app/public/index.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+🌙 Studio Notebook
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/public/login.html b/app/public/login.html
new file mode 100644
index 0000000..02b05c6
--- /dev/null
+++ b/app/public/login.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+🌙 Studio Notebook — sign in
+
+
+
+
+
🌙 Studio Notebook
+
welcome back to the studio
+
+
+
+
+
diff --git a/app/scripts/migrate-sqlite.mjs b/app/scripts/migrate-sqlite.mjs
new file mode 100644
index 0000000..e96d467
--- /dev/null
+++ b/app/scripts/migrate-sqlite.mjs
@@ -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 "); 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();
diff --git a/app/scripts/seed-owner.mjs b/app/scripts/seed-owner.mjs
new file mode 100644
index 0000000..830b0e9
--- /dev/null
+++ b/app/scripts/seed-owner.mjs
@@ -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();
diff --git a/app/server.mjs b/app/server.mjs
new file mode 100644
index 0000000..a055cfc
Binary files /dev/null and b/app/server.mjs differ