Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ef82748ea | ||
|
|
59645e59b5 | ||
|
|
9f0a3dbc74 | ||
|
|
f3d026a109 | ||
|
|
7416898bec | ||
|
|
057d68156b | ||
|
|
99d7e8f121 | ||
|
|
8f82000e31 | ||
|
|
089ee6916f | ||
|
|
122427d30d | ||
|
|
f56dc8efa9 | ||
|
|
22e6e721a7 | ||
|
|
892479dceb | ||
|
|
432dd2176f |
@@ -5,3 +5,4 @@ node_modules
|
||||
npm-debug.log*
|
||||
Dockerfile
|
||||
README.md
|
||||
appdata/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Generate each with: openssl rand -base64 48
|
||||
POSTGRES_PASSWORD=
|
||||
# One-use secret to create [email protected]; leave blank/remove after bootstrap.
|
||||
BOOTSTRAP_SETUP_TOKEN=
|
||||
# Optional host path mounted read-only at /home/node/.pi/agent (never commit it).
|
||||
PI_AGENT_CONFIG_DIR=./appdata/pi-agent
|
||||
# Optional explicit reverse-proxy IP/CIDR; leave blank if no trusted proxy is present.
|
||||
TRUST_PROXY=
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
appdata//
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
dev-harness.mjs
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Development and tests
|
||||
|
||||
Run `npm test` before every commit and whenever changing authentication, API authorization, service-worker behavior, or persistence. Add a regression test under `test/` for every new route and for every security/ownership bug. Tests use an in-memory PostgreSQL-compatible database; production migration is run automatically at startup against `DATABASE_URL`.
|
||||
|
||||
Every change pushed to the remote must be deployed to the Unraid Docker service immediately afterward. Verify the deployed container is healthy and the production endpoint responds before reporting completion.
|
||||
|
||||
For local development, copy `.env.example`, start `docker compose up --build`, then visit `http://localhost:8090`. Production must set `APP_ORIGIN=https://roast.srmr.xyz`, `COOKIE_SECURE=true`, a strong `POSTGRES_PASSWORD`, and a random `BOOTSTRAP_SETUP_TOKEN`. Bootstrap `[email protected]` exactly once at `POST /api/auth/bootstrap`, then remove `BOOTSTRAP_SETUP_TOKEN` from deployment configuration. Never commit credentials or a bootstrap password/token.
|
||||
@@ -9,7 +9,11 @@ RUN npm ci --omit=dev
|
||||
COPY public ./public
|
||||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
COPY db/migrations ./db/migrations
|
||||
|
||||
# The optional Pi agent configuration is mounted read-only here at runtime.
|
||||
ENV HOME=/home/node
|
||||
RUN mkdir -p /home/node/.pi/agent && chown -R node:node /home/node/.pi
|
||||
USER node
|
||||
EXPOSE 8090
|
||||
CMD ["node", "server/index.js"]
|
||||
|
||||
@@ -21,10 +21,33 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set strong secrets
|
||||
npm install
|
||||
npm start # http://localhost:8090 (set PORT to override)
|
||||
npm test
|
||||
# local PostgreSQL stack (database is not published to the host)
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
The public landing page is at `/`; plans require an account at `/app`. Production is configured for `https://roast.srmr.xyz`: retain `APP_ORIGIN=https://roast.srmr.xyz` and `COOKIE_SECURE=true` behind its HTTPS proxy. Database migrations in `db/migrations/` run at application startup exactly once.
|
||||
|
||||
### First administrator
|
||||
|
||||
Generate `BOOTSTRAP_SETUP_TOKEN` with `openssl rand -base64 48`, keep it only in the deployment environment, then call `POST /api/auth/bootstrap` with that token, `[email protected]`, and a 12+ character password. The endpoint can create that account only once. Remove the setup token after success; it is optional thereafter and no administrator password is stored in source control.
|
||||
|
||||
### Pi agent configuration in Docker
|
||||
|
||||
The `app` service mounts `PI_AGENT_CONFIG_DIR` (default `./appdata/pi-agent`) read-only at `/home/node/.pi/agent`, the non-root Node user's Pi configuration directory. This lets `/api/prefill` use the same configured model at runtime without baking credentials into the image. The directory is ignored by Git and Docker build context; do not commit its contents.
|
||||
|
||||
Before bringing up the stack, sync only the local Pi agent configuration you intend to make available to the container:
|
||||
|
||||
```bash
|
||||
mkdir -p appdata/pi-agent
|
||||
rsync -a --delete ~/.pi/agent/ appdata/pi-agent/
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
Set `PI_AGENT_CONFIG_DIR` to another protected host directory instead if preferred. Restrict access to that directory because it can contain provider credentials. The mount is read-only, so Pi cannot alter the host configuration. If deployed behind a reverse proxy, set `TRUST_PROXY` only to that proxy's specific IP/CIDR (or keep it blank when the app is directly exposed).
|
||||
|
||||
## Mobile and PWA use
|
||||
|
||||
The planner is responsive and caches its app shell for offline use after the first visit. Browser installation and service-worker caching require HTTPS in production (localhost is exempt). Put the Docker container behind an HTTPS reverse proxy before using it as an installable PWA on a phone.
|
||||
@@ -52,8 +75,7 @@ If those tables change in the paper worksheet, port the change here too.
|
||||
|
||||
## Known gaps (v1)
|
||||
|
||||
- No automated test suite yet (the ledger math and `.alog` parser were verified manually
|
||||
against the worksheet's worked examples and all 14 logs in `ref/roasts/`, respectively).
|
||||
- Offline drafts are intentionally scoped to the authenticated browser account and are cleared on logout; account-backed plans remain the authoritative copy.
|
||||
- Roastetta (roastetta.com) integration is intentionally out of scope — it needs a headed,
|
||||
Cloudflare-clearing browser and the operator's own credentials. Use the `.alog` file picker,
|
||||
or point `ALOG_DIR` at wherever the `roastetta` skill already downloaded files.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE TABLE users (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), email text UNIQUE NOT NULL, password_hash text NOT NULL, role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')), created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE sessions (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash text NOT NULL, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE roast_plans (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, plan jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE TABLE app_settings (key text PRIMARY KEY, value text NOT NULL);
|
||||
INSERT INTO app_settings(key,value) VALUES ('signup_enabled','true');
|
||||
CREATE INDEX roast_plans_user_updated ON roast_plans(user_id, updated_at DESC);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive only: existing users/sessions/roast_plans rows and columns are untouched.
|
||||
CREATE TABLE password_reset_tokens (token_hash text PRIMARY KEY, user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now());
|
||||
ALTER TABLE users ADD COLUMN disabled_at timestamptz;
|
||||
ALTER TABLE sessions ADD COLUMN user_agent text, ADD COLUMN ip text, ADD COLUMN last_seen_at timestamptz;
|
||||
CREATE TABLE audit_events (id uuid PRIMARY KEY DEFAULT gen_random_uuid(), actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, action text NOT NULL, target text, created_at timestamptz NOT NULL DEFAULT now());
|
||||
CREATE INDEX audit_events_created ON audit_events(created_at DESC);
|
||||
CREATE INDEX sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX password_reset_tokens_user_id ON password_reset_tokens(user_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Additive only: existing tables, rows, and columns are untouched.
|
||||
CREATE TABLE green_bean_lots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
origin text NOT NULL,
|
||||
variety text NOT NULL DEFAULT '',
|
||||
process text NOT NULL DEFAULT '',
|
||||
producer text NOT NULL DEFAULT '',
|
||||
purchase_date date,
|
||||
initial_weight_g numeric NOT NULL,
|
||||
remaining_weight_g numeric NOT NULL,
|
||||
cost_total numeric,
|
||||
moisture_pct numeric,
|
||||
density_g_l numeric,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE bean_consumption (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
weight_g numeric NOT NULL CHECK (weight_g > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
-- One draw-down per roast plan, ever (NULLs are distinct in Postgres, so plan-less manual
|
||||
-- entries remain unlimited). This is the server-side backstop that makes the client's
|
||||
-- "Draw from lot" button idempotent even if clicked twice or from two devices.
|
||||
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
|
||||
CREATE TABLE cupping_sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,
|
||||
data jsonb NOT NULL,
|
||||
total_score numeric NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX green_bean_lots_user ON green_bean_lots(user_id, archived, purchase_date DESC);
|
||||
CREATE INDEX bean_consumption_lot ON bean_consumption(lot_id, created_at DESC);
|
||||
CREATE INDEX bean_consumption_user ON bean_consumption(user_id);
|
||||
CREATE INDEX cupping_sessions_user ON cupping_sessions(user_id, updated_at DESC);
|
||||
CREATE INDEX cupping_sessions_plan ON cupping_sessions(roast_plan_id);
|
||||
@@ -0,0 +1,40 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://roast:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/roast
|
||||
APP_ORIGIN: https://roast.srmr.xyz
|
||||
COOKIE_SECURE: "true"
|
||||
# Optional after the one-time administrator bootstrap has completed.
|
||||
BOOTSTRAP_SETUP_TOKEN: ${BOOTSTRAP_SETUP_TOKEN:-}
|
||||
# Leave unset unless a known reverse-proxy address/CIDR is configured.
|
||||
TRUST_PROXY: ${TRUST_PROXY:-}
|
||||
# Browser-rendered fallback for storefronts that rate-limit server fetches.
|
||||
BROWSERLESS_URL: ${BROWSERLESS_URL:-http://host.docker.internal:9085}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Mount only non-secret Pi agent model/auth configuration; keep it read-only.
|
||||
- ${PI_AGENT_CONFIG_DIR:-./appdata/pi-agent}:/home/node/.pi/agent:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# Nginx Proxy Manager routes https://roast.srmr.xyz to this host port.
|
||||
ports: ["8090:8090"]
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: roast
|
||||
POSTGRES_USER: roast
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
||||
volumes: [postgres-data:/var/lib/postgresql/data]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U roast -d roast"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
postgres-data:
|
||||
Generated
+750
-21
@@ -8,20 +8,27 @@
|
||||
"name": "roast-planner-webapp",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.82.1",
|
||||
"express": "^5.0.1"
|
||||
"@earendil-works/pi-coding-agent": "^0.83.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"express": "^5.0.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg-mem": "^3.0.14",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz",
|
||||
"integrity": "sha512-zbkAhoIuDPMF3pKuja0ajZabrMWU29FUMV9A/XMXT/XC1yXs5xt6t6t13GogQFsDrDqbFP4DkZQO1w8rWRAzYA==",
|
||||
"version": "0.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.83.0.tgz",
|
||||
"integrity": "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw==",
|
||||
"hasShrinkwrap": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@earendil-works/pi-agent-core": "^0.83.0",
|
||||
"@earendil-works/pi-ai": "^0.83.0",
|
||||
"@earendil-works/pi-tui": "^0.83.0",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -34,7 +41,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"semver": "7.8.0",
|
||||
"typebox": "1.1.38",
|
||||
"typebox": "1.3.7",
|
||||
"undici": "8.5.0",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
@@ -484,14 +491,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz",
|
||||
"version": "0.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.83.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.83.0",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"typebox": "1.3.7",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -499,8 +506,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz",
|
||||
"version": "0.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.83.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -513,7 +520,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"openai": "6.26.0",
|
||||
"partial-json": "0.1.7",
|
||||
"typebox": "1.1.38"
|
||||
"typebox": "1.3.7"
|
||||
},
|
||||
"bin": {
|
||||
"pi-ai": "dist/cli.js"
|
||||
@@ -523,8 +530,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
|
||||
"version": "0.83.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.83.0.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
@@ -1718,9 +1725,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": {
|
||||
"version": "1.1.38",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz",
|
||||
"integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==",
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz",
|
||||
"integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent/node_modules/undici": {
|
||||
@@ -1831,6 +1838,29 @@
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -1844,6 +1874,29 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": 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/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
@@ -1890,6 +1943,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1919,6 +1991,36 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -1959,6 +2061,13 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1976,6 +2085,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -1985,6 +2122,24 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -2044,6 +2199,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -2102,6 +2273,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
@@ -2123,6 +2301,64 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -2150,6 +2386,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/functional-red-black-tree": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
|
||||
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -2199,6 +2442,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
@@ -2211,6 +2467,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
@@ -2259,6 +2531,13 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "4.3.9",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
|
||||
"integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -2280,6 +2559,56 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stable-stringify": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.4",
|
||||
"isarray": "^2.0.5",
|
||||
"jsonify": "^0.0.1",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonify": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||
"dev": true,
|
||||
"license": "Public Domain",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2314,6 +2643,29 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -2339,12 +2691,52 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
|
||||
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nearley": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
"railroad-diagrams": "^1.0.0",
|
||||
"randexp": "0.4.6"
|
||||
},
|
||||
"bin": {
|
||||
"nearley-railroad": "bin/nearley-railroad.js",
|
||||
"nearley-test": "bin/nearley-test.js",
|
||||
"nearley-unparse": "bin/nearley-unparse.js",
|
||||
"nearleyc": "bin/nearleyc.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://nearley.js.org/#give-to-nearley"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -2354,6 +2746,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -2366,6 +2777,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -2406,6 +2827,204 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-mem": {
|
||||
"version": "3.0.14",
|
||||
"resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz",
|
||||
"integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"functional-red-black-tree": "^1.0.1",
|
||||
"immutable": "^4.3.4",
|
||||
"json-stable-stringify": "^1.0.1",
|
||||
"lru-cache": "^6.0.0",
|
||||
"moment": "^2.27.0",
|
||||
"object-hash": "^2.0.3",
|
||||
"pgsql-ast-parser": "^12.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mikro-orm/core": ">=4.5.3",
|
||||
"@mikro-orm/postgresql": ">=4.5.3",
|
||||
"knex": ">=0.20",
|
||||
"kysely": ">=0.26",
|
||||
"pg-promise": ">=10.8.7",
|
||||
"pg-server": "^0.1.5",
|
||||
"postgres": "^3.4.4",
|
||||
"slonik": ">=23.0.1",
|
||||
"typeorm": ">=0.2.29"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@mikro-orm/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@mikro-orm/postgresql": {
|
||||
"optional": true
|
||||
},
|
||||
"knex": {
|
||||
"optional": true
|
||||
},
|
||||
"kysely": {
|
||||
"optional": true
|
||||
},
|
||||
"mikro-orm": {
|
||||
"optional": true
|
||||
},
|
||||
"pg-promise": {
|
||||
"optional": true
|
||||
},
|
||||
"pg-server": {
|
||||
"optional": true
|
||||
},
|
||||
"postgres": {
|
||||
"optional": true
|
||||
},
|
||||
"slonik": {
|
||||
"optional": true
|
||||
},
|
||||
"typeorm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pgsql-ast-parser": {
|
||||
"version": "12.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz",
|
||||
"integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"moo": "^0.5.1",
|
||||
"nearley": "^2.19.5"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -2435,6 +3054,27 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||
@@ -2463,6 +3103,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -2530,6 +3180,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -2608,6 +3276,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -2617,6 +3294,42 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -2680,6 +3393,22 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -6,10 +6,18 @@
|
||||
"description": "Fillable web version of the manual roast plan worksheet, with URL prefill and .alog reference curves.",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js"
|
||||
"dev": "node --watch server/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.83.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"express": "^5.0.1",
|
||||
"@earendil-works/pi-coding-agent": "^0.82.1"
|
||||
"nodemailer": "^9.0.3",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"pg-mem": "^3.0.14",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Account — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Account</h1>
|
||||
<p class="brand-sub">Profile, security, plans, and devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="profile">
|
||||
<div class="panel-head">
|
||||
<h2>Profile</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>
|
||||
<strong id="profile-email"></strong>
|
||||
<span class="badge badge-admin hidden" id="profile-role-admin"
|
||||
>Admin</span
|
||||
>
|
||||
</p>
|
||||
<p class="muted" id="profile-since"></p>
|
||||
<p class="subhead">Change email</p>
|
||||
<form id="email-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">New email</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Current password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Update email</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="security">
|
||||
<div class="panel-head">
|
||||
<h2>Security</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="subhead">Change password</p>
|
||||
<form id="password-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">Current password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">New password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required
|
||||
minlength="12"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Confirm new password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required
|
||||
minlength="12"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">
|
||||
Update password
|
||||
</button>
|
||||
</form>
|
||||
<p class="subhead">Active sessions</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Last seen</th>
|
||||
<th>Expires</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="ghost-btn" type="button" id="btn-revoke-others">
|
||||
Sign out everywhere else
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="your-plans">
|
||||
<div class="panel-head">
|
||||
<h2>Your plans</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="plans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="app">
|
||||
<div class="panel-head">
|
||||
<h2>App</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Install Roast Planner for a focused, offline-capable
|
||||
workspace. Updates wait for your confirmation so an
|
||||
in-progress plan is never discarded.
|
||||
</p>
|
||||
<button id="btn-install" type="button" class="primary-btn hidden">
|
||||
Install Roast Planner
|
||||
</button>
|
||||
<button id="btn-refresh" type="button" class="ghost-btn hidden">
|
||||
Update ready — refresh safely
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card danger-zone" id="danger">
|
||||
<div class="panel-head">
|
||||
<h2>Danger zone</h2>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
Deleting your account permanently removes your plans and
|
||||
cannot be undone.
|
||||
</p>
|
||||
<form id="delete-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label"
|
||||
>Type your email to confirm</span
|
||||
><input class="field-input" type="email" name="confirmEmail" required
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Password</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<button class="ghost-btn" type="submit" style="border-color:var(--fail);color:var(--fail)">
|
||||
Delete my account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/account.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Admin — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="#plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item" href="/admin" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Administration</h1>
|
||||
<p class="brand-sub">Users, plans, and app settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content wide">
|
||||
<section class="stat-grid" id="metrics" aria-label="Metrics"></section>
|
||||
|
||||
<section class="panel-card" id="reset-links-card" hidden>
|
||||
<div class="panel-head"><h2>Pending password resets</h2></div>
|
||||
<div class="panel-body">
|
||||
<p class="muted">
|
||||
No outgoing email is configured for this deployment
|
||||
(<code>SMTP_URL</code>). Hand these links to the user
|
||||
directly.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="resets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Link</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="resets-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card">
|
||||
<div class="panel-head"><h2>Signups</h2></div>
|
||||
<div class="panel-body">
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="signup-toggle" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Allow new signups</label
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="users">
|
||||
<div class="panel-head">
|
||||
<h2>Users</h2>
|
||||
<input
|
||||
class="text-input"
|
||||
id="user-search"
|
||||
placeholder="Search by email…"
|
||||
style="max-width:220px"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Plans</th>
|
||||
<th>Joined</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="plans">
|
||||
<div class="panel-head">
|
||||
<h2>Roast plans</h2>
|
||||
<button class="ghost-btn small hidden" id="clear-plan-filter">
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="plans-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Title</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="activity">
|
||||
<div class="panel-head"><h2>Recent activity</h2></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2502
-368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Cupping — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1 id="cupping-title">Cupping</h1>
|
||||
<p class="brand-sub" id="cupping-subtitle">
|
||||
Tasting and scoring sessions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions hidden" id="session-autosave-wrap">
|
||||
<span class="autosave-chip" id="cupping-autosave-status"
|
||||
><span class="dot"></span
|
||||
><span class="autosave-text">Not saved yet</span></span
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content wide" id="cupping-list-view">
|
||||
<section class="panel-card" id="cupping-sessions-list">
|
||||
<div class="panel-head"><h2>Cupping sessions</h2></div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Coffee</th>
|
||||
<th class="num">Score</th>
|
||||
<th class="num">Cups</th>
|
||||
<th>Flavors</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="new-session-card">
|
||||
<div class="panel-head"><h2>New session</h2></div>
|
||||
<div class="panel-body">
|
||||
<form id="new-session-form" class="inline-form">
|
||||
<label class="field"
|
||||
><span class="field-label">Roast (optional)</span>
|
||||
<select class="field-input" id="new-session-plan">
|
||||
<option value="">— no linked roast —</option>
|
||||
</select></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Cups</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="number"
|
||||
id="new-session-cups"
|
||||
min="1"
|
||||
max="12"
|
||||
value="5"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">
|
||||
Start cupping
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<main class="page-content wide hidden" id="cupping-session-view">
|
||||
<div class="workspace-grid">
|
||||
<div class="form-col">
|
||||
<section class="panel-card" id="cup-scores">
|
||||
<div class="panel-head">
|
||||
<h2>Scores</h2>
|
||||
<label class="field" style="margin:0">
|
||||
<span class="field-label">Cups</span>
|
||||
<input
|
||||
class="field-input sm"
|
||||
type="number"
|
||||
id="cup-count"
|
||||
min="1"
|
||||
max="12"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-scores" open>
|
||||
<summary>Why the scale starts at 6</summary>
|
||||
<p>
|
||||
The 6–10 range is a specialty-grading convention:
|
||||
anything below specialty grade simply isn't scored, so
|
||||
6 is the floor of the scale, not "zero". Score in
|
||||
quarter-point steps and leave an attribute unscored
|
||||
(—) rather than guessing — an unscored attribute
|
||||
contributes nothing, which is honest; a guessed 7.5
|
||||
pollutes every comparison you make later. The tick
|
||||
rows count cups, not quality: five clean cups out of
|
||||
five is full marks even if the coffee is merely good.
|
||||
</p>
|
||||
</details>
|
||||
<div id="cup-score-rows"></div>
|
||||
<div id="cup-tick-rows"></div>
|
||||
<details class="cupping-defects">
|
||||
<summary>Defects</summary>
|
||||
<div id="cup-defect-rows"></div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="cup-flavors">
|
||||
<div class="panel-head"><h2>Flavors</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-flavors" open>
|
||||
<summary>Tag what you can point at</summary>
|
||||
<p>
|
||||
A tag is only useful if you could defend it to
|
||||
another taster with the cup in front of you. Two or
|
||||
three confident descriptors beat a dozen hopeful ones
|
||||
— the list is capped at 32, but a session that needs
|
||||
10 is already suspect.
|
||||
</p>
|
||||
</details>
|
||||
<p class="field-note hidden" id="flavor-limit-note">
|
||||
32-tag limit reached.
|
||||
</p>
|
||||
<div id="flavor-families"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="cup-notes">
|
||||
<div class="panel-head"><h2>Notes</h2></div>
|
||||
<div class="panel-body">
|
||||
<details class="why-panel" data-why="cup-notes" open>
|
||||
<summary>Notes outlive scores</summary>
|
||||
<p>
|
||||
Numbers compare roasts; sentences explain them. Write
|
||||
what you'd want to read before roasting this coffee
|
||||
again — texture, where it fell apart as it cooled,
|
||||
what you'd change.
|
||||
</p>
|
||||
</details>
|
||||
<textarea
|
||||
class="field-input"
|
||||
id="cup-notes-text"
|
||||
maxlength="4000"
|
||||
rows="5"
|
||||
aria-label="Cupping notes"
|
||||
></textarea>
|
||||
<p class="field-note"><span id="cup-notes-count">0</span>/4000</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="dock">
|
||||
<div class="dock-card">
|
||||
<div class="dock-card-head"><h2>Score</h2></div>
|
||||
<div class="stat-tile" style="box-shadow:none;border:none;padding:0">
|
||||
<div class="stat-tile-label">Total score</div>
|
||||
<div class="stat-tile-value" id="cup-total">0.00</div>
|
||||
</div>
|
||||
<p class="dock-note">Recomputed by the server on every save.</p>
|
||||
<div class="chip-group" id="flavor-chips"></div>
|
||||
</div>
|
||||
<div class="dock-card">
|
||||
<div class="dock-card-head"><h2>Radar</h2></div>
|
||||
<svg
|
||||
id="cup-radar"
|
||||
viewBox="0 0 240 240"
|
||||
role="img"
|
||||
aria-label="Cupping score radar chart"
|
||||
></svg>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Runs synchronously at parse time (before the deferred module script below) so opening
|
||||
// /cupping?session=… never flashes the empty sessions list first while cupping.js's own
|
||||
// async init (which awaits the auth check) is still getting underway.
|
||||
if (new URLSearchParams(location.search).get("session")) {
|
||||
document.getElementById("cupping-list-view").classList.add("hidden");
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/js/cupping.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Reset your password — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Forgot your password?</h1>
|
||||
<p class="lede">
|
||||
Enter your account email and we'll send a link to reset your
|
||||
password.
|
||||
</p>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="forgot-form">
|
||||
<label
|
||||
>Email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Send reset link</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/login">Back to log in</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/forgot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2432
-740
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,222 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Inventory — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<nav class="side-nav" id="side-nav" aria-label="Main navigation">
|
||||
<div class="side-nav-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span class="brand-name">Roast Planner</span>
|
||||
</div>
|
||||
<div class="nav-group">
|
||||
<a class="nav-item" href="/app"
|
||||
><span class="nav-icon" aria-hidden="true">◐</span
|
||||
><span class="nav-label">Planner</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account#your-plans"
|
||||
><span class="nav-icon" aria-hidden="true">▤</span
|
||||
><span class="nav-label">Plans</span></a
|
||||
>
|
||||
<a class="nav-item" href="/inventory" aria-current="page"
|
||||
><span class="nav-icon" aria-hidden="true">▥</span
|
||||
><span class="nav-label">Inventory</span></a
|
||||
>
|
||||
<a class="nav-item" href="/cupping"
|
||||
><span class="nav-icon" aria-hidden="true">◒</span
|
||||
><span class="nav-label">Cupping</span></a
|
||||
>
|
||||
<a class="nav-item" href="/account"
|
||||
><span class="nav-icon" aria-hidden="true">◔</span
|
||||
><span class="nav-label">Account</span></a
|
||||
>
|
||||
<a class="nav-item hidden" id="nav-admin" href="/admin"
|
||||
><span class="nav-icon" aria-hidden="true">⚙</span
|
||||
><span class="nav-label">Admin</span></a
|
||||
>
|
||||
</div>
|
||||
<div class="nav-spacer"></div>
|
||||
<button
|
||||
class="icon-btn nav-collapse-toggle"
|
||||
type="button"
|
||||
id="nav-collapse"
|
||||
aria-label="Collapse navigation"
|
||||
title="Collapse navigation"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<div class="nav-user">
|
||||
<div class="nav-user-avatar" id="nav-user-avatar" aria-hidden="true"></div>
|
||||
<div class="nav-user-detail">
|
||||
<span class="nav-user-email" id="account-email"></span>
|
||||
<button class="nav-user-logout" type="button" id="btn-logout">
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="app-workspace" id="app-workspace">
|
||||
<header class="app-header">
|
||||
<div class="header-row-top">
|
||||
<button
|
||||
class="icon-btn nav-hamburger"
|
||||
type="button"
|
||||
id="nav-hamburger"
|
||||
aria-label="Open navigation"
|
||||
aria-expanded="false"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-text">
|
||||
<h1>Inventory</h1>
|
||||
<p class="brand-sub">Green bean lots and remaining stock</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="drawer-overlay hidden" id="drawer-overlay"></div>
|
||||
|
||||
<main class="page-content">
|
||||
<section class="panel-card" id="lots">
|
||||
<div class="panel-head">
|
||||
<h2>Green bean lots</h2>
|
||||
<label
|
||||
style="display:flex;align-items:center;gap:10px;font-size:13px;color:var(--ink-2)"
|
||||
><span class="switch"
|
||||
><input type="checkbox" id="show-archived" /><span
|
||||
class="switch-track"
|
||||
></span></span
|
||||
>Show archived</label
|
||||
>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table" id="lots-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lot</th>
|
||||
<th>Process</th>
|
||||
<th>Remaining</th>
|
||||
<th>Purchased</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lots-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-card" id="lot-form-card">
|
||||
<div class="panel-head"><h2 id="lot-form-title">Add a lot</h2></div>
|
||||
<div class="panel-body">
|
||||
<form id="lot-form">
|
||||
<input type="hidden" name="id" />
|
||||
<div class="field-grid">
|
||||
<label class="field"
|
||||
><span class="field-label">Origin *</span
|
||||
><input
|
||||
class="field-input"
|
||||
name="origin"
|
||||
required
|
||||
placeholder="e.g. Huila, Colombia"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Variety</span
|
||||
><input class="field-input" name="variety"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Process</span
|
||||
><input class="field-input" name="process"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Producer</span
|
||||
><input class="field-input" name="producer"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Purchase date</span
|
||||
><input class="field-input" type="date" name="purchaseDate"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Initial weight *</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="initialWeightG"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
required
|
||||
/><span class="unit">g</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Cost</span
|
||||
><input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="costTotal"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/></label>
|
||||
<label class="field"
|
||||
><span class="field-label">Moisture</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="moisturePct"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">%</span>
|
||||
</div></label
|
||||
>
|
||||
<label class="field"
|
||||
><span class="field-label">Density</span>
|
||||
<div class="unit-input">
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
name="densityGL"
|
||||
inputmode="decimal"
|
||||
min="0"
|
||||
step="any"
|
||||
/><span class="unit">g/L</span>
|
||||
</div></label
|
||||
>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span class="field-label">Notes</span
|
||||
><input class="field-input" name="notes"
|
||||
/></label>
|
||||
<p class="field-note hidden" id="lot-remaining-note"></p>
|
||||
<button class="primary-btn" type="submit" id="lot-form-submit">
|
||||
Add lot
|
||||
</button>
|
||||
<button
|
||||
class="ghost-btn hidden"
|
||||
type="button"
|
||||
id="lot-form-cancel"
|
||||
>
|
||||
Cancel edit
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/js/inventory.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,351 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
function friendlyDevice(userAgent) {
|
||||
if (!userAgent) return "Unknown device";
|
||||
const browser = /Edg\//.test(userAgent)
|
||||
? "Edge"
|
||||
: /OPR\//.test(userAgent)
|
||||
? "Opera"
|
||||
: /Chrome\//.test(userAgent)
|
||||
? "Chrome"
|
||||
: /CriOS\//.test(userAgent)
|
||||
? "Chrome"
|
||||
: /Firefox\//.test(userAgent)
|
||||
? "Firefox"
|
||||
: /Safari\//.test(userAgent)
|
||||
? "Safari"
|
||||
: "Browser";
|
||||
const os = /Windows/.test(userAgent)
|
||||
? "Windows"
|
||||
: /iPhone|iPad/.test(userAgent)
|
||||
? "iOS"
|
||||
: /Mac OS X/.test(userAgent)
|
||||
? "macOS"
|
||||
: /Android/.test(userAgent)
|
||||
? "Android"
|
||||
: /Linux/.test(userAgent)
|
||||
? "Linux"
|
||||
: "";
|
||||
return os ? `${browser} on ${os}` : browser;
|
||||
}
|
||||
|
||||
async function loadProfile(user) {
|
||||
document.getElementById("profile-email").textContent = user.email;
|
||||
document
|
||||
.getElementById("profile-role-admin")
|
||||
.classList.toggle("hidden", user.role !== "admin");
|
||||
document.getElementById("email-form").email.value = user.email;
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const body = document.getElementById("sessions-body");
|
||||
try {
|
||||
const { sessions } = await api("/api/account/sessions");
|
||||
if (!sessions.length) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">No active sessions.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...sessions.map((session) => {
|
||||
const tr = document.createElement("tr");
|
||||
const device = document.createElement("td");
|
||||
const label = document.createElement("span");
|
||||
label.className = "session-device-label";
|
||||
label.textContent = friendlyDevice(session.userAgent);
|
||||
if (session.userAgent) label.title = session.userAgent;
|
||||
device.append(label);
|
||||
if (session.current) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge badge-current";
|
||||
badge.textContent = "This device";
|
||||
badge.style.marginLeft = "8px";
|
||||
device.append(badge);
|
||||
}
|
||||
const lastSeen = document.createElement("td");
|
||||
lastSeen.textContent = fmtDate(session.lastSeenAt || session.createdAt);
|
||||
const expires = document.createElement("td");
|
||||
expires.textContent = fmtDate(session.expiresAt);
|
||||
const actions = document.createElement("td");
|
||||
const revoke = document.createElement("button");
|
||||
revoke.className = "ghost-btn small";
|
||||
revoke.type = "button";
|
||||
revoke.textContent = session.current ? "Sign out" : "Revoke";
|
||||
revoke.addEventListener("click", async () => {
|
||||
try {
|
||||
await api(`/api/account/sessions/${session.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (session.current) return location.assign("/login");
|
||||
showToast("Session revoked.");
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(revoke);
|
||||
tr.append(device, lastSeen, expires, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load sessions.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
const body = document.getElementById("plans-body");
|
||||
try {
|
||||
const response = await fetch("/api/plans");
|
||||
if (!response.ok) throw new Error("could not load plans");
|
||||
const { plans } = await response.json();
|
||||
if (!plans.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">No saved plans yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...plans.map((plan) => {
|
||||
const tr = document.createElement("tr");
|
||||
const title = document.createElement("td");
|
||||
title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(plan.updated_at);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
|
||||
const open = document.createElement("a");
|
||||
open.className = "ghost-btn small";
|
||||
open.href = `/app?plan=${encodeURIComponent(plan.id)}`;
|
||||
open.textContent = "Open";
|
||||
|
||||
const duplicate = document.createElement("button");
|
||||
duplicate.className = "ghost-btn small";
|
||||
duplicate.type = "button";
|
||||
duplicate.textContent = "Duplicate";
|
||||
duplicate.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/api/plans", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ plan: plan.plan }),
|
||||
});
|
||||
showToast("Plan duplicated.");
|
||||
loadPlans();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
|
||||
const download = document.createElement("button");
|
||||
download.className = "ghost-btn small";
|
||||
download.type = "button";
|
||||
download.textContent = "Download";
|
||||
download.addEventListener("click", () => {
|
||||
const blob = new Blob([JSON.stringify(plan.plan, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${(plan.plan?.fields?.["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
const cup = document.createElement("button");
|
||||
cup.className = "ghost-btn small";
|
||||
cup.type = "button";
|
||||
cup.textContent = "Cup";
|
||||
cup.addEventListener("click", async () => {
|
||||
cup.disabled = true;
|
||||
try {
|
||||
const existing = await api(`/api/cupping?plan=${encodeURIComponent(plan.id)}`);
|
||||
if (existing.sessions?.length) {
|
||||
location.assign(`/cupping?session=${existing.sessions[0].id}`);
|
||||
return;
|
||||
}
|
||||
const created = await api("/api/cupping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ roastPlanId: plan.id }),
|
||||
});
|
||||
location.assign(`/cupping?session=${created.session.id}`);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
cup.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const del = document.createElement("button");
|
||||
del.className = "ghost-btn small";
|
||||
del.type = "button";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", async () => {
|
||||
if (!confirm("Delete this plan? This cannot be undone.")) return;
|
||||
try {
|
||||
await api(`/api/plans/${plan.id}`, { method: "DELETE" });
|
||||
showToast("Plan deleted.");
|
||||
loadPlans();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(open, duplicate, download, cup, del);
|
||||
tr.append(title, updated, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(button, run) {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function wireForms(user) {
|
||||
document.getElementById("email-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(event.target));
|
||||
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
await api("/api/account/email", { method: "PUT", body: JSON.stringify(data) });
|
||||
showToast("Email updated.");
|
||||
document.getElementById("profile-email").textContent = data.email;
|
||||
event.target.password.value = "";
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error.code === "email_exists"
|
||||
? "That email is already in use."
|
||||
: error.message,
|
||||
"fail",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("password-form")
|
||||
.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(event.target));
|
||||
if (data.newPassword !== data.confirmPassword) {
|
||||
showToast("New passwords do not match.", "fail");
|
||||
return;
|
||||
}
|
||||
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
await api("/api/account/password", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
showToast("Password updated. Other sessions were signed out.");
|
||||
event.target.reset();
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("btn-revoke-others")
|
||||
.addEventListener("click", (event) => {
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api("/api/account/sessions/revoke-others", { method: "POST" });
|
||||
showToast("Other sessions signed out.");
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("delete-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(event.target));
|
||||
if (data.confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()) {
|
||||
showToast("Type your email exactly to confirm.", "fail");
|
||||
return;
|
||||
}
|
||||
if (!confirm("This permanently deletes your account and plans. Continue?"))
|
||||
return;
|
||||
guarded(event.submitter || event.target.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
await api("/api/account", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ password: data.password }),
|
||||
});
|
||||
location.assign("/");
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error.code === "last_admin"
|
||||
? "You are the only administrator — promote another admin first."
|
||||
: error.message,
|
||||
"fail",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wirePwaButtons() {
|
||||
let deferredInstallPrompt = null;
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
event.preventDefault();
|
||||
deferredInstallPrompt = event;
|
||||
document.getElementById("btn-install").classList.remove("hidden");
|
||||
});
|
||||
document.getElementById("btn-install").addEventListener("click", async () => {
|
||||
if (!deferredInstallPrompt) return;
|
||||
deferredInstallPrompt.prompt();
|
||||
await deferredInstallPrompt.userChoice;
|
||||
deferredInstallPrompt = null;
|
||||
document.getElementById("btn-install").classList.add("hidden");
|
||||
});
|
||||
if ("serviceWorker" in navigator) {
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.getRegistration().then((registration) => {
|
||||
if (registration?.waiting)
|
||||
document.getElementById("btn-refresh").classList.remove("hidden");
|
||||
});
|
||||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||
if (hadController) location.reload();
|
||||
});
|
||||
}
|
||||
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
registration?.waiting?.postMessage("SKIP_WAITING");
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
document.getElementById("profile-since").textContent =
|
||||
`Member since ${fmtDate(user.createdAt)}`;
|
||||
await loadProfile(user);
|
||||
wireForms(user);
|
||||
wirePwaButtons();
|
||||
await Promise.all([loadSessions(), loadPlans()]);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,329 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
let currentUsers = [];
|
||||
let planFilterUserId = null;
|
||||
let currentUserId = null;
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
async function loadMetrics() {
|
||||
const grid = document.getElementById("metrics");
|
||||
try {
|
||||
const { metrics } = await api("/api/admin/metrics");
|
||||
const tiles = [
|
||||
["Users", metrics.totalUsers],
|
||||
["Plans", metrics.totalPlans],
|
||||
["Plans updated (7d)", metrics.plansUpdatedLast7Days],
|
||||
["Active sessions", metrics.activeSessions],
|
||||
];
|
||||
grid.replaceChildren(
|
||||
...tiles.map(([label, value]) => {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "stat-tile";
|
||||
tile.innerHTML = `<div class="stat-tile-label"></div><div class="stat-tile-value"></div>`;
|
||||
tile.querySelector(".stat-tile-label").textContent = label;
|
||||
tile.querySelector(".stat-tile-value").textContent = value;
|
||||
return tile;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
grid.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResetLinks() {
|
||||
try {
|
||||
const { links } = await api("/api/admin/password-resets");
|
||||
const card = document.getElementById("reset-links-card");
|
||||
if (!links.length) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
document.getElementById("resets-body").replaceChildren(
|
||||
...links.map((link) => {
|
||||
const tr = document.createElement("tr");
|
||||
const email = document.createElement("td");
|
||||
email.textContent = link.email;
|
||||
const url = document.createElement("td");
|
||||
// Not a clickable <a>: an admin's own session immediately 302s /reset to /app,
|
||||
// so the link is only useful copied out and handed to the actual user.
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className = "ghost-btn small";
|
||||
copyBtn.type = "button";
|
||||
copyBtn.textContent = "Copy link";
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.url);
|
||||
copyBtn.textContent = "Copied!";
|
||||
} catch {
|
||||
copyBtn.textContent = link.url;
|
||||
}
|
||||
setTimeout(() => (copyBtn.textContent = "Copy link"), 2000);
|
||||
});
|
||||
url.append(copyBtn);
|
||||
const expires = document.createElement("td");
|
||||
expires.textContent = fmtDate(link.expiresAt);
|
||||
tr.append(email, url, expires);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* optional feature; ignore failures */
|
||||
}
|
||||
}
|
||||
|
||||
function wireSignupToggle() {
|
||||
const toggle = document.getElementById("signup-toggle");
|
||||
toggle.addEventListener("change", async () => {
|
||||
try {
|
||||
await api("/api/admin/signup-enabled", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ enabled: toggle.checked }),
|
||||
});
|
||||
showToast(toggle.checked ? "Signups enabled." : "Signups disabled.");
|
||||
} catch (error) {
|
||||
toggle.checked = !toggle.checked;
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function roleBadge(user) {
|
||||
return user.role === "admin"
|
||||
? `<span class="badge badge-admin">Admin</span>`
|
||||
: `<span class="badge badge-user">User</span>`;
|
||||
}
|
||||
function statusBadge(user) {
|
||||
return user.disabled_at
|
||||
? `<span class="badge badge-disabled">Disabled</span>`
|
||||
: `<span class="badge badge-current">Active</span>`;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const body = document.getElementById("users-body");
|
||||
try {
|
||||
const { users, signupEnabled } = await api("/api/admin/users");
|
||||
currentUsers = users;
|
||||
document.getElementById("signup-toggle").checked = signupEnabled;
|
||||
renderUsers();
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load users.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
const body = document.getElementById("users-body");
|
||||
const query = document
|
||||
.getElementById("user-search")
|
||||
.value.trim()
|
||||
.toLowerCase();
|
||||
const filtered = query
|
||||
? currentUsers.filter((u) => u.email.toLowerCase().includes(query))
|
||||
: currentUsers;
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No matching users.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...filtered.map((user) => {
|
||||
const tr = document.createElement("tr");
|
||||
const email = document.createElement("td");
|
||||
email.textContent = user.email;
|
||||
const role = document.createElement("td");
|
||||
role.innerHTML = roleBadge(user);
|
||||
const status = document.createElement("td");
|
||||
status.innerHTML = statusBadge(user);
|
||||
const count = document.createElement("td");
|
||||
count.className = "num";
|
||||
count.textContent = user.plan_count;
|
||||
const joined = document.createElement("td");
|
||||
joined.textContent = fmtDate(user.created_at);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
|
||||
const isBootstrapAdmin = user.email === "[email protected]";
|
||||
const isSelf = user.id === currentUserId;
|
||||
const viewPlans = document.createElement("button");
|
||||
viewPlans.className = "ghost-btn small";
|
||||
viewPlans.type = "button";
|
||||
viewPlans.textContent = "View plans";
|
||||
viewPlans.addEventListener("click", () => filterPlansByUser(user));
|
||||
actions.append(viewPlans);
|
||||
|
||||
// The server refuses role/disable/delete on the bootstrap admin and on your own
|
||||
// account (so an admin can never lock themselves out) — don't offer buttons the
|
||||
// server will always reject.
|
||||
if (!isBootstrapAdmin && !isSelf) {
|
||||
const roleBtn = document.createElement("button");
|
||||
roleBtn.className = "ghost-btn small";
|
||||
roleBtn.type = "button";
|
||||
const promoting = user.role !== "admin";
|
||||
roleBtn.textContent = promoting ? "Promote" : "Demote";
|
||||
roleBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
promoting &&
|
||||
!confirm(
|
||||
`Grant ${user.email} full admin access, including user management and password-reset links?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/role`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
role: promoting ? "admin" : "user",
|
||||
}),
|
||||
});
|
||||
showToast("Role updated.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
const disableBtn = document.createElement("button");
|
||||
disableBtn.className = "ghost-btn small";
|
||||
disableBtn.type = "button";
|
||||
const disabling = !user.disabled_at;
|
||||
disableBtn.textContent = disabling ? "Disable" : "Enable";
|
||||
disableBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
disabling &&
|
||||
!confirm(`Disable ${user.email}? This signs them out everywhere immediately.`)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}/disabled`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ disabled: disabling }),
|
||||
});
|
||||
showToast(disabling ? "User disabled." : "User enabled.");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
if (
|
||||
!confirm(
|
||||
`Permanently delete ${user.email} and all of their plans?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
||||
showToast("User deleted.");
|
||||
loadUsers();
|
||||
loadMetrics();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
actions.append(roleBtn, disableBtn, deleteBtn);
|
||||
}
|
||||
|
||||
tr.append(email, role, status, count, joined, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
const body = document.getElementById("plans-body");
|
||||
try {
|
||||
const url = planFilterUserId
|
||||
? `/api/admin/plans?user=${encodeURIComponent(planFilterUserId)}`
|
||||
: "/api/admin/plans";
|
||||
const { plans } = await api(url);
|
||||
if (!plans.length) {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">No plans.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...plans.map((plan) => {
|
||||
const tr = document.createElement("tr");
|
||||
const owner = document.createElement("td");
|
||||
owner.textContent = plan.email;
|
||||
const title = document.createElement("td");
|
||||
title.textContent = plan.title || "Untitled plan";
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(plan.updated_at);
|
||||
tr.append(owner, title, updated);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="3" class="empty-state">Could not load plans.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterPlansByUser(user) {
|
||||
planFilterUserId = user.id;
|
||||
document.getElementById("clear-plan-filter").classList.remove("hidden");
|
||||
document.getElementById("plans").scrollIntoView({ behavior: "smooth" });
|
||||
loadPlans();
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const body = document.getElementById("audit-body");
|
||||
try {
|
||||
const { events } = await api("/api/admin/audit");
|
||||
if (!events.length) {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">No activity yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...events.map((event) => {
|
||||
const tr = document.createElement("tr");
|
||||
const when = document.createElement("td");
|
||||
when.textContent = fmtDate(event.created_at);
|
||||
const actor = document.createElement("td");
|
||||
actor.textContent = event.actor_email || "—";
|
||||
const action = document.createElement("td");
|
||||
action.textContent = event.action.replaceAll("_", " ");
|
||||
const target = document.createElement("td");
|
||||
target.textContent = event.target || "—";
|
||||
tr.append(when, actor, action, target);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="4" class="empty-state">Could not load activity.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
document.getElementById("user-search").addEventListener("input", renderUsers);
|
||||
document.getElementById("clear-plan-filter").addEventListener("click", () => {
|
||||
planFilterUserId = null;
|
||||
document.getElementById("clear-plan-filter").classList.add("hidden");
|
||||
loadPlans();
|
||||
});
|
||||
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
currentUserId = user.id;
|
||||
wireSignupToggle();
|
||||
await Promise.all([
|
||||
loadMetrics(),
|
||||
loadResetLinks(),
|
||||
loadUsers(),
|
||||
loadPlans(),
|
||||
loadAudit(),
|
||||
]);
|
||||
}
|
||||
|
||||
init();
|
||||
+88
-75
@@ -1,79 +1,92 @@
|
||||
// Wires the ".alog reference curve" panel: local file upload, and browsing a server-side
|
||||
// library directory (e.g. wherever the roastetta skill already downloaded logs).
|
||||
|
||||
const csrf = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
const setText = (el, text, error = false) => {
|
||||
const node = document.createElement("p");
|
||||
node.textContent = text;
|
||||
if (error) node.style.color = "#a8371a";
|
||||
el.replaceChildren(node);
|
||||
};
|
||||
export function initAlogPanel({ state, recompute }) {
|
||||
const fileInput = document.getElementById("alog-file");
|
||||
const libraryBtn = document.getElementById("alog-library-refresh");
|
||||
const libraryList = document.getElementById("alog-library-list");
|
||||
const resultEl = document.getElementById("alog-result");
|
||||
|
||||
fileInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
resultEl.innerHTML = "<p>Parsing…</p>";
|
||||
try {
|
||||
const content = await file.text();
|
||||
const res = await fetch("/api/alog", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ filename: file.name, content }),
|
||||
});
|
||||
const body = await res.json();
|
||||
applyResult(body);
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
libraryBtn.addEventListener("click", async () => {
|
||||
libraryList.classList.remove("hidden");
|
||||
libraryList.innerHTML = "<li>Loading…</li>";
|
||||
try {
|
||||
const res = await fetch("/api/alog/library");
|
||||
const body = await res.json();
|
||||
if (!body.ok || body.files.length === 0) {
|
||||
libraryList.innerHTML = "<li>No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.</li>";
|
||||
return;
|
||||
}
|
||||
libraryList.innerHTML = "";
|
||||
for (const f of body.files) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
|
||||
li.addEventListener("click", async () => {
|
||||
resultEl.innerHTML = "<p>Loading…</p>";
|
||||
const r = await fetch(`/api/alog/library/${encodeURIComponent(f.filename)}`);
|
||||
applyResult(await r.json());
|
||||
});
|
||||
libraryList.appendChild(li);
|
||||
}
|
||||
} catch (err) {
|
||||
libraryList.innerHTML = `<li style="color:#a8371a">${escapeHtml(err.message)}</li>`;
|
||||
}
|
||||
});
|
||||
|
||||
function applyResult(body) {
|
||||
if (!body.ok) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
|
||||
return;
|
||||
}
|
||||
state.plan.reference = body;
|
||||
recompute();
|
||||
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
|
||||
resultEl.innerHTML = `
|
||||
<p><strong>${escapeHtml(body.roast.title)}</strong> — ${body.roast.roastDate || "no date"} ·
|
||||
first crack ${fmt(body.derived?.firstCrackS)} · development ${fmt(body.derived?.developmentS)} ·
|
||||
drop ${fmt(body.derived?.dropS)} · DTR ${body.derived?.dtrPct ?? "—"}%</p>
|
||||
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
|
||||
`;
|
||||
}
|
||||
const fileInput = document.getElementById("alog-file"),
|
||||
libraryBtn = document.getElementById("alog-library-refresh"),
|
||||
libraryList = document.getElementById("alog-library-list"),
|
||||
resultEl = document.getElementById("alog-result");
|
||||
fileInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setText(resultEl, "Parsing…");
|
||||
try {
|
||||
const res = await fetch("/api/alog", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
content: await file.text(),
|
||||
}),
|
||||
});
|
||||
applyResult(await res.json());
|
||||
} catch (err) {
|
||||
setText(resultEl, err.message, true);
|
||||
}
|
||||
});
|
||||
libraryBtn.addEventListener("click", async () => {
|
||||
libraryList.classList.remove("hidden");
|
||||
libraryList.replaceChildren(
|
||||
Object.assign(document.createElement("li"), { textContent: "Loading…" }),
|
||||
);
|
||||
try {
|
||||
const body = await (await fetch("/api/alog/library")).json();
|
||||
if (!body.ok || !body.files.length) {
|
||||
libraryList.replaceChildren(
|
||||
Object.assign(document.createElement("li"), {
|
||||
textContent:
|
||||
"No .alog files found. Set ALOG_DIR or drop files in ~/Roastetta.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
libraryList.replaceChildren(
|
||||
...body.files.map((f) => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = `${f.filename} (${Math.round(f.sizeBytes / 1024)} KB)`;
|
||||
li.onclick = async () => {
|
||||
setText(resultEl, "Loading…");
|
||||
applyResult(
|
||||
await (
|
||||
await fetch(
|
||||
`/api/alog/library/${encodeURIComponent(f.filename)}`,
|
||||
)
|
||||
).json(),
|
||||
);
|
||||
};
|
||||
return li;
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = err.message;
|
||||
li.style.color = "#a8371a";
|
||||
libraryList.replaceChildren(li);
|
||||
}
|
||||
});
|
||||
function applyResult(body) {
|
||||
if (!body.ok) {
|
||||
setText(resultEl, body.error ?? body.code, true);
|
||||
return;
|
||||
}
|
||||
state.plan.reference = body;
|
||||
recompute();
|
||||
setText(
|
||||
resultEl,
|
||||
`${body.roast.title} — ${body.roast.roastDate || "no date"}; first crack ${fmt(body.derived?.firstCrackS)}; development ${fmt(body.derived?.developmentS)}; drop ${fmt(body.derived?.dropS)}; DTR ${body.derived?.dtrPct ?? "—"}%.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(seconds) {
|
||||
if (seconds === null || seconds === undefined) return "—";
|
||||
const s = Math.round(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
if (seconds == null) return "—";
|
||||
const s = Math.round(seconds);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const csrfToken = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
|
||||
export const protectedFetch = (url, options = {}) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
headers: { ...options.headers, "x-csrf-token": csrfToken() },
|
||||
});
|
||||
|
||||
export async function api(url, options = {}) {
|
||||
const response = await protectedFetch(url, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...options.headers },
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (response.status === 401 && body.code === "unauthorized") {
|
||||
// This is specifically requireAuth's code for "no valid session" — the session expired
|
||||
// or was revoked mid-page (account/admin pages otherwise show a permanent "could not
|
||||
// load" error with no indication the user was signed out). A 401 with any other code
|
||||
// (e.g. a wrong current password on an account form) is a normal request failure, not a
|
||||
// sign-out, and must not redirect the user away mid-form.
|
||||
location.assign("/login");
|
||||
return new Promise(() => {}); // navigation is already underway; never resolve
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(body.error || body.code || "request_failed");
|
||||
error.code = body.code;
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
import { wireWhyPanels } from "./why-panels.js";
|
||||
import {
|
||||
SCORE_ATTRS,
|
||||
SCORE_LABELS,
|
||||
TICK_ATTRS,
|
||||
TICK_LABELS,
|
||||
FLAVOR_TAXONOMY,
|
||||
MAX_FLAVOR_TAGS,
|
||||
MAX_CUP_COUNT,
|
||||
computeTotalScore,
|
||||
} from "/shared/cupping.js";
|
||||
|
||||
const sessionId = new URLSearchParams(location.search).get("session");
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleString() : "—";
|
||||
}
|
||||
|
||||
/** Disables `el` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(el, run) {
|
||||
if (!el || el.disabled) return;
|
||||
el.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
el.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── List view ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadSessions() {
|
||||
const body = document.getElementById("sessions-body");
|
||||
try {
|
||||
const { sessions } = await api("/api/cupping");
|
||||
if (!sessions.length) {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">No cupping sessions yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...sessions.map((s) => {
|
||||
const tr = document.createElement("tr");
|
||||
const coffee = document.createElement("td");
|
||||
coffee.textContent = s.planTitle || "Untitled";
|
||||
const score = document.createElement("td");
|
||||
score.className = "num";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge badge-current";
|
||||
badge.textContent = s.totalScore.toFixed(2);
|
||||
score.append(badge);
|
||||
const cups = document.createElement("td");
|
||||
cups.className = "num";
|
||||
cups.textContent = s.cupCount;
|
||||
const flavors = document.createElement("td");
|
||||
const shown = s.flavorTags.slice(0, 3).map((t) => t.split(".").pop());
|
||||
flavors.textContent =
|
||||
shown.join(", ") + (s.flavorTags.length > 3 ? ` +${s.flavorTags.length - 3}` : "");
|
||||
const updated = document.createElement("td");
|
||||
updated.textContent = fmtDate(s.updatedAt);
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
const open = document.createElement("a");
|
||||
open.className = "ghost-btn small";
|
||||
open.href = `/cupping?session=${s.id}`;
|
||||
open.textContent = "Open";
|
||||
const del = document.createElement("button");
|
||||
del.className = "ghost-btn small";
|
||||
del.type = "button";
|
||||
del.textContent = "Delete";
|
||||
del.addEventListener("click", (event) => {
|
||||
if (!confirm("Delete this cupping session?")) return;
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/cupping/${s.id}`, { method: "DELETE" });
|
||||
showToast("Session deleted.");
|
||||
loadSessions();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
actions.append(open, del);
|
||||
tr.append(coffee, score, cups, flavors, updated, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
body.innerHTML = `<tr><td colspan="6" class="empty-state">Could not load sessions.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlanOptions() {
|
||||
const select = document.getElementById("new-session-plan");
|
||||
try {
|
||||
const { plans } = await api("/api/plans");
|
||||
select.append(
|
||||
...plans.map((p) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = p.id;
|
||||
option.textContent = p.plan?.fields?.["0.1"] || "Untitled plan";
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* the plan-less option still works */
|
||||
}
|
||||
}
|
||||
|
||||
function wireNewSessionForm() {
|
||||
const form = document.getElementById("new-session-form");
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const roastPlanId = document.getElementById("new-session-plan").value || undefined;
|
||||
const cupCount = Number(document.getElementById("new-session-cups").value) || 5;
|
||||
guarded(form.querySelector("button[type=submit]"), async () => {
|
||||
try {
|
||||
const { session } = await api("/api/cupping", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ roastPlanId, cupCount }),
|
||||
});
|
||||
location.assign(`/cupping?session=${session.id}`);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Session view ────────────────────────────────────────────────────────
|
||||
|
||||
let data = null; // the coerced session document (snake_case, matches shared/cupping.js)
|
||||
let saveTimer = null;
|
||||
|
||||
function setAutosaveStatus(status) {
|
||||
const chip = document.getElementById("cupping-autosave-status");
|
||||
const text = chip.querySelector(".autosave-text");
|
||||
chip.classList.remove("saving", "saved", "failed");
|
||||
chip.classList.add(status);
|
||||
text.textContent =
|
||||
{ saving: "Saving…", saved: "Synced", failed: "Sync failed" }[status] || "Not saved yet";
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
setAutosaveStatus("saving");
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(save, 500);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const body = await api(`/api/cupping/${sessionId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
document.getElementById("cup-total").textContent = body.session.totalScore.toFixed(2);
|
||||
setAutosaveStatus("saved");
|
||||
} catch (error) {
|
||||
setAutosaveStatus("failed");
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}
|
||||
|
||||
function liveTotal() {
|
||||
return computeTotalScore(
|
||||
data.scores,
|
||||
data.ticks,
|
||||
data.taint_cups,
|
||||
data.fault_cups,
|
||||
data.cup_count,
|
||||
);
|
||||
}
|
||||
|
||||
function renderTotal() {
|
||||
document.getElementById("cup-total").textContent = liveTotal().toFixed(2);
|
||||
}
|
||||
|
||||
// ── Score sliders ──
|
||||
function renderScoreRows() {
|
||||
const wrap = document.getElementById("cup-score-rows");
|
||||
wrap.replaceChildren(
|
||||
...SCORE_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-score-row";
|
||||
row.dataset.attr = attr;
|
||||
const unscored = !(data.scores[attr] > 0);
|
||||
row.classList.toggle("unscored", unscored);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = SCORE_LABELS[attr];
|
||||
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range";
|
||||
slider.min = "6";
|
||||
slider.max = "10";
|
||||
slider.step = "0.25";
|
||||
slider.value = unscored ? "6" : data.scores[attr];
|
||||
slider.setAttribute("aria-label", `${SCORE_LABELS[attr]} score`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
output.className = "cup-score-value";
|
||||
output.textContent = unscored ? "—" : data.scores[attr].toFixed(2);
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className = "ghost-btn small cup-score-clear";
|
||||
clearBtn.hidden = unscored;
|
||||
clearBtn.textContent = "✕";
|
||||
clearBtn.setAttribute("aria-label", `Clear ${SCORE_LABELS[attr]} score`);
|
||||
|
||||
slider.addEventListener("input", () => {
|
||||
data.scores[attr] = Number(slider.value);
|
||||
row.classList.remove("unscored");
|
||||
output.textContent = data.scores[attr].toFixed(2);
|
||||
clearBtn.hidden = false;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
clearBtn.addEventListener("click", () => {
|
||||
data.scores[attr] = 0;
|
||||
row.classList.add("unscored");
|
||||
slider.value = "6";
|
||||
output.textContent = "—";
|
||||
clearBtn.hidden = true;
|
||||
renderTotal();
|
||||
renderRadar();
|
||||
scheduleSave();
|
||||
});
|
||||
|
||||
row.append(label, slider, output, clearBtn);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tick attributes (fill-up-to cup cells) ──
|
||||
function renderTickRows() {
|
||||
const wrap = document.getElementById("cup-tick-rows");
|
||||
wrap.replaceChildren(
|
||||
...TICK_ATTRS.map((attr) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-tick-row";
|
||||
row.dataset.tick = attr;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "cup-score-label";
|
||||
label.textContent = TICK_LABELS[attr];
|
||||
|
||||
const cells = document.createElement("div");
|
||||
cells.className = "cup-tick-cells";
|
||||
cells.setAttribute("role", "group");
|
||||
cells.setAttribute("aria-label", `${TICK_LABELS[attr]} — cups passed`);
|
||||
|
||||
const output = document.createElement("output");
|
||||
|
||||
function renderCells() {
|
||||
const count = data.ticks[attr] || 0;
|
||||
cells.replaceChildren(
|
||||
...Array.from({ length: data.cup_count }, (_, i) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "cup-tick";
|
||||
btn.setAttribute("aria-label", `Cup ${i + 1}`);
|
||||
const pressed = i < count;
|
||||
btn.setAttribute("aria-pressed", String(pressed));
|
||||
btn.addEventListener("click", () => {
|
||||
data.ticks[attr] = i >= (data.ticks[attr] || 0) ? i + 1 : i;
|
||||
renderCells();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
return btn;
|
||||
}),
|
||||
);
|
||||
output.textContent = `${count}/${data.cup_count}`;
|
||||
}
|
||||
renderCells();
|
||||
row._renderCells = renderCells;
|
||||
|
||||
row.append(label, cells, output);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Defects (taint/fault steppers) ──
|
||||
function renderDefectRows() {
|
||||
const wrap = document.getElementById("cup-defect-rows");
|
||||
const specs = [
|
||||
{ key: "taint_cups", label: "Taint cups", note: "−2 pts each" },
|
||||
{ key: "fault_cups", label: "Fault cups", note: "−4 pts each" },
|
||||
];
|
||||
wrap.replaceChildren(
|
||||
...specs.map(({ key, label, note }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cup-stepper-row";
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "cup-score-label";
|
||||
labelEl.textContent = `${label} (${note})`;
|
||||
const minus = document.createElement("button");
|
||||
minus.type = "button";
|
||||
minus.className = "ghost-btn small";
|
||||
minus.textContent = "−";
|
||||
minus.setAttribute("aria-label", `Decrease ${label}`);
|
||||
const count = document.createElement("output");
|
||||
const plus = document.createElement("button");
|
||||
plus.type = "button";
|
||||
plus.className = "ghost-btn small";
|
||||
plus.textContent = "+";
|
||||
plus.setAttribute("aria-label", `Increase ${label}`);
|
||||
|
||||
function update() {
|
||||
count.textContent = data[key];
|
||||
}
|
||||
minus.addEventListener("click", () => {
|
||||
data[key] = Math.max(0, data[key] - 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
plus.addEventListener("click", () => {
|
||||
data[key] = Math.min(data.cup_count, data[key] + 1);
|
||||
update();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
update();
|
||||
row.append(labelEl, minus, count, plus);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function reclampForCupCount() {
|
||||
for (const attr of TICK_ATTRS) data.ticks[attr] = Math.min(data.ticks[attr] || 0, data.cup_count);
|
||||
data.taint_cups = Math.min(data.taint_cups, data.cup_count);
|
||||
data.fault_cups = Math.min(data.fault_cups, data.cup_count);
|
||||
}
|
||||
|
||||
function wireCupCount() {
|
||||
const input = document.getElementById("cup-count");
|
||||
input.min = "1";
|
||||
input.max = String(MAX_CUP_COUNT);
|
||||
input.value = data.cup_count;
|
||||
input.addEventListener("change", () => {
|
||||
const next = Math.max(1, Math.min(MAX_CUP_COUNT, Number(input.value) || 1));
|
||||
data.cup_count = next;
|
||||
input.value = next;
|
||||
reclampForCupCount();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderTotal();
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Flavor checklist ──
|
||||
function renderFlavorChips() {
|
||||
const chips = document.getElementById("flavor-chips");
|
||||
chips.replaceChildren(
|
||||
...data.flavor_tags.map((tag) => {
|
||||
// A dedicated class, not .chip-opt (a checkbox-option style whose CSS uses a
|
||||
// descendant `span` selector — reusing it here with a nested span for the remove
|
||||
// button doubled-up borders/padding onto that inner span too).
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "flavor-chip";
|
||||
const text = document.createElement("span");
|
||||
text.textContent = tag.split(".").pop().replaceAll("_", " ");
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "flavor-chip-remove";
|
||||
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
|
||||
remove.textContent = "✕";
|
||||
remove.addEventListener("click", () => toggleFlavorTag(tag, false));
|
||||
chip.append(text, remove);
|
||||
return chip;
|
||||
}),
|
||||
);
|
||||
document
|
||||
.getElementById("flavor-limit-note")
|
||||
.classList.toggle("hidden", data.flavor_tags.length < MAX_FLAVOR_TAGS);
|
||||
}
|
||||
|
||||
function toggleFlavorTag(tag, checked) {
|
||||
if (checked) {
|
||||
if (data.flavor_tags.length >= MAX_FLAVOR_TAGS || data.flavor_tags.includes(tag)) return;
|
||||
data.flavor_tags.push(tag);
|
||||
} else {
|
||||
data.flavor_tags = data.flavor_tags.filter((t) => t !== tag);
|
||||
}
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function renderFlavorFamilies() {
|
||||
const wrap = document.getElementById("flavor-families");
|
||||
const atLimit = data.flavor_tags.length >= MAX_FLAVOR_TAGS;
|
||||
wrap.replaceChildren(
|
||||
...Object.entries(FLAVOR_TAXONOMY).map(([familyId, family]) => {
|
||||
const selectedCount = data.flavor_tags.filter((t) => t.startsWith(`${familyId}.`)).length;
|
||||
const details = document.createElement("details");
|
||||
details.className = "flavor-family";
|
||||
const summary = document.createElement("summary");
|
||||
summary.textContent = family.label + (selectedCount ? ` (${selectedCount})` : "");
|
||||
details.append(summary);
|
||||
for (const [subId, descriptors] of Object.entries(family.subgroups)) {
|
||||
const subhead = document.createElement("p");
|
||||
subhead.className = "subhead";
|
||||
subhead.textContent = subId.replaceAll("_", " ");
|
||||
const group = document.createElement("div");
|
||||
group.className = "chip-group";
|
||||
for (const descriptor of descriptors) {
|
||||
const tag = `${familyId}.${subId}.${descriptor}`;
|
||||
const label = document.createElement("label");
|
||||
label.className = "chip-opt";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = data.flavor_tags.includes(tag);
|
||||
input.disabled = atLimit && !input.checked;
|
||||
input.addEventListener("change", () => toggleFlavorTag(tag, input.checked));
|
||||
const span = document.createElement("span");
|
||||
span.textContent = descriptor.replaceAll("_", " ");
|
||||
label.append(input, span);
|
||||
group.append(label);
|
||||
}
|
||||
details.append(subhead, group);
|
||||
}
|
||||
return details;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Radar chart (pure SVG, no dependency) ──
|
||||
const RADAR_CENTER = 120;
|
||||
const RADAR_RADIUS = 90;
|
||||
|
||||
function radarPoints() {
|
||||
const n = SCORE_ATTRS.length;
|
||||
return SCORE_ATTRS.map((attr, i) => {
|
||||
const raw = data.scores[attr] || 0;
|
||||
const frac = raw > 0 ? Math.max(0, Math.min(1, (raw - 6) / 4)) : 0;
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
return {
|
||||
x: RADAR_CENTER + frac * RADAR_RADIUS * Math.cos(angle),
|
||||
y: RADAR_CENTER + frac * RADAR_RADIUS * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
|
||||
return el;
|
||||
}
|
||||
|
||||
function initRadarStatic() {
|
||||
const svg = document.getElementById("cup-radar");
|
||||
svg.replaceChildren();
|
||||
// Rings at scores 7/8/9/10 (6 is the center — an unscored/floor axis point).
|
||||
for (const score of [7, 8, 9, 10]) {
|
||||
const r = ((score - 6) / 4) * RADAR_RADIUS;
|
||||
svg.append(
|
||||
svgEl("circle", {
|
||||
cx: RADAR_CENTER,
|
||||
cy: RADAR_CENTER,
|
||||
r,
|
||||
fill: "none",
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = SCORE_ATTRS.length;
|
||||
SCORE_ATTRS.forEach((attr, i) => {
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * i) / n;
|
||||
const x2 = RADAR_CENTER + RADAR_RADIUS * Math.cos(angle);
|
||||
const y2 = RADAR_CENTER + RADAR_RADIUS * Math.sin(angle);
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
x1: RADAR_CENTER,
|
||||
y1: RADAR_CENTER,
|
||||
x2,
|
||||
y2,
|
||||
stroke: "var(--line)",
|
||||
"stroke-width": "1",
|
||||
}),
|
||||
);
|
||||
const lx = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.cos(angle);
|
||||
const ly = RADAR_CENTER + (RADAR_RADIUS + 14) * Math.sin(angle);
|
||||
const label = svgEl("text", {
|
||||
x: lx,
|
||||
y: ly,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "middle",
|
||||
"font-size": "9",
|
||||
fill: "var(--ink-2)",
|
||||
});
|
||||
label.textContent = SCORE_LABELS[attr].split("/")[0];
|
||||
svg.append(label);
|
||||
});
|
||||
const shape = svgEl("polygon", {
|
||||
id: "radar-shape",
|
||||
fill: "var(--ember)",
|
||||
"fill-opacity": "0.25",
|
||||
stroke: "var(--ember)",
|
||||
"stroke-width": "1.5",
|
||||
});
|
||||
svg.append(shape);
|
||||
}
|
||||
|
||||
function renderRadar() {
|
||||
const shape = document.getElementById("radar-shape");
|
||||
if (!shape) return;
|
||||
shape.setAttribute("points", radarPoints().map((p) => `${p.x},${p.y}`).join(" "));
|
||||
}
|
||||
|
||||
function wireNotes() {
|
||||
const textarea = document.getElementById("cup-notes-text");
|
||||
textarea.value = data.notes;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
textarea.addEventListener("input", () => {
|
||||
data.notes = textarea.value;
|
||||
document.getElementById("cup-notes-count").textContent = data.notes.length;
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
async function initSessionView(user) {
|
||||
document.getElementById("cupping-list-view").classList.add("hidden");
|
||||
document.getElementById("cupping-session-view").classList.remove("hidden");
|
||||
document.getElementById("session-autosave-wrap").classList.remove("hidden");
|
||||
setAutosaveStatus("saved");
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await api(`/api/cupping/${sessionId}`);
|
||||
} catch (error) {
|
||||
showToast(error.message || "Could not load this session.", "fail");
|
||||
location.assign("/cupping");
|
||||
return;
|
||||
}
|
||||
data = body.session.data;
|
||||
document.getElementById("cupping-title").textContent =
|
||||
body.session.planTitle || "Cupping session";
|
||||
document.getElementById("cupping-subtitle").textContent = `${data.cup_count} cups`;
|
||||
|
||||
wireCupCount();
|
||||
renderScoreRows();
|
||||
renderTickRows();
|
||||
renderDefectRows();
|
||||
renderFlavorFamilies();
|
||||
renderFlavorChips();
|
||||
wireNotes();
|
||||
initRadarStatic();
|
||||
renderRadar();
|
||||
renderTotal();
|
||||
wireWhyPanels();
|
||||
void user;
|
||||
}
|
||||
|
||||
async function initListView() {
|
||||
document.getElementById("cupping-list-view").classList.remove("hidden");
|
||||
document.getElementById("cupping-session-view").classList.add("hidden");
|
||||
wireNewSessionForm();
|
||||
await Promise.all([loadSessions(), loadPlanOptions()]);
|
||||
}
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
if (sessionId) await initSessionView(user);
|
||||
else await initListView();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,30 @@
|
||||
const form = document.querySelector("#forgot-form");
|
||||
const message = document.querySelector("#message");
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
try {
|
||||
const response = await fetch("/api/auth/forgot", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
if (response.status === 429) {
|
||||
message.textContent = "Too many requests. Try again in a few minutes.";
|
||||
message.className = "auth-message error";
|
||||
return;
|
||||
}
|
||||
// Always show the same message, whether or not the account exists.
|
||||
message.textContent =
|
||||
"If that email has an account, a reset link is on its way.";
|
||||
message.className = "auth-message info";
|
||||
form.reset();
|
||||
} catch {
|
||||
message.textContent = "Could not reach the server. Try again shortly.";
|
||||
message.className = "auth-message error";
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { initSideNav, loadNavUser } from "./nav.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
let lots = [];
|
||||
let showArchived = false;
|
||||
let editingId = null;
|
||||
|
||||
function fmtDate(value) {
|
||||
return value ? new Date(value).toLocaleDateString() : "—";
|
||||
}
|
||||
|
||||
/** Disables `button` for the duration of `run()` so a slow request can't be double-submitted. */
|
||||
async function guarded(button, run) {
|
||||
if (!button || button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLots() {
|
||||
const body = document.getElementById("lots-body");
|
||||
const visible = showArchived ? lots : lots.filter((l) => !l.archived);
|
||||
if (!visible.length) {
|
||||
body.innerHTML = `<tr><td colspan="5" class="empty-state">No lots yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
body.replaceChildren(
|
||||
...visible.map((lot) => {
|
||||
const tr = document.createElement("tr");
|
||||
if (lot.archived) tr.className = "archived";
|
||||
|
||||
const lotCell = document.createElement("td");
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = lot.origin;
|
||||
const sub = document.createElement("div");
|
||||
sub.className = "muted";
|
||||
sub.style.fontSize = "11.5px";
|
||||
sub.textContent = [lot.variety, lot.producer].filter(Boolean).join(" · ") || "—";
|
||||
lotCell.append(strong, sub);
|
||||
|
||||
const processCell = document.createElement("td");
|
||||
processCell.textContent = lot.process || "—";
|
||||
|
||||
const remainingCell = document.createElement("td");
|
||||
const pct = lot.initialWeightG > 0
|
||||
? Math.max(0, Math.min(100, (lot.remainingWeightG / lot.initialWeightG) * 100))
|
||||
: 0;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "lot-remaining";
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "blend-total-bar";
|
||||
const fill = document.createElement("div");
|
||||
fill.className = "blend-total-fill";
|
||||
if (lot.remainingWeightG < 0) fill.classList.add("over");
|
||||
fill.style.width = `${lot.remainingWeightG < 0 ? 0 : pct}%`;
|
||||
bar.append(fill);
|
||||
const label = document.createElement("span");
|
||||
label.className = "blend-total-label";
|
||||
label.textContent = `${Math.round(lot.remainingWeightG)} g of ${Math.round(lot.initialWeightG)} g`;
|
||||
wrap.append(bar, label);
|
||||
remainingCell.append(wrap);
|
||||
|
||||
const purchasedCell = document.createElement("td");
|
||||
purchasedCell.textContent = fmtDate(lot.purchaseDate);
|
||||
|
||||
const actions = document.createElement("td");
|
||||
actions.className = "data-table-actions";
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "ghost-btn small";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => startEdit(lot));
|
||||
|
||||
const archiveBtn = document.createElement("button");
|
||||
archiveBtn.className = "ghost-btn small";
|
||||
archiveBtn.type = "button";
|
||||
archiveBtn.textContent = lot.archived ? "Unarchive" : "Archive";
|
||||
archiveBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
try {
|
||||
await api(`/api/inventory/${lot.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ archived: !lot.archived }),
|
||||
});
|
||||
showToast(lot.archived ? "Lot unarchived." : "Lot archived.");
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const logBtn = document.createElement("button");
|
||||
logBtn.className = "ghost-btn small";
|
||||
logBtn.type = "button";
|
||||
logBtn.textContent = "Log";
|
||||
logBtn.addEventListener("click", () => toggleLog(lot, tr, logBtn));
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "ghost-btn small";
|
||||
deleteBtn.type = "button";
|
||||
deleteBtn.textContent = "Delete";
|
||||
deleteBtn.addEventListener("click", (event) =>
|
||||
guarded(event.currentTarget, async () => {
|
||||
if (!confirm(`Delete the ${lot.origin} lot? This cannot be undone.`))
|
||||
return;
|
||||
try {
|
||||
await api(`/api/inventory/${lot.id}`, { method: "DELETE" });
|
||||
showToast("Lot deleted.");
|
||||
if (editingId === lot.id) cancelEdit();
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
actions.append(editBtn, archiveBtn, logBtn, deleteBtn);
|
||||
tr.append(lotCell, processCell, remainingCell, purchasedCell, actions);
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleLog(lot, row, button) {
|
||||
const existing = row.nextElementSibling;
|
||||
if (existing?.classList.contains("lot-log-row")) {
|
||||
existing.remove();
|
||||
return;
|
||||
}
|
||||
guarded(button, async () => {
|
||||
try {
|
||||
const { log } = await api(`/api/inventory/${lot.id}`);
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "lot-log-row";
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = 5;
|
||||
if (!log.length) {
|
||||
td.className = "empty-state";
|
||||
td.textContent = "No consumption recorded yet.";
|
||||
} else {
|
||||
const ul = document.createElement("ul");
|
||||
ul.style.margin = "0";
|
||||
ul.style.paddingLeft = "18px";
|
||||
for (const entry of log) {
|
||||
const li = document.createElement("li");
|
||||
li.style.fontSize = "12.5px";
|
||||
li.textContent = `${Math.round(entry.weightG)} g — ${entry.planTitle || "manual"} — ${fmtDate(entry.createdAt)}`;
|
||||
ul.append(li);
|
||||
}
|
||||
td.append(ul);
|
||||
}
|
||||
tr.append(td);
|
||||
row.after(tr);
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLots() {
|
||||
try {
|
||||
const body = await api("/api/inventory");
|
||||
lots = body.lots;
|
||||
renderLots();
|
||||
} catch (error) {
|
||||
document.getElementById("lots-body").innerHTML =
|
||||
`<tr><td colspan="5" class="empty-state">Could not load lots.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(lot) {
|
||||
editingId = lot.id;
|
||||
const form = document.getElementById("lot-form");
|
||||
form.id.value = lot.id;
|
||||
form.origin.value = lot.origin;
|
||||
form.variety.value = lot.variety;
|
||||
form.process.value = lot.process;
|
||||
form.producer.value = lot.producer;
|
||||
form.purchaseDate.value = lot.purchaseDate ? lot.purchaseDate.slice(0, 10) : "";
|
||||
form.initialWeightG.value = lot.initialWeightG;
|
||||
form.costTotal.value = lot.costTotal ?? "";
|
||||
form.moisturePct.value = lot.moisturePct ?? "";
|
||||
form.densityGL.value = lot.densityGL ?? "";
|
||||
form.notes.value = lot.notes;
|
||||
document.getElementById("lot-form-title").textContent = "Edit lot";
|
||||
document.getElementById("lot-form-submit").textContent = "Save changes";
|
||||
document.getElementById("lot-form-cancel").classList.remove("hidden");
|
||||
const note = document.getElementById("lot-remaining-note");
|
||||
note.classList.remove("hidden");
|
||||
note.textContent = `Remaining: ${Math.round(lot.remainingWeightG)} g — remaining weight is only changed by roasts drawing from the lot. Correct the initial weight and the remaining shifts with it.`;
|
||||
document.getElementById("lot-form-card").scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
const form = document.getElementById("lot-form");
|
||||
form.reset();
|
||||
form.id.value = "";
|
||||
document.getElementById("lot-form-title").textContent = "Add a lot";
|
||||
document.getElementById("lot-form-submit").textContent = "Add lot";
|
||||
document.getElementById("lot-form-cancel").classList.add("hidden");
|
||||
document.getElementById("lot-remaining-note").classList.add("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("lot-form-cancel").addEventListener("click", cancelEdit);
|
||||
document.getElementById("show-archived").addEventListener("change", (event) => {
|
||||
showArchived = event.target.checked;
|
||||
renderLots();
|
||||
});
|
||||
document.getElementById("lot-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const submitBtn = document.getElementById("lot-form-submit");
|
||||
guarded(submitBtn, async () => {
|
||||
try {
|
||||
const payload = {
|
||||
origin: data.origin,
|
||||
variety: data.variety,
|
||||
process: data.process,
|
||||
producer: data.producer,
|
||||
purchaseDate: data.purchaseDate || null,
|
||||
initialWeightG: Number(data.initialWeightG),
|
||||
costTotal: data.costTotal === "" ? null : Number(data.costTotal),
|
||||
moisturePct: data.moisturePct === "" ? null : Number(data.moisturePct),
|
||||
densityGL: data.densityGL === "" ? null : Number(data.densityGL),
|
||||
notes: data.notes,
|
||||
};
|
||||
if (editingId) {
|
||||
await api(`/api/inventory/${editingId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
showToast("Lot updated.");
|
||||
} else {
|
||||
await api("/api/inventory", { method: "POST", body: JSON.stringify(payload) });
|
||||
showToast("Lot added.");
|
||||
}
|
||||
cancelEdit();
|
||||
await loadLots();
|
||||
} catch (error) {
|
||||
showToast(error.message, "fail");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("btn-logout").addEventListener("click", async () => {
|
||||
await protectedFetch("/api/auth/logout", { method: "POST" });
|
||||
location.assign("/");
|
||||
});
|
||||
|
||||
async function init() {
|
||||
initSideNav();
|
||||
const user = await loadNavUser();
|
||||
if (!user) return;
|
||||
await loadLots();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,35 @@
|
||||
const form = document.querySelector("#login-form");
|
||||
const message = document.querySelector("#message");
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
body.code === "too_many_attempts"
|
||||
? "Too many attempts. Try again in a few minutes."
|
||||
: body.error || "Could not log in. Check your email and password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { api, protectedFetch } from "./api.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
let lots = [];
|
||||
|
||||
function findLot(id) {
|
||||
return lots.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
/** Wires the "From inventory lot" picker (in The Coffee) and the "Draw from lot" action
|
||||
* (in Roast Log — Plan vs. Actual). `getRemotePlanId` is a function since the planner's
|
||||
* remotePlanId is module-local and can change after a sync. */
|
||||
export function initLotPicker({ state, recompute, flushCurrentPlan, getRemotePlanId }) {
|
||||
const select = document.getElementById("lot-select");
|
||||
const note = document.getElementById("lot-picker-note");
|
||||
const consumeRow = document.getElementById("inventory-consume");
|
||||
const consumeLabel = document.getElementById("inventory-consume-label");
|
||||
const consumeBtn = document.getElementById("btn-consume");
|
||||
|
||||
function renderOptions() {
|
||||
const current = state.plan.inventory.lotId;
|
||||
select.replaceChildren(
|
||||
Object.assign(document.createElement("option"), { value: "", textContent: "— none —" }),
|
||||
...lots
|
||||
.filter((l) => !l.archived)
|
||||
.map((l) =>
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: l.id,
|
||||
textContent: `${l.origin}${l.variety ? ` — ${l.variety}` : ""} (${Math.round(l.remainingWeightG)} g remaining)`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (current && !findLot(current)) {
|
||||
// The plan references a lot that's now archived/deleted — keep it selectable so the
|
||||
// stored value still displays instead of silently reverting to "— none —".
|
||||
select.append(
|
||||
Object.assign(document.createElement("option"), {
|
||||
value: current,
|
||||
textContent: state.plan.inventory.lotLabel || "Unavailable lot",
|
||||
disabled: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
select.value = current || "";
|
||||
}
|
||||
|
||||
async function loadLots() {
|
||||
try {
|
||||
const body = await api("/api/inventory");
|
||||
lots = body.lots;
|
||||
} catch {
|
||||
lots = [];
|
||||
}
|
||||
renderOptions();
|
||||
updateConsumeRow();
|
||||
}
|
||||
|
||||
function updateConsumeRow() {
|
||||
const lotId = state.plan.inventory.lotId;
|
||||
if (!lotId) {
|
||||
consumeRow.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
consumeRow.classList.remove("hidden");
|
||||
const consumed = state.plan.inventory.consumed;
|
||||
if (consumed) {
|
||||
consumeLabel.textContent = `✓ ${Math.round(consumed.weightG)} g drawn from ${consumed.lotLabel} · ${new Date(consumed.atIso).toLocaleDateString()}`;
|
||||
consumeBtn.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
consumeBtn.classList.remove("hidden");
|
||||
const weightG = Number(state.plan.fields["0.4"]);
|
||||
const lot = findLot(lotId);
|
||||
const lotLabel = lot ? lot.origin : state.plan.inventory.lotLabel || "this lot";
|
||||
if (Number.isFinite(weightG) && weightG > 0) {
|
||||
consumeLabel.textContent = `Charging will draw ${weightG} g from ${lotLabel}.`;
|
||||
consumeBtn.disabled = false;
|
||||
consumeBtn.removeAttribute("title");
|
||||
} else {
|
||||
consumeLabel.textContent = `Set "Green in" (field 0.4) to draw weight from ${lotLabel}.`;
|
||||
consumeBtn.disabled = true;
|
||||
consumeBtn.title = "Enter a Green in weight on The Coffee first";
|
||||
}
|
||||
}
|
||||
|
||||
select.addEventListener("change", () => {
|
||||
const lotId = select.value;
|
||||
state.plan.inventory.lotId = lotId;
|
||||
const lot = findLot(lotId);
|
||||
state.plan.inventory.lotLabel = lot
|
||||
? `${lot.origin}${lot.variety ? ` — ${lot.variety}` : ""}`
|
||||
: "";
|
||||
if (lot) {
|
||||
note.textContent = `${Math.round(lot.remainingWeightG)} g remaining in this lot.`;
|
||||
const nameInput = document.querySelector('[name="0.1"]');
|
||||
if (nameInput && !nameInput.value) {
|
||||
nameInput.value = lot.origin;
|
||||
state.plan.fields["0.1"] = lot.origin;
|
||||
}
|
||||
} else {
|
||||
note.textContent =
|
||||
"Optional — link a lot and the Roast Log can draw the green weight down when you charge.";
|
||||
}
|
||||
updateConsumeRow();
|
||||
recompute();
|
||||
});
|
||||
|
||||
consumeBtn.addEventListener("click", async () => {
|
||||
consumeBtn.disabled = true;
|
||||
try {
|
||||
if (!(await flushCurrentPlan())) {
|
||||
showToast("Could not sync the plan. Try again.", "fail");
|
||||
return;
|
||||
}
|
||||
const roastPlanId = getRemotePlanId();
|
||||
if (!roastPlanId) {
|
||||
showToast("Sync the plan first, then draw from the lot.", "fail");
|
||||
return;
|
||||
}
|
||||
const weightG = Number(state.plan.fields["0.4"]);
|
||||
const response = await protectedFetch(
|
||||
`/api/inventory/${state.plan.inventory.lotId}/consume`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ weightG, roastPlanId }),
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok && body.code !== "already_consumed") {
|
||||
showToast(body.error || "Could not draw from the lot.", "fail");
|
||||
return;
|
||||
}
|
||||
state.plan.inventory.consumed = {
|
||||
lotId: state.plan.inventory.lotId,
|
||||
lotLabel: state.plan.inventory.lotLabel,
|
||||
weightG,
|
||||
atIso: new Date().toISOString(),
|
||||
};
|
||||
updateConsumeRow();
|
||||
recompute();
|
||||
showToast(`${weightG} g drawn from lot.`);
|
||||
await loadLots();
|
||||
} finally {
|
||||
consumeBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
loadLots();
|
||||
return { updateConsumeRow, renderOptions };
|
||||
}
|
||||
+860
-399
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
// Shared side-nav behavior (collapse/expand, mobile drawer) for every authenticated page
|
||||
// (planner, account, admin) so each page's own script doesn't reimplement it.
|
||||
export function initSideNav() {
|
||||
const nav = document.getElementById("side-nav");
|
||||
const hamburger = document.getElementById("nav-hamburger");
|
||||
const collapseBtn = document.getElementById("nav-collapse");
|
||||
const workspace = document.getElementById("app-workspace");
|
||||
const overlay = document.getElementById("drawer-overlay");
|
||||
const COLLAPSE_KEY = "roastPlannerNavCollapsed";
|
||||
|
||||
function closeMobileNav() {
|
||||
nav.classList.remove("mobile-open");
|
||||
hamburger?.setAttribute("aria-expanded", "false");
|
||||
if (!document.querySelector(".drawer:not(.hidden)"))
|
||||
overlay?.classList.add("hidden");
|
||||
}
|
||||
hamburger?.addEventListener("click", () => {
|
||||
const open = nav.classList.toggle("mobile-open");
|
||||
hamburger.setAttribute("aria-expanded", String(open));
|
||||
overlay?.classList.toggle("hidden", !open);
|
||||
});
|
||||
overlay?.addEventListener("click", closeMobileNav);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeMobileNav();
|
||||
});
|
||||
|
||||
function applyCollapsed(collapsed) {
|
||||
nav.classList.toggle("collapsed", collapsed);
|
||||
workspace?.classList.toggle("nav-collapsed", collapsed);
|
||||
if (collapseBtn) {
|
||||
collapseBtn.textContent = collapsed ? "»" : "«";
|
||||
collapseBtn.setAttribute(
|
||||
"aria-label",
|
||||
collapsed ? "Expand navigation" : "Collapse navigation",
|
||||
);
|
||||
}
|
||||
}
|
||||
applyCollapsed(localStorage.getItem(COLLAPSE_KEY) === "true");
|
||||
collapseBtn?.addEventListener("click", () => {
|
||||
const next = !nav.classList.contains("collapsed");
|
||||
applyCollapsed(next);
|
||||
localStorage.setItem(COLLAPSE_KEY, String(next));
|
||||
});
|
||||
return { closeMobileNav };
|
||||
}
|
||||
|
||||
/** Populates the nav's user chip and admin link; redirects to /login when signed out. */
|
||||
export async function loadNavUser() {
|
||||
const response = await fetch("/api/auth/me");
|
||||
if (!response.ok) {
|
||||
location.replace("/login");
|
||||
return null;
|
||||
}
|
||||
const { user } = await response.json();
|
||||
const emailEl = document.getElementById("account-email");
|
||||
if (emailEl) emailEl.textContent = user.email;
|
||||
const avatarEl = document.getElementById("nav-user-avatar");
|
||||
if (avatarEl) avatarEl.textContent = user.email.charAt(0).toUpperCase();
|
||||
if (user.role === "admin")
|
||||
document.getElementById("nav-admin")?.classList.remove("hidden");
|
||||
return user;
|
||||
}
|
||||
+97
-86
@@ -1,89 +1,100 @@
|
||||
// Wires the "Prefill from URL" panel. Applies the returned field patch only into empty
|
||||
// fields by default (checkbox to overwrite), tracks a snapshot for undo, and never touches
|
||||
// the form on any error.
|
||||
// URL prefill UI; output is always built with DOM nodes so remote text never becomes markup.
|
||||
const csrf = () =>
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((v) => v.startsWith("rp_csrf="))
|
||||
?.split("=")[1] || "";
|
||||
function message(target, text, error = false) {
|
||||
const p = document.createElement("p");
|
||||
p.textContent = text;
|
||||
if (error) p.style.color = "#a8371a";
|
||||
target.replaceChildren(p);
|
||||
}
|
||||
|
||||
export function initPrefillPanel({ state, renderFormFromPlan, recompute }) {
|
||||
const urlInput = document.getElementById("prefill-url");
|
||||
const overwriteBox = document.getElementById("prefill-overwrite");
|
||||
const goBtn = document.getElementById("prefill-go");
|
||||
const undoBtn = document.getElementById("prefill-undo");
|
||||
const resultEl = document.getElementById("prefill-result");
|
||||
|
||||
let snapshot = null;
|
||||
|
||||
goBtn.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
goBtn.disabled = true;
|
||||
resultEl.innerHTML = `<p>Fetching…</p>`;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/prefill", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!body.ok) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(body.error ?? body.code)}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
snapshot = JSON.parse(JSON.stringify(state.plan));
|
||||
const overwrite = overwriteBox.checked;
|
||||
let applied = 0;
|
||||
for (const [id, value] of Object.entries(body.fields ?? {})) {
|
||||
const current = state.plan.fields[id] ?? "";
|
||||
if (!overwrite && current !== "") continue;
|
||||
state.plan.fields[id] = value;
|
||||
applied++;
|
||||
}
|
||||
|
||||
renderFormFromPlan();
|
||||
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
|
||||
recompute();
|
||||
undoBtn.disabled = false;
|
||||
|
||||
const warnings = (body.warnings ?? []).map((w) => `<li>${escapeHtml(w)}</li>`).join("");
|
||||
resultEl.innerHTML = `
|
||||
<p>Applied ${applied} field${applied === 1 ? "" : "s"} from <a href="${escapeHtml(body.source.finalUrl)}" target="_blank" rel="noopener">${escapeHtml(body.source.finalUrl)}</a>.</p>
|
||||
${warnings ? `<ul class="warnings">${warnings}</ul>` : ""}
|
||||
`;
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `<p style="color:#a8371a">${escapeHtml(err.message)}</p>`;
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
undoBtn.addEventListener("click", () => {
|
||||
if (!snapshot) return;
|
||||
state.plan = snapshot;
|
||||
snapshot = null;
|
||||
undoBtn.disabled = true;
|
||||
renderFormFromPlan();
|
||||
clearPrefilledMarks();
|
||||
recompute();
|
||||
resultEl.innerHTML = "<p>Prefill undone.</p>";
|
||||
});
|
||||
|
||||
function markPrefilled(ids, provenance) {
|
||||
for (const id of ids) {
|
||||
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add("prefilled");
|
||||
const prov = provenance[id];
|
||||
if (prov) el.title = `from: ${prov}`;
|
||||
}
|
||||
}
|
||||
function clearPrefilledMarks() {
|
||||
for (const el of document.querySelectorAll(".prefilled")) {
|
||||
el.classList.remove("prefilled");
|
||||
el.removeAttribute("title");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const urlInput = document.getElementById("prefill-url"),
|
||||
overwriteBox = document.getElementById("prefill-overwrite"),
|
||||
goBtn = document.getElementById("prefill-go"),
|
||||
undoBtn = document.getElementById("prefill-undo"),
|
||||
resultEl = document.getElementById("prefill-result");
|
||||
let snapshot = null;
|
||||
goBtn.addEventListener("click", async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
goBtn.disabled = true;
|
||||
message(resultEl, "Fetching…");
|
||||
try {
|
||||
const res = await fetch("/api/prefill", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-csrf-token": csrf() },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!body.ok) {
|
||||
message(resultEl, body.error ?? body.code, true);
|
||||
return;
|
||||
}
|
||||
snapshot = structuredClone(state.plan);
|
||||
let applied = 0;
|
||||
for (const [id, value] of Object.entries(body.fields ?? {})) {
|
||||
if (!overwriteBox.checked && (state.plan.fields[id] ?? "") !== "")
|
||||
continue;
|
||||
state.plan.fields[id] = value;
|
||||
applied++;
|
||||
}
|
||||
renderFormFromPlan();
|
||||
markPrefilled(Object.keys(body.fields ?? {}), body.provenance ?? {});
|
||||
recompute();
|
||||
undoBtn.disabled = false;
|
||||
const p = document.createElement("p"),
|
||||
link = document.createElement("a");
|
||||
link.href = body.source.finalUrl;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
link.textContent = body.source.finalUrl;
|
||||
p.append(
|
||||
`Applied ${applied} field${applied === 1 ? "" : "s"} from `,
|
||||
link,
|
||||
".",
|
||||
);
|
||||
const children = [p];
|
||||
if (body.warnings?.length) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "warnings";
|
||||
for (const warning of body.warnings) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = warning;
|
||||
ul.append(li);
|
||||
}
|
||||
children.push(ul);
|
||||
}
|
||||
resultEl.replaceChildren(...children);
|
||||
} catch (err) {
|
||||
message(resultEl, err.message, true);
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
undoBtn.addEventListener("click", () => {
|
||||
if (!snapshot) return;
|
||||
state.plan = snapshot;
|
||||
snapshot = null;
|
||||
undoBtn.disabled = true;
|
||||
renderFormFromPlan();
|
||||
for (const el of document.querySelectorAll(".prefilled")) {
|
||||
el.classList.remove("prefilled");
|
||||
el.removeAttribute("title");
|
||||
}
|
||||
recompute();
|
||||
message(resultEl, "Prefill undone.");
|
||||
});
|
||||
function markPrefilled(ids, provenance) {
|
||||
for (const id of ids) {
|
||||
const el = document.querySelector(`[name="${CSS.escape(id)}"]`);
|
||||
if (el) {
|
||||
el.classList.add("prefilled");
|
||||
if (provenance[id]) el.title = `from: ${provenance[id]}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const form = document.querySelector("#reset-form");
|
||||
const message = document.querySelector("#message");
|
||||
const resetToken = new URLSearchParams(location.search).get("token") || "";
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
if (!resetToken) {
|
||||
form.classList.add("hidden");
|
||||
showError(
|
||||
"This reset link is missing its token. Request a new one from the forgot-password page.",
|
||||
);
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (data.password !== data.confirmPassword) {
|
||||
showError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token: resetToken, password: data.password }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
invalid_token:
|
||||
"This reset link is invalid or has expired. Request a new one.",
|
||||
account_disabled:
|
||||
"Your password was updated, but this account is disabled. Contact an administrator.",
|
||||
}[body.code] || body.error || "Could not reset the password.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const form = document.querySelector("#setup-form");
|
||||
const message = document.querySelector("#message");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/bootstrap", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(Object.fromEntries(new FormData(form))),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
bootstrap_used: "The administrator account already exists.",
|
||||
invalid_setup_token: "That setup token is incorrect.",
|
||||
bootstrap_unavailable: "Setup is not available in this deployment.",
|
||||
}[body.code] || body.error || "Could not create the administrator.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const form = document.querySelector("#signup-form");
|
||||
const message = document.querySelector("#message");
|
||||
const title = document.querySelector("#signup-title");
|
||||
|
||||
function showError(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message error";
|
||||
}
|
||||
function showInfo(text) {
|
||||
message.textContent = text;
|
||||
message.className = "auth-message info";
|
||||
}
|
||||
|
||||
(async function checkSignupEnabled() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/signup-enabled");
|
||||
const body = await response.json();
|
||||
if (body.enabled) {
|
||||
form.classList.remove("hidden");
|
||||
} else {
|
||||
title.textContent = "Invite only";
|
||||
showInfo(
|
||||
"New signups are currently disabled. Ask an administrator for an invitation.",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Don't strand a new customer on a dead page over one failed probe — show the form and
|
||||
// let the real submit enforce the signup-enabled rule if it turns out to matter.
|
||||
form.classList.remove("hidden");
|
||||
showError(
|
||||
"Could not confirm signups are open. You can still try creating an account below.",
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
if (data.password !== data.confirmPassword) {
|
||||
showError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
submitButton.disabled = true;
|
||||
message.textContent = "";
|
||||
try {
|
||||
const response = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: data.email, password: data.password }),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(
|
||||
{
|
||||
email_exists: "An account with that email already exists.",
|
||||
signup_disabled:
|
||||
"New signups are currently disabled. Ask an administrator for an invitation.",
|
||||
}[body.code] || body.error || "Could not create the account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
location.assign("/app");
|
||||
} catch {
|
||||
showError("Could not reach the server. Check your connection and try again.");
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
function stack() {
|
||||
let el = document.querySelector(".toast-stack");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.className = "toast-stack";
|
||||
el.setAttribute("aria-live", "polite");
|
||||
document.body.append(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function showToast(text, type = "") {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`.trim();
|
||||
toast.textContent = text;
|
||||
stack().append(toast);
|
||||
setTimeout(() => toast.remove(), 4500);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Shared teaching-layer collapse memory for every page that uses `.why-panel` (`<details>`
|
||||
// with a `data-why` id): the planner and the cupping session view. One localStorage key across
|
||||
// both so a user's dismissals carry over between pages.
|
||||
const WHY_COLLAPSED_KEY = "roastPlannerWhyCollapsed.v1";
|
||||
|
||||
export function wireWhyPanels() {
|
||||
let collapsed;
|
||||
try {
|
||||
collapsed = new Set(JSON.parse(localStorage.getItem(WHY_COLLAPSED_KEY)) || []);
|
||||
} catch {
|
||||
collapsed = new Set();
|
||||
}
|
||||
for (const panel of document.querySelectorAll(".why-panel")) {
|
||||
if (collapsed.has(panel.dataset.why)) panel.open = false;
|
||||
panel.addEventListener("toggle", () => {
|
||||
if (panel.open) collapsed.delete(panel.dataset.why);
|
||||
else collapsed.add(panel.dataset.why);
|
||||
try {
|
||||
localStorage.setItem(WHY_COLLAPSED_KEY, JSON.stringify([...collapsed]));
|
||||
} catch {
|
||||
/* unavailable storage */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Roast Planner — Plan the roast before you light the burner</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A structured worksheet that computes first crack, development, and drop from your bean — then keeps the plan next to what actually happened."
|
||||
/>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#2A1D16" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="marketing-header">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<span>Roast Planner</span>
|
||||
</div>
|
||||
<nav>
|
||||
<a class="ghost-btn" href="/login">Log in</a>
|
||||
<a class="primary-btn" href="/signup">Get started</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-inner">
|
||||
<div>
|
||||
<h1>Plan the roast before you light the burner.</h1>
|
||||
<p class="lede">
|
||||
A structured worksheet that computes first crack, development, and
|
||||
drop from your bean — then keeps the plan next to what actually
|
||||
happened.
|
||||
</p>
|
||||
<div class="hero-ctas">
|
||||
<a class="primary-btn" href="/signup">Start planning — free</a>
|
||||
<a class="ghost-btn" href="#how-it-works">See how it works</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-visual" aria-hidden="true">
|
||||
<div class="dock-card-head">
|
||||
<h2>Time Ledger</h2>
|
||||
</div>
|
||||
<div class="ledger-row">
|
||||
<span class="l-fid">1.4</span
|
||||
><span class="l-label">First crack anchor</span
|
||||
><span class="l-val">9:30</span>
|
||||
</div>
|
||||
<div class="ledger-row">
|
||||
<span class="l-fid">5.6</span
|
||||
><span class="l-label">Bean-condition refinement</span
|
||||
><span class="l-val">+0:10</span>
|
||||
</div>
|
||||
<div class="ledger-row">
|
||||
<span class="l-fid">4.3</span
|
||||
><span class="l-label">Development base</span
|
||||
><span class="l-val">2:15</span>
|
||||
</div>
|
||||
<div class="ledger-total drop">
|
||||
<span>Drop time</span><output>11:55</output>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="marketing-section" id="how-it-works">
|
||||
<h2>Built around the worksheet, not a generic form</h2>
|
||||
<p class="section-lede">
|
||||
Every field ties back to the same time-ledger math professional
|
||||
roasters already use on paper — now it computes itself.
|
||||
</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">◐</div>
|
||||
<h3>The Time Ledger</h3>
|
||||
<p>
|
||||
Anchor first crack by cultivar, then let refinements, bean
|
||||
condition, and batch size sum into a plan you can defend.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">✓</div>
|
||||
<h3>Sanity checks</h3>
|
||||
<p>
|
||||
Drying, Maillard, and development ratios validated against known
|
||||
bands before you roast.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">∿</div>
|
||||
<h3>Plan vs. actual</h3>
|
||||
<p>
|
||||
Log milestones at the machine, attach an Artisan .alog reference
|
||||
curve, and see plan against reality.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-row">
|
||||
<div class="feature-row-item">
|
||||
<span class="feature-icon" aria-hidden="true">↗</span
|
||||
><span>Prefill straight from a bean product page URL</span>
|
||||
</div>
|
||||
<div class="feature-row-item">
|
||||
<span class="feature-icon" aria-hidden="true">🖶</span
|
||||
><span>Printable two-page worksheet, faithful to the original</span>
|
||||
</div>
|
||||
<div class="feature-row-item">
|
||||
<span class="feature-icon" aria-hidden="true">⇩</span
|
||||
><span>Installable PWA that keeps working offline</span>
|
||||
</div>
|
||||
<div class="feature-row-item">
|
||||
<span class="feature-icon" aria-hidden="true">⟳</span
|
||||
><span>Autosave with sync the moment you're back online</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="marketing-section">
|
||||
<h2>Three steps, every roast</h2>
|
||||
<div class="steps-row">
|
||||
<div class="step-item">
|
||||
<h3>Describe the coffee</h3>
|
||||
<p>
|
||||
Cultivar, process, bean condition — the worksheet pulls in the
|
||||
right reference numbers automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<h3>Review ledger & checks</h3>
|
||||
<p>
|
||||
Watch first crack, development, and drop compute live, with every
|
||||
ratio checked against known-good bands.
|
||||
</p>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<h3>Roast and record</h3>
|
||||
<p>
|
||||
Log milestones as you go, then compare against an Artisan
|
||||
reference curve after the roast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-band">
|
||||
<h2>Plan your next roast properly.</h2>
|
||||
<a class="primary-btn" href="/signup">Get started — it's free</a>
|
||||
</section>
|
||||
|
||||
<footer class="marketing-footer">
|
||||
<span>© Roast Planner</span>
|
||||
<span
|
||||
><a href="/login">Log in</a><a href="/signup">Sign up</a></span
|
||||
>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Log in — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Log in</h1>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="login-form">
|
||||
<label
|
||||
>Email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<label
|
||||
>Password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Log in</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/forgot">Forgot password?</a>
|
||||
<a href="/signup">Create an account</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "Roast Planner",
|
||||
"short_name": "Roast Planner",
|
||||
"description": "Plan, record, and compare coffee roasts.",
|
||||
"start_url": "/",
|
||||
"start_url": "/app",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#FBF8F4",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Set a new password — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Set a new password</h1>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="reset-form">
|
||||
<label
|
||||
>New password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<label
|
||||
>Confirm password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<p class="muted">Use at least 12 characters.</p>
|
||||
<button class="primary-btn" type="submit">Set password</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/login">Back to log in</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/reset.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Administrator setup — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1>Create the administrator account</h1>
|
||||
<p class="lede">
|
||||
This runs once, before anyone else can sign up. You'll need the
|
||||
setup token from the deployment environment.
|
||||
</p>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="setup-form">
|
||||
<label
|
||||
>Administrator email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
value="[email protected]"
|
||||
readonly
|
||||
/></label>
|
||||
<label
|
||||
>Password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<label
|
||||
>Setup token
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
name="setupToken"
|
||||
autocomplete="off"
|
||||
/></label>
|
||||
<button class="primary-btn" type="submit">Create administrator</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/login">Back to log in</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/setup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Create an account — Roast Planner</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#A8481A" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="auth-page">
|
||||
<section class="panel-card auth-card">
|
||||
<div class="auth-brand">
|
||||
<span class="brand-mark" aria-hidden="true">◐</span>
|
||||
<strong>Roast Planner</strong>
|
||||
</div>
|
||||
<h1 id="signup-title">Create an account</h1>
|
||||
<div id="message" class="auth-message" role="alert"></div>
|
||||
<form id="signup-form" class="hidden">
|
||||
<label
|
||||
>Email
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
/></label>
|
||||
<label
|
||||
>Password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<label
|
||||
>Confirm password
|
||||
<input
|
||||
class="field-input"
|
||||
required
|
||||
type="password"
|
||||
minlength="12"
|
||||
name="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
/></label>
|
||||
<p class="muted">Use at least 12 characters.</p>
|
||||
<button class="primary-btn" type="submit">Create account</button>
|
||||
</form>
|
||||
<div class="auth-links">
|
||||
<a href="/login">Already have an account?</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="/js/signup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+84
-45
@@ -1,58 +1,97 @@
|
||||
const CACHE_NAME = "roast-planner-static-v1";
|
||||
const CACHE_NAME = "roast-planner-static-v6";
|
||||
// Only /app is precached as a navigable page: it is a data-free authenticated shell (draft
|
||||
// data lives only in account-scoped local storage, and logout clears that namespace), so it is
|
||||
// the one page that is both safe and useful to relaunch offline. The marketing page, auth
|
||||
// pages, account, admin, inventory, and cupping all depend on the network (auth checks, live
|
||||
// data) and are deliberately left off this list rather than cached in a way that could show
|
||||
// stale content.
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
"/index.html",
|
||||
"/app.css",
|
||||
"/worksheet.css",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
"/js/main.js",
|
||||
"/js/prefill-ui.js",
|
||||
"/js/alog-ui.js",
|
||||
"/js/print.js",
|
||||
"/shared/fields.js",
|
||||
"/shared/ledger.js",
|
||||
"/shared/time.js",
|
||||
"/shared/reference-data.js",
|
||||
"/shared/curve.js",
|
||||
"/app",
|
||||
"/app.css",
|
||||
"/worksheet.css",
|
||||
"/manifest.webmanifest",
|
||||
"/icon.svg",
|
||||
"/js/main.js",
|
||||
"/js/api.js",
|
||||
"/js/nav.js",
|
||||
"/js/toast.js",
|
||||
"/js/why-panels.js",
|
||||
"/js/lot-picker.js",
|
||||
"/js/prefill-ui.js",
|
||||
"/js/alog-ui.js",
|
||||
"/js/print.js",
|
||||
"/shared/fields.js",
|
||||
"/shared/ledger.js",
|
||||
"/shared/time.js",
|
||||
"/shared/reference-data.js",
|
||||
"/shared/curve.js",
|
||||
"/shared/cupping.js",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))),
|
||||
);
|
||||
self.clients.claim();
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key)),
|
||||
),
|
||||
),
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
const { request } = event;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(request.url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method !== "GET" ||
|
||||
url.origin !== self.location.origin ||
|
||||
url.pathname.startsWith("/api/")
|
||||
)
|
||||
return;
|
||||
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(fetch(request).catch(() => caches.match("/index.html")));
|
||||
return;
|
||||
}
|
||||
if (request.mode === "navigate") {
|
||||
// Only /app has a meaningful offline shell (see APP_SHELL comment above); every other
|
||||
// page requires the network anyway, so let those navigations fail normally when offline
|
||||
// rather than risk serving stale auth/account/admin content.
|
||||
if (url.pathname !== "/app") return;
|
||||
event.respondWith(
|
||||
fetch(request).catch(
|
||||
async () => (await caches.match("/app")) || Response.error(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const update = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached ?? update;
|
||||
}),
|
||||
);
|
||||
// Prefer fresh JavaScript and styles so installed clients receive UI/security updates
|
||||
// immediately; use the cache only when offline.
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok)
|
||||
caches
|
||||
.open(CACHE_NAME)
|
||||
.then((cache) => cache.put(request, response.clone()));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request)),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data === "SKIP_WAITING") self.skipWaiting();
|
||||
});
|
||||
|
||||
+1635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import pg from "pg";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function createDb(connectionString = process.env.DATABASE_URL) {
|
||||
if (!connectionString) throw new Error("DATABASE_URL is required");
|
||||
const pool = new pg.Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
ssl:
|
||||
process.env.DATABASE_SSL === "true"
|
||||
? { rejectUnauthorized: true }
|
||||
: undefined,
|
||||
});
|
||||
return {
|
||||
query: (...args) => pool.query(...args),
|
||||
connect: () => pool.connect(),
|
||||
close: () => pool.end(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply each versioned SQL file once; failed migrations are not recorded. */
|
||||
export async function migrate(db) {
|
||||
await db.query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (filename text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())",
|
||||
);
|
||||
const directory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"db",
|
||||
"migrations",
|
||||
);
|
||||
const files = (await fs.readdir(directory))
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
for (const filename of files) {
|
||||
if (
|
||||
(
|
||||
await db.query("SELECT 1 FROM schema_migrations WHERE filename=$1", [
|
||||
filename,
|
||||
])
|
||||
).rowCount
|
||||
)
|
||||
continue;
|
||||
const sql = await fs.readFile(path.join(directory, filename), "utf8");
|
||||
await db.query("BEGIN");
|
||||
try {
|
||||
await db.query(sql);
|
||||
await db.query("INSERT INTO schema_migrations(filename) VALUES($1)", [
|
||||
filename,
|
||||
]);
|
||||
await db.query("COMMIT");
|
||||
} catch (error) {
|
||||
await db.query("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -13,12 +13,17 @@ const MAX_REDIRECTS = 5;
|
||||
const ALLOWED_CONTENT_TYPES = ["text/html", "text/plain", "application/json", "application/xhtml+xml"];
|
||||
const USER_AGENT = "RoastPlannerWebapp/0.1 (+local prefill tool)";
|
||||
const MAX_EXTRACTED_CHARS = 40_000;
|
||||
const BROWSERLESS_URL = process.env.BROWSERLESS_URL?.replace(/\/$/, "");
|
||||
|
||||
function normalizeToUrl(input) {
|
||||
const trimmed = input.trim();
|
||||
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
|
||||
const candidate = hasScheme ? trimmed : `https://${trimmed}`;
|
||||
return new URL(candidate);
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function contentTypeOf(header) {
|
||||
@@ -70,6 +75,9 @@ export async function fetchPageText(rawUrl) {
|
||||
} catch {
|
||||
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
|
||||
}
|
||||
if (!url) {
|
||||
throw withCode(new Error(`"${rawUrl}" is not a valid URL.`), "bad_url");
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw withCode(new Error(`Only https:// URLs are allowed (got ${url.protocol}).`), "scheme_rejected");
|
||||
}
|
||||
@@ -107,7 +115,15 @@ export async function fetchPageText(rawUrl) {
|
||||
currentUrl = next;
|
||||
continue;
|
||||
}
|
||||
response = res;
|
||||
// Some storefronts (including Shopify sites protected by Cloudflare) rate-limit
|
||||
// server-to-server requests while allowing a normal browser. Use the operator's
|
||||
// local Browserless service only as a narrow fallback; all existing size and
|
||||
// extraction limits still apply below.
|
||||
if (res.status === 429 && BROWSERLESS_URL) {
|
||||
response = await fetchWithBrowserless(currentUrl, controller.signal);
|
||||
} else {
|
||||
response = res;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
@@ -136,6 +152,21 @@ export async function fetchPageText(rawUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithBrowserless(url, signal) {
|
||||
try {
|
||||
const response = await fetch(`${BROWSERLESS_URL}/content`, {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: url.toString(), gotoOptions: { waitUntil: "networkidle2", timeout: TIMEOUT_MS } }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Browserless returned HTTP ${response.status}.`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw withCode(new Error(`Browser fallback failed for ${url}: ${error.message}`), "fetch_failed");
|
||||
}
|
||||
}
|
||||
|
||||
function withCode(err, code) {
|
||||
err.code = code;
|
||||
return err;
|
||||
|
||||
+9
-74
@@ -1,77 +1,12 @@
|
||||
import express from "express";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fetchPageText } from "./fetch-page.js";
|
||||
import { runPrefill } from "./prefill.js";
|
||||
import { parseAlog } from "./alog.js";
|
||||
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
|
||||
import { createDb, migrate } from "./db.js";
|
||||
import { createApp } from "./app.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.join(__dirname, "..");
|
||||
const PORT = Number(process.env.PORT) || 8090;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "2mb" }));
|
||||
|
||||
app.use(express.static(path.join(ROOT, "public")));
|
||||
app.use("/shared", express.static(path.join(ROOT, "shared")));
|
||||
|
||||
app.post("/api/prefill", async (req, res) => {
|
||||
const url = typeof req.body?.url === "string" ? req.body.url.trim() : "";
|
||||
if (!url) return res.status(400).json({ ok: false, code: "bad_url", error: "Missing url." });
|
||||
|
||||
let page;
|
||||
try {
|
||||
page = await fetchPageText(url);
|
||||
} catch (err) {
|
||||
const code = err.code ?? "fetch_failed";
|
||||
const status = code === "fetch_timeout" ? 504 : code === "bad_url" ? 400 : 502;
|
||||
return res.status(status).json({ ok: false, code, error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runPrefill(page);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
console.error("prefill failed:", err);
|
||||
const code = err.code ?? "prefill_failed";
|
||||
const status = code === "no_model" ? 503 : code === "unparseable_model_output" ? 422 : 500;
|
||||
res.status(status).json({ ok: false, code, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/alog", (req, res) => {
|
||||
const content = req.body?.content;
|
||||
if (typeof content !== "string" || content.trim() === "") {
|
||||
return res.status(400).json({ ok: false, code: "bad_request", error: "Missing .alog file content." });
|
||||
}
|
||||
try {
|
||||
const result = parseAlog(content, req.body?.filename ?? "upload.alog");
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
console.error("alog parse failed:", err);
|
||||
res.status(422).json({ ok: false, code: "unparseable_alog", error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/alog/library", async (_req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, files: await listAlogLibrary() });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, code: "library_failed", error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/alog/library/:filename", async (req, res) => {
|
||||
try {
|
||||
const result = await readAlogFromLibrary(req.params.filename);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
const status = err.code === "not_found" ? 404 : err.code === "bad_filename" ? 400 : 422;
|
||||
res.status(status).json({ ok: false, code: err.code ?? "library_read_failed", error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Roast planner webapp listening on http://localhost:${PORT}`);
|
||||
});
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const port = Number(process.env.PORT) || 8090;
|
||||
const db = createDb();
|
||||
await migrate(db);
|
||||
createApp({ db, root }).listen(port, () =>
|
||||
console.log(`Roast planner listening on ${port}`),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Sends transactional email via SMTP when configured; the caller decides what to do when this
|
||||
* returns false (e.g. log the link so an admin can hand it to the user manually). */
|
||||
export async function sendMail({ smtpUrl, from, to, subject, text }) {
|
||||
if (!smtpUrl) return false;
|
||||
const { default: nodemailer } = await import("nodemailer");
|
||||
const transporter = nodemailer.createTransport(smtpUrl);
|
||||
await transporter.sendMail({ from, to, subject, text });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Browser-safe ESM (served at /shared/, imported by both server and client — no Node-only
|
||||
// APIs) — the single implementation of cupping scoring/validation. The server always
|
||||
// recomputes the total from coerceSession's output; a client-submitted total is never trusted.
|
||||
//
|
||||
// FLAVOR_TAXONOMY is an ORIGINAL taxonomy for this project: the family/subgroup/descriptor
|
||||
// list and id strings are our own, deliberately not a transcription of the copyrighted
|
||||
// SCA/WCR Coffee Taster's Flavor Wheel graphic or its exact category boundaries/wording.
|
||||
|
||||
export const SCORE_ATTRS = [
|
||||
"fragrance_aroma",
|
||||
"flavor",
|
||||
"aftertaste",
|
||||
"acidity",
|
||||
"body",
|
||||
"balance",
|
||||
"overall",
|
||||
];
|
||||
|
||||
export const SCORE_LABELS = {
|
||||
fragrance_aroma: "Fragrance/Aroma",
|
||||
flavor: "Flavor",
|
||||
aftertaste: "Aftertaste",
|
||||
acidity: "Acidity",
|
||||
body: "Body",
|
||||
balance: "Balance",
|
||||
overall: "Overall",
|
||||
};
|
||||
|
||||
export const TICK_ATTRS = ["uniformity", "clean_cup", "sweetness"];
|
||||
|
||||
export const TICK_LABELS = {
|
||||
uniformity: "Uniformity",
|
||||
clean_cup: "Clean cup",
|
||||
sweetness: "Sweetness",
|
||||
};
|
||||
|
||||
export const STAGE_IDS = [
|
||||
"dry_fragrance",
|
||||
"pour",
|
||||
"crust_aroma",
|
||||
"break",
|
||||
"skim",
|
||||
"taste_1",
|
||||
"taste_2",
|
||||
"taste_3",
|
||||
];
|
||||
|
||||
const STAGE_ORDER = Object.fromEntries(STAGE_IDS.map((s, i) => [s, i]));
|
||||
|
||||
export const DEFAULT_CUP_COUNT = 5;
|
||||
export const MAX_CUP_COUNT = 12;
|
||||
export const MAX_FLAVOR_TAGS = 32;
|
||||
export const MAX_NOTES_CHARS = 4000;
|
||||
const MAX_STAGE_ELAPSED_SEC = 24 * 60 * 60;
|
||||
|
||||
// 1-3 dot-separated segments, lowercase alnum(+underscore) in EVERY segment — three family
|
||||
// ids (nutty_cocoa, green_vegetal, fermented_sour) have an underscore in the first segment.
|
||||
const FLAVOR_ID_RE = /^[a-z0-9_]+(\.[a-z0-9_]+){0,2}$/;
|
||||
|
||||
export const FLAVOR_TAXONOMY = {
|
||||
fruity: {
|
||||
label: "Fruity",
|
||||
subgroups: {
|
||||
berry: ["blueberry", "blackberry", "strawberry"],
|
||||
citrus: ["orange", "lemon", "grapefruit"],
|
||||
stone_fruit: ["peach", "apricot", "cherry"],
|
||||
dried_fruit: ["raisin", "fig", "prune"],
|
||||
},
|
||||
},
|
||||
floral: {
|
||||
label: "Floral",
|
||||
subgroups: {
|
||||
blossom: ["jasmine", "orange_blossom", "chamomile"],
|
||||
herbal_floral: ["lavender", "rose"],
|
||||
},
|
||||
},
|
||||
sweet: {
|
||||
label: "Sweet",
|
||||
subgroups: {
|
||||
sugars: ["brown_sugar", "honey", "caramel"],
|
||||
vanilla: ["vanilla", "malt"],
|
||||
},
|
||||
},
|
||||
nutty_cocoa: {
|
||||
label: "Nutty / Cocoa",
|
||||
subgroups: {
|
||||
nutty: ["almond", "hazelnut", "peanut"],
|
||||
cocoa: ["dark_chocolate", "cocoa_powder"],
|
||||
},
|
||||
},
|
||||
spice: {
|
||||
label: "Spice",
|
||||
subgroups: {
|
||||
warm_spice: ["cinnamon", "clove", "nutmeg"],
|
||||
pungent: ["pepper", "anise"],
|
||||
},
|
||||
},
|
||||
roasted: {
|
||||
label: "Roasted",
|
||||
subgroups: {
|
||||
grain: ["toast", "cereal"],
|
||||
char: ["smoky", "tobacco", "pipe_tobacco"],
|
||||
},
|
||||
},
|
||||
green_vegetal: {
|
||||
label: "Green / Vegetal",
|
||||
subgroups: {
|
||||
fresh: ["cut_grass", "leafy", "herbaceous"],
|
||||
raw: ["beany", "peapod"],
|
||||
},
|
||||
},
|
||||
fermented_sour: {
|
||||
label: "Fermented / Sour",
|
||||
subgroups: {
|
||||
sour: ["citric", "acetic", "tart"],
|
||||
fermented: ["winey", "boozy", "overripe"],
|
||||
},
|
||||
},
|
||||
other: {
|
||||
label: "Other",
|
||||
subgroups: {
|
||||
chemical: ["rubber", "medicinal"],
|
||||
papery: ["papery", "musty", "woody"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const snapQuarter = (v) => Math.round(v * 4) / 4;
|
||||
|
||||
export function blankSession(cupCount = DEFAULT_CUP_COUNT) {
|
||||
return {
|
||||
cup_count: cupCount,
|
||||
scores: Object.fromEntries(SCORE_ATTRS.map((a) => [a, 0])),
|
||||
ticks: Object.fromEntries(TICK_ATTRS.map((a) => [a, 0])),
|
||||
taint_cups: 0,
|
||||
fault_cups: 0,
|
||||
flavor_tags: [],
|
||||
stage_marks: [],
|
||||
ritual_started_at_iso: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** total = sum(scored attrs) + 10*ticks/cupCount per tick attr - 2*taint - 4*fault, floored
|
||||
* at 0, rounded to 2dp. Pure arithmetic over whatever it's handed — call coerceSession first. */
|
||||
export function computeTotalScore(scores, ticks, taintCups, faultCups, cupCount) {
|
||||
let total = SCORE_ATTRS.reduce((sum, a) => sum + Number(scores?.[a] ?? 0), 0);
|
||||
const cc = Number(cupCount);
|
||||
if (cc > 0)
|
||||
total += TICK_ATTRS.reduce((sum, a) => sum + (10 * Number(ticks?.[a] ?? 0)) / cc, 0);
|
||||
total -= 2 * Number(taintCups ?? 0);
|
||||
total -= 4 * Number(faultCups ?? 0);
|
||||
total = Math.max(0, total);
|
||||
return Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
/** Validates and clamps a session document. Throws Error with a human message on genuinely
|
||||
* bad input (malformed flavor id, unknown stage, too many tags); everything else is clamped. */
|
||||
export function coerceSession(session) {
|
||||
const raw = session && typeof session === "object" ? session : {};
|
||||
|
||||
// Tolerant of a missing cup_count (defaults it), same stance as every other field below —
|
||||
// but a *present and garbage* value (NaN, a string, etc.) is a genuine client bug, not an
|
||||
// absent field, so that still throws rather than silently defaulting.
|
||||
const cupCountRaw = raw.cup_count == null ? DEFAULT_CUP_COUNT : Number(raw.cup_count);
|
||||
if (!Number.isFinite(cupCountRaw)) throw new Error("cup_count must be a finite number");
|
||||
const cup_count = Math.max(1, Math.min(MAX_CUP_COUNT, Math.round(cupCountRaw)));
|
||||
|
||||
const scores = {};
|
||||
for (const attr of SCORE_ATTRS) {
|
||||
const f = Number(raw.scores?.[attr] ?? 0);
|
||||
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
|
||||
scores[attr] = f <= 0 ? 0 : Math.round(snapQuarter(Math.max(6, Math.min(10, f))) * 100) / 100;
|
||||
}
|
||||
|
||||
const ticks = {};
|
||||
for (const attr of TICK_ATTRS) {
|
||||
const f = Number(raw.ticks?.[attr] ?? 0);
|
||||
if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
|
||||
ticks[attr] = Math.max(0, Math.min(cup_count, Math.round(f)));
|
||||
}
|
||||
|
||||
const taintRaw = Number(raw.taint_cups ?? 0);
|
||||
if (!Number.isFinite(taintRaw)) throw new Error("taint_cups must be a finite number");
|
||||
const taint_cups = Math.max(0, Math.min(cup_count, Math.round(taintRaw)));
|
||||
|
||||
const faultRaw = Number(raw.fault_cups ?? 0);
|
||||
if (!Number.isFinite(faultRaw)) throw new Error("fault_cups must be a finite number");
|
||||
const fault_cups = Math.max(0, Math.min(cup_count, Math.round(faultRaw)));
|
||||
|
||||
const notes = String(raw.notes ?? "").slice(0, MAX_NOTES_CHARS);
|
||||
|
||||
const flavor_tags = [];
|
||||
const seen = new Set();
|
||||
for (const tag of Array.isArray(raw.flavor_tags) ? raw.flavor_tags : []) {
|
||||
if (typeof tag !== "string" || !FLAVOR_ID_RE.test(tag))
|
||||
throw new Error(`invalid flavor tag id: ${JSON.stringify(tag)}`);
|
||||
if (seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
flavor_tags.push(tag);
|
||||
}
|
||||
if (flavor_tags.length > MAX_FLAVOR_TAGS)
|
||||
throw new Error(
|
||||
`at most ${MAX_FLAVOR_TAGS} flavor tags allowed, got ${flavor_tags.length} (after de-duplication)`,
|
||||
);
|
||||
|
||||
const markByStage = {};
|
||||
for (const mark of Array.isArray(raw.stage_marks) ? raw.stage_marks : []) {
|
||||
const stage = mark?.stage;
|
||||
// Object.hasOwn, not `in` — `in` walks the prototype chain, so a stage value of
|
||||
// "toString"/"constructor"/etc. would pass validation and later corrupt the sort below
|
||||
// (STAGE_ORDER["toString"] is a function, not a number).
|
||||
if (!Object.hasOwn(STAGE_ORDER, stage))
|
||||
throw new Error(`unknown cupping stage: ${JSON.stringify(stage)}`);
|
||||
const elapsedRaw = Number(mark.elapsed_sec);
|
||||
if (!Number.isFinite(elapsedRaw)) throw new Error("elapsed_sec must be a finite number");
|
||||
const elapsed_sec = Math.max(0, Math.min(MAX_STAGE_ELAPSED_SEC, elapsedRaw));
|
||||
markByStage[stage] = {
|
||||
stage,
|
||||
elapsed_sec,
|
||||
marked_at_iso: String(mark.marked_at_iso ?? ""),
|
||||
};
|
||||
}
|
||||
const stage_marks = Object.values(markByStage).sort(
|
||||
(a, b) => STAGE_ORDER[a.stage] - STAGE_ORDER[b.stage],
|
||||
);
|
||||
|
||||
return {
|
||||
cup_count,
|
||||
scores,
|
||||
ticks,
|
||||
taint_cups,
|
||||
fault_cups,
|
||||
notes,
|
||||
flavor_tags,
|
||||
stage_marks,
|
||||
ritual_started_at_iso: String(raw.ritual_started_at_iso ?? ""),
|
||||
};
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function blankPlan() {
|
||||
sanityOverride: { drying: null, maillard: null, dtr: null, ceiling: null },
|
||||
reference: null,
|
||||
prefill: null,
|
||||
inventory: { lotId: "", lotLabel: "", consumed: null },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../server/app.js";
|
||||
import { root, password, setup, signup } from "./helpers.js";
|
||||
|
||||
test("strict CSP/static modules, no-store data, auth lifecycle, and ownership share one database", async () => {
|
||||
const { db, app, agent: first } = await setup();
|
||||
const second = request.agent(app);
|
||||
const anonymous = request.agent(app);
|
||||
|
||||
const marketing = await anonymous.get("/");
|
||||
assert.equal(marketing.status, 200);
|
||||
assert.match(
|
||||
marketing.headers["content-security-policy"],
|
||||
/default-src 'self'/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
marketing.headers["content-security-policy"],
|
||||
/(?:default-src|script-src)[^;]*unsafe-inline/,
|
||||
);
|
||||
assert.match(marketing.text, /Get started/);
|
||||
|
||||
const login = await anonymous.get("/login");
|
||||
assert.equal(login.status, 200);
|
||||
assert.match(
|
||||
login.text,
|
||||
/<script type="module" src="\/js\/login\.js"><\/script>/,
|
||||
);
|
||||
assert.equal((await anonymous.get("/signup")).status, 200);
|
||||
assert.equal((await anonymous.get("/forgot")).status, 200);
|
||||
assert.equal((await anonymous.get("/reset")).status, 200);
|
||||
assert.equal((await anonymous.get("/api/auth/signup-enabled")).body.enabled, true);
|
||||
|
||||
// Page routes redirect a signed-out browser navigation to /login (never a bare JSON 401 —
|
||||
// that's only for /api/* callers) but the API underneath stays strictly 401/no-store.
|
||||
const adminHtml = await anonymous.get("/admin");
|
||||
assert.equal(adminHtml.status, 302);
|
||||
assert.equal(adminHtml.headers.location, "/login");
|
||||
assert.equal(adminHtml.headers["cache-control"], "no-store, private");
|
||||
const accountHtml = await anonymous.get("/account");
|
||||
assert.equal(accountHtml.status, 302);
|
||||
assert.equal(accountHtml.headers.location, "/login");
|
||||
const appHtml = await anonymous.get("/app");
|
||||
assert.equal(appHtml.status, 302);
|
||||
assert.equal(appHtml.headers.location, "/login");
|
||||
assert.equal((await anonymous.get("/api/plans")).status, 401);
|
||||
assert.equal(
|
||||
(await anonymous.get("/api/plans")).headers["cache-control"],
|
||||
"no-store, private",
|
||||
);
|
||||
// Protected HTML shells are never reachable by static filename, only through their routes —
|
||||
// including percent-encoded and repeated-slash variants that bypass an undecoded string
|
||||
// comparison but still resolve to the same file once express.static decodes them.
|
||||
assert.equal((await anonymous.get("/index.html")).status, 302);
|
||||
assert.equal((await anonymous.get("/admin.html")).status, 302);
|
||||
assert.equal((await anonymous.get("/account.html")).status, 302);
|
||||
assert.equal((await anonymous.get("/%69ndex.html")).status, 302);
|
||||
assert.equal((await anonymous.get("//index.html")).status, 302);
|
||||
|
||||
assert.match(
|
||||
(await anonymous.get("/js/admin.js")).text,
|
||||
/loadNavUser/,
|
||||
);
|
||||
const mainScript = await anonymous.get("/js/main.js");
|
||||
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
|
||||
assert.match(mainScript.text, /localStorage\.removeItem\(key\)/);
|
||||
const serviceWorker = (await anonymous.get("/sw.js")).text;
|
||||
assert.match(serviceWorker, /data-free authenticated shell/);
|
||||
assert.match(
|
||||
serviceWorker,
|
||||
/if \(url\.pathname !== "\/app"\) return;[\s\S]*caches\.match\("\/app"\)/,
|
||||
);
|
||||
assert.match(serviceWorker, /logout clears that namespace/);
|
||||
|
||||
const one = await signup(first, "[email protected]");
|
||||
const two = await signup(second, "[email protected]");
|
||||
assert.equal(one.response.status, 201);
|
||||
assert.equal(two.response.status, 201);
|
||||
|
||||
// A signed-in visitor is bounced off the public auth pages straight to /app.
|
||||
assert.equal((await first.get("/login")).status, 302);
|
||||
assert.equal((await first.get("/")).status, 302);
|
||||
|
||||
const plan = await first
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", one.csrf)
|
||||
.send({ plan: { fields: { 0.1: "Private" } } });
|
||||
assert.equal(plan.status, 201);
|
||||
assert.equal(
|
||||
(await first.get("/api/plans")).headers["cache-control"],
|
||||
"no-store, private",
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await second
|
||||
.put(`/api/plans/${plan.body.plan.id}`)
|
||||
.set("x-csrf-token", two.csrf)
|
||||
.send({ plan: {} })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
// A non-UUID id is a clean 404, not a 500 from the database driver.
|
||||
assert.equal(
|
||||
(
|
||||
await first
|
||||
.put("/api/plans/not-a-uuid")
|
||||
.set("x-csrf-token", one.csrf)
|
||||
.send({ plan: {} })
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
// PUT validates the plan body just like POST does.
|
||||
assert.equal(
|
||||
(
|
||||
await first
|
||||
.put(`/api/plans/${plan.body.plan.id}`)
|
||||
.set("x-csrf-token", one.csrf)
|
||||
.send({ plan: null })
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
|
||||
// A signed-in non-admin visiting /admin lands back in the app, not a bare 403 JSON page.
|
||||
const nonAdminVisitsAdmin = await second.get("/admin");
|
||||
assert.equal(nonAdminVisitsAdmin.status, 302);
|
||||
assert.equal(nonAdminVisitsAdmin.headers.location, "/app");
|
||||
assert.equal(
|
||||
(
|
||||
await second
|
||||
.delete(`/api/plans/${plan.body.plan.id}`)
|
||||
.set("x-csrf-token", two.csrf)
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await first
|
||||
.delete(`/api/plans/${plan.body.plan.id}`)
|
||||
.set("x-csrf-token", one.csrf)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await first.get("/api/plans")).body.plans.length, 0);
|
||||
|
||||
const loginAgent = request.agent(app);
|
||||
assert.equal(
|
||||
(
|
||||
await loginAgent
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await loginAgent.get("/api/auth/me")).status, 200);
|
||||
// Logout succeeds even without a valid CSRF token (it can only end the caller's own session).
|
||||
assert.equal((await loginAgent.post("/api/auth/logout")).status, 200);
|
||||
assert.equal((await loginAgent.get("/api/auth/me")).status, 401);
|
||||
|
||||
const admin = await first.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
assert.equal(admin.status, 201);
|
||||
assert.equal((await first.get("/api/admin/users")).status, 200);
|
||||
assert.equal((await second.get("/api/admin/users")).status, 403);
|
||||
assert.equal(
|
||||
(
|
||||
await first
|
||||
.put("/api/admin/signup-enabled")
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ enabled: false })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await anonymous
|
||||
.post("/api/auth/signup")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await db.query("SELECT count(*)::int AS count FROM users")).rows[0].count,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("bootstrap token is optional after first setup and unavailable before setup without one", async () => {
|
||||
const { app, db } = await setup();
|
||||
const agent = request.agent(app);
|
||||
assert.equal(
|
||||
(
|
||||
await agent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "wrong",
|
||||
})
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
const noTokenApp = createApp({ db, root, env: { NODE_ENV: "test" } });
|
||||
assert.equal(
|
||||
(
|
||||
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
})
|
||||
).status,
|
||||
503,
|
||||
);
|
||||
await db.query(
|
||||
"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin')",
|
||||
["[email protected]", "not-used-in-this-test"],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(noTokenApp).post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
});
|
||||
|
||||
test("/setup redirects to /login once the administrator exists", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
assert.equal((await agent.get("/setup")).status, 200);
|
||||
await agent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
const fresh = request.agent(app);
|
||||
const setupResponse = await fresh.get("/setup");
|
||||
assert.equal(setupResponse.status, 302);
|
||||
assert.equal(setupResponse.headers.location, "/login");
|
||||
});
|
||||
|
||||
test("forgot/reset password issues a working link without leaking account existence", async () => {
|
||||
const { app } = await setup();
|
||||
const memberAgent = request.agent(app);
|
||||
await signup(memberAgent, "[email protected]");
|
||||
const admin = request.agent(app);
|
||||
await admin.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
|
||||
const unknown = await request(app)
|
||||
.post("/api/auth/forgot")
|
||||
.send({ email: "[email protected]" });
|
||||
assert.equal(unknown.status, 200);
|
||||
const known = await request(app)
|
||||
.post("/api/auth/forgot")
|
||||
.send({ email: "[email protected]" });
|
||||
assert.equal(known.status, 200);
|
||||
assert.deepEqual(known.body, unknown.body);
|
||||
|
||||
const pending = await admin.get("/api/admin/password-resets");
|
||||
assert.equal(pending.status, 200);
|
||||
const entry = pending.body.links.find((l) => l.email === "[email protected]");
|
||||
assert.ok(entry, "expected a pending reset link for [email protected]");
|
||||
const token = new URL(entry.url, "http://x").searchParams.get("token");
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/reset")
|
||||
.send({ token: "wrong-token", password: "another long password" })
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const resetResponse = await request(app)
|
||||
.post("/api/auth/reset")
|
||||
.send({ token, password: "another long password" });
|
||||
assert.equal(resetResponse.status, 200);
|
||||
// The old password no longer works; the new one does.
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password: "another long password" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("account: email change, password change, sessions, and self-delete", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/account/email")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ email: "[email protected]", password: "wrong password wrong" })
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/account/email")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
const sessionsBefore = await agent.get("/api/account/sessions");
|
||||
assert.equal(sessionsBefore.body.sessions.length, 1);
|
||||
assert.equal(sessionsBefore.body.sessions[0].current, true);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.put("/api/account/password")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ currentPassword: password, newPassword: "yet another long one" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
// Logging in with the old password now fails; the new one works.
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
const relogin = request.agent(app);
|
||||
assert.equal(
|
||||
(
|
||||
await relogin
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password: "yet another long one" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.delete("/api/account")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ password: "yet another long one" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password: "yet another long one" })
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
test("the bootstrap admin's email is reserved and fixed, and the bootstrap admin cannot delete themself even as a second admin exists", async () => {
|
||||
const { app } = await setup();
|
||||
|
||||
// Nobody can claim the reserved email via plain signup before bootstrap ever runs.
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/signup")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
|
||||
const bootstrapAgent = request.agent(app);
|
||||
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
assert.equal(admin.status, 201);
|
||||
|
||||
// Someone else still can't claim it after bootstrap (this would also just 409 on the
|
||||
// column's UNIQUE constraint, but the reserved-email check must fire first regardless).
|
||||
const otherAgent = request.agent(app);
|
||||
const { csrf: otherCsrf } = await signup(otherAgent, "[email protected]");
|
||||
assert.equal(
|
||||
(
|
||||
await otherAgent
|
||||
.put("/api/account/email")
|
||||
.set("x-csrf-token", otherCsrf)
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
|
||||
// The bootstrap admin can't change away from the reserved email either — that would strip
|
||||
// every other guard's protection for this account.
|
||||
assert.equal(
|
||||
(
|
||||
await bootstrapAgent
|
||||
.put("/api/account/email")
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
|
||||
// Promote a second admin, then confirm the bootstrap admin still can't delete themself even
|
||||
// though the "last admin" count check alone would otherwise allow it.
|
||||
const secondAgent = request.agent(app);
|
||||
await signup(secondAgent, "[email protected]");
|
||||
const secondId = (
|
||||
await bootstrapAgent.get("/api/admin/users")
|
||||
).body.users.find((u) => u.email === "[email protected]").id;
|
||||
await bootstrapAgent
|
||||
.put(`/api/admin/users/${secondId}/role`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ role: "admin" });
|
||||
assert.equal(
|
||||
(
|
||||
await bootstrapAgent
|
||||
.delete("/api/account")
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ password })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await bootstrapAgent.get("/api/auth/me")).status, 200);
|
||||
});
|
||||
|
||||
test("password-reset links for admin accounts never appear in the shared admin panel", async () => {
|
||||
const { app } = await setup();
|
||||
const bootstrapAgent = request.agent(app);
|
||||
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
const secondAgent = request.agent(app);
|
||||
await signup(secondAgent, "[email protected]");
|
||||
const secondId = (
|
||||
await bootstrapAgent.get("/api/admin/users")
|
||||
).body.users.find((u) => u.email === "[email protected]").id;
|
||||
await bootstrapAgent
|
||||
.put(`/api/admin/users/${secondId}/role`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ role: "admin" });
|
||||
|
||||
// The second admin requests a reset for the bootstrap admin's own account...
|
||||
await request(app)
|
||||
.post("/api/auth/forgot")
|
||||
.send({ email: "[email protected]" });
|
||||
// ...but that link must never surface in the panel any admin (including this one) can read —
|
||||
// otherwise a second admin could take over the account every other guard protects.
|
||||
const pending = await secondAgent.get("/api/admin/password-resets");
|
||||
assert.equal(pending.status, 200);
|
||||
assert.equal(
|
||||
pending.body.links.some((l) => l.email === "[email protected]"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("admin: metrics, role changes, disable, delete, and audit trail", async () => {
|
||||
const { app } = await setup();
|
||||
const adminAgent = request.agent(app);
|
||||
const admin = await adminAgent.post("/api/auth/bootstrap").send({
|
||||
email: "[email protected]",
|
||||
password,
|
||||
setupToken: "a-secure-bootstrap-token",
|
||||
});
|
||||
const userAgent = request.agent(app);
|
||||
await signup(userAgent, "[email protected]");
|
||||
const userId = (
|
||||
await adminAgent.get("/api/admin/users")
|
||||
).body.users.find((u) => u.email === "[email protected]").id;
|
||||
|
||||
const metrics = await adminAgent.get("/api/admin/metrics");
|
||||
assert.equal(metrics.status, 200);
|
||||
assert.equal(metrics.body.metrics.totalUsers, 2);
|
||||
|
||||
// The bootstrap admin cannot be demoted, disabled, or deleted by another admin, or by itself.
|
||||
const bootstrapId = (
|
||||
await adminAgent.get("/api/admin/users")
|
||||
).body.users.find((u) => u.email === "[email protected]").id;
|
||||
assert.equal(
|
||||
(
|
||||
await adminAgent
|
||||
.put(`/api/admin/users/${bootstrapId}/role`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ role: "user" })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await adminAgent
|
||||
.put(`/api/admin/users/${userId}/role`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ role: "admin" })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await adminAgent
|
||||
.put(`/api/admin/users/${userId}/disabled`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
.send({ disabled: true })
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
// A disabled user's existing session stops working and cannot log back in.
|
||||
assert.equal((await userAgent.get("/api/auth/me")).status, 401);
|
||||
assert.equal(
|
||||
(
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.send({ email: "[email protected]", password })
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
|
||||
const audit = await adminAgent.get("/api/admin/audit");
|
||||
assert.equal(audit.status, 200);
|
||||
assert.ok(audit.body.events.some((e) => e.action === "role_changed"));
|
||||
assert.ok(audit.body.events.some((e) => e.action === "user_disabled"));
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await adminAgent
|
||||
.delete(`/api/admin/users/${userId}`)
|
||||
.set("x-csrf-token", admin.body.csrfToken)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await adminAgent.get("/api/admin/metrics")).body.metrics.totalUsers, 1);
|
||||
});
|
||||
|
||||
test("login is locked out per-email after repeated failures, independent of source IP", async () => {
|
||||
// Uses distinct simulated client IPs (via a trust-proxy app instance) so the assertion
|
||||
// isolates the per-email lockout from the separate per-IP rate limiter.
|
||||
const { db } = await setup();
|
||||
const app = createApp({
|
||||
db,
|
||||
root,
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
|
||||
TRUST_PROXY: true,
|
||||
},
|
||||
});
|
||||
await signup(request.agent(app), "[email protected]");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app)
|
||||
.post("/api/auth/login")
|
||||
.set("X-Forwarded-For", `10.0.0.${i}`)
|
||||
.send({ email: "[email protected]", password: "wrong password wrong" });
|
||||
}
|
||||
// Further wrong guesses are locked out...
|
||||
const stillWrong = await request(app)
|
||||
.post("/api/auth/login")
|
||||
.set("X-Forwarded-For", "10.0.0.99")
|
||||
.send({ email: "[email protected]", password: "still not it either" });
|
||||
assert.equal(stillWrong.status, 429);
|
||||
assert.equal(stillWrong.body.code, "too_many_attempts");
|
||||
// ...but the lock never blocks the real owner: the correct password always gets them in,
|
||||
// so the lockout can only ever throttle guessing, never be weaponized to deny the owner.
|
||||
const legitimateLogin = await request(app)
|
||||
.post("/api/auth/login")
|
||||
.set("X-Forwarded-For", "10.0.0.100")
|
||||
.send({ email: "[email protected]", password });
|
||||
assert.equal(legitimateLogin.status, 200);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
async function dockerAvailable() {
|
||||
try {
|
||||
await execFileAsync("docker", ["info"], { timeout: 15_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test("Docker image starts against PostgreSQL and applies migrations", async (t) => {
|
||||
if (!(await dockerAvailable())) {
|
||||
t.skip("Docker daemon is unavailable");
|
||||
return;
|
||||
}
|
||||
const temp = await mkdtemp(
|
||||
path.join(os.tmpdir(), "roast-planner-container-"),
|
||||
);
|
||||
const agentDir = path.join(temp, "pi-agent");
|
||||
await mkdir(agentDir);
|
||||
const envFile = path.join(temp, "compose.env");
|
||||
await writeFile(
|
||||
envFile,
|
||||
`POSTGRES_PASSWORD=container-test-password\nBOOTSTRAP_SETUP_TOKEN=\nPI_AGENT_CONFIG_DIR=${agentDir}\n`,
|
||||
);
|
||||
const project = `roastplanner${Date.now()}`;
|
||||
const compose = (args, options = {}) =>
|
||||
execFileAsync(
|
||||
"docker",
|
||||
["compose", "--project-name", project, "--env-file", envFile, ...args],
|
||||
{ cwd: root, timeout: 120_000, ...options },
|
||||
);
|
||||
t.after(async () => {
|
||||
try {
|
||||
await compose(["down", "--volumes", "--remove-orphans"]);
|
||||
} catch {
|
||||
// Preserve the startup failure rather than masking it with cleanup.
|
||||
}
|
||||
});
|
||||
await compose(["up", "--build", "--detach"]);
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
try {
|
||||
await compose([
|
||||
"exec",
|
||||
"-T",
|
||||
"app",
|
||||
"node",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
"const response = await fetch('http://127.0.0.1:8090/'); process.exit(response.ok ? 0 : 1)",
|
||||
]);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
lastError,
|
||||
null,
|
||||
"application never became ready in its container",
|
||||
);
|
||||
const migration = await compose([
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"psql",
|
||||
"-U",
|
||||
"roast",
|
||||
"-d",
|
||||
"roast",
|
||||
"-tAc",
|
||||
"SELECT count(*) FROM schema_migrations WHERE filename = '001_auth.sql'",
|
||||
]);
|
||||
assert.equal(migration.stdout.trim(), "1");
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup } from "./helpers.js";
|
||||
import { computeTotalScore, SCORE_ATTRS, TICK_ATTRS } from "../shared/cupping.js";
|
||||
|
||||
async function bootstrapPlan(agent, csrf) {
|
||||
const r = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "Test coffee" } } });
|
||||
return r.body.plan.id;
|
||||
}
|
||||
|
||||
test("cupping: auth and CSRF are required on every route", async () => {
|
||||
const { app } = await setup();
|
||||
const anon = request.agent(app);
|
||||
assert.equal((await anon.get("/api/cupping")).status, 401);
|
||||
assert.equal((await anon.post("/api/cupping").send({})).status, 401);
|
||||
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
assert.equal((await agent.post("/api/cupping").send({})).status, 403);
|
||||
const created = await agent.post("/api/cupping").set("x-csrf-token", csrf).send({});
|
||||
assert.equal(created.status, 201);
|
||||
assert.equal(
|
||||
(await agent.put(`/api/cupping/${created.body.session.id}`).send({ data: {} })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await agent.delete(`/api/cupping/${created.body.session.id}`)).status, 403);
|
||||
});
|
||||
|
||||
test("cupping: create (with and without a linked plan), list, and ownership isolation", async () => {
|
||||
const { app } = await setup();
|
||||
const first = request.agent(app);
|
||||
const second = request.agent(app);
|
||||
const { csrf: firstCsrf } = await signup(first, "[email protected]");
|
||||
await signup(second, "[email protected]");
|
||||
const planId = await bootstrapPlan(first, firstCsrf);
|
||||
|
||||
const linked = await first
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", firstCsrf)
|
||||
.send({ roastPlanId: planId, cupCount: 4 });
|
||||
assert.equal(linked.status, 201);
|
||||
assert.equal(linked.body.session.roastPlanId, planId);
|
||||
assert.equal(linked.body.session.data.cup_count, 4);
|
||||
assert.equal(linked.body.session.totalScore, 0);
|
||||
|
||||
const unlinked = await first.post("/api/cupping").set("x-csrf-token", firstCsrf).send({});
|
||||
assert.equal(unlinked.status, 201);
|
||||
assert.equal(unlinked.body.session.roastPlanId, null);
|
||||
|
||||
assert.equal((await first.get("/api/cupping")).body.sessions.length, 2);
|
||||
assert.equal((await second.get("/api/cupping")).body.sessions.length, 0);
|
||||
assert.equal((await second.get(`/api/cupping/${linked.body.session.id}`)).status, 404);
|
||||
|
||||
const filtered = await first.get(`/api/cupping?plan=${planId}`);
|
||||
assert.equal(filtered.body.sessions.length, 1);
|
||||
assert.equal(filtered.body.sessions[0].id, linked.body.session.id);
|
||||
});
|
||||
|
||||
test("cupping: creating against another user's plan is refused", async () => {
|
||||
const { app } = await setup();
|
||||
const owner = request.agent(app);
|
||||
const attacker = request.agent(app);
|
||||
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
|
||||
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
|
||||
const planId = await bootstrapPlan(owner, ownerCsrf);
|
||||
const attempt = await attacker
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", attackerCsrf)
|
||||
.send({ roastPlanId: planId });
|
||||
assert.equal(attempt.status, 404);
|
||||
});
|
||||
|
||||
test("cupping: server always recomputes the total score and ignores a client-supplied value", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (
|
||||
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 5 })
|
||||
).body.session;
|
||||
|
||||
const scores = Object.fromEntries(SCORE_ATTRS.map((a) => [a, 8]));
|
||||
const ticks = Object.fromEntries(TICK_ATTRS.map((a) => [a, 5]));
|
||||
const expected = computeTotalScore(scores, ticks, 1, 1, 5);
|
||||
|
||||
const saved = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({
|
||||
data: {
|
||||
cup_count: 5,
|
||||
scores,
|
||||
ticks,
|
||||
taint_cups: 1,
|
||||
fault_cups: 1,
|
||||
flavor_tags: ["fruity.berry.blueberry"],
|
||||
notes: "Bright and clean.",
|
||||
total_score: 999999, // must be discarded — the server recomputes it
|
||||
},
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.session.totalScore, expected);
|
||||
assert.notEqual(expected, 999999);
|
||||
|
||||
const reloaded = await agent.get(`/api/cupping/${session.id}`);
|
||||
assert.equal(reloaded.body.session.totalScore, expected);
|
||||
assert.deepEqual(reloaded.body.session.data.flavor_tags, ["fruity.berry.blueberry"]);
|
||||
});
|
||||
|
||||
test("cupping: coerceSession validation errors surface as 400s", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (await agent.post("/api/cupping").set("x-csrf-token", csrf).send({})).body
|
||||
.session;
|
||||
|
||||
const badFlavor = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: ["Not A Valid Id!"] } });
|
||||
assert.equal(badFlavor.status, 400);
|
||||
assert.equal(badFlavor.body.code, "bad_session");
|
||||
|
||||
const dedupedTags = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: Array.from({ length: 33 }, () => "other.chemical.rubber") } });
|
||||
// 33 identical tags dedupe to 1, so this specific input should NOT throw — verifies dedup
|
||||
// happens before the >32 check.
|
||||
assert.equal(dedupedTags.status, 200);
|
||||
assert.deepEqual(dedupedTags.body.session.data.flavor_tags, ["other.chemical.rubber"]);
|
||||
|
||||
// Build 33 genuinely distinct valid-shaped ids by walking the real taxonomy, to trip the
|
||||
// actual >32-after-dedup limit.
|
||||
const { FLAVOR_TAXONOMY } = await import("../shared/cupping.js");
|
||||
const manyValid = [];
|
||||
for (const [famId, fam] of Object.entries(FLAVOR_TAXONOMY)) {
|
||||
for (const [subId, descriptors] of Object.entries(fam.subgroups)) {
|
||||
for (const d of descriptors) manyValid.push(`${famId}.${subId}.${d}`);
|
||||
}
|
||||
}
|
||||
assert.ok(manyValid.length > 32);
|
||||
const overLimit = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { flavor_tags: manyValid } });
|
||||
assert.equal(overLimit.status, 400);
|
||||
|
||||
const badStage = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: { stage_marks: [{ stage: "not_a_real_stage", elapsed_sec: 1 }] } });
|
||||
assert.equal(badStage.status, 400);
|
||||
|
||||
const notObject = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ data: "nope" });
|
||||
assert.equal(notObject.status, 400);
|
||||
});
|
||||
|
||||
test("cupping: scores clamp/snap and ticks clamp to cup count", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const session = (
|
||||
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
|
||||
).body.session;
|
||||
|
||||
const saved = await agent
|
||||
.put(`/api/cupping/${session.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({
|
||||
data: {
|
||||
cup_count: 3,
|
||||
scores: { flavor: 11, acidity: 7.13, body: -1 },
|
||||
ticks: { uniformity: 99 },
|
||||
},
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.session.data.scores.flavor, 10);
|
||||
assert.equal(saved.body.session.data.scores.acidity, 7.25);
|
||||
assert.equal(saved.body.session.data.scores.body, 0);
|
||||
assert.equal(saved.body.session.data.ticks.uniformity, 3);
|
||||
});
|
||||
|
||||
test("cupping: delete removes the session, and deleting the linked plan nulls roastPlanId instead of deleting the session", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const planId = await bootstrapPlan(agent, csrf);
|
||||
const session = (
|
||||
await agent
|
||||
.post("/api/cupping")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ roastPlanId: planId })
|
||||
).body.session;
|
||||
|
||||
await agent.delete(`/api/plans/${planId}`).set("x-csrf-token", csrf);
|
||||
const afterPlanDelete = await agent.get(`/api/cupping/${session.id}`);
|
||||
assert.equal(afterPlanDelete.status, 200);
|
||||
assert.equal(afterPlanDelete.body.session.roastPlanId, null);
|
||||
|
||||
assert.equal(
|
||||
(await agent.delete(`/api/cupping/${session.id}`).set("x-csrf-token", csrf)).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import request from "supertest";
|
||||
import { newDb } from "pg-mem";
|
||||
import { createApp } from "../server/app.js";
|
||||
|
||||
export const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
export const password = "this is a long password";
|
||||
|
||||
export async function setup(env = {}) {
|
||||
const mem = newDb();
|
||||
mem.public.registerFunction({
|
||||
name: "gen_random_uuid",
|
||||
returns: "uuid",
|
||||
implementation: () => crypto.randomUUID(),
|
||||
impure: true,
|
||||
});
|
||||
const pg = mem.adapters.createPg();
|
||||
const db = new pg.Pool();
|
||||
await db.query(
|
||||
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz);
|
||||
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
|
||||
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
|
||||
INSERT INTO app_settings VALUES('signup_enabled','true');
|
||||
CREATE TABLE password_reset_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now());
|
||||
CREATE TABLE audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,action text NOT NULL,target text,created_at timestamptz DEFAULT now());
|
||||
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
|
||||
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
|
||||
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
|
||||
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now())`,
|
||||
);
|
||||
const app = createApp({
|
||||
db,
|
||||
root,
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
|
||||
...env,
|
||||
},
|
||||
});
|
||||
return { db, app, agent: request.agent(app) };
|
||||
}
|
||||
|
||||
export async function signup(agent, email) {
|
||||
const response = await agent.post("/api/auth/signup").send({ email, password });
|
||||
return { response, csrf: response.body.csrfToken };
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import request from "supertest";
|
||||
import { setup, signup } from "./helpers.js";
|
||||
|
||||
async function bootstrapPlan(agent, csrf) {
|
||||
const r = await agent
|
||||
.post("/api/plans")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ plan: { fields: { 0.1: "Test coffee" } } });
|
||||
return r.body.plan.id;
|
||||
}
|
||||
|
||||
test("inventory: auth and CSRF are required on every route", async () => {
|
||||
const { app } = await setup();
|
||||
const anon = request.agent(app);
|
||||
assert.equal((await anon.get("/api/inventory")).status, 401);
|
||||
assert.equal((await anon.post("/api/inventory").send({})).status, 401);
|
||||
const { agent, csrf } = await (async () => {
|
||||
const a = request.agent(app);
|
||||
const { csrf: c } = await signup(a, "[email protected]");
|
||||
return { agent: a, csrf: c };
|
||||
})();
|
||||
assert.equal(
|
||||
(await agent.post("/api/inventory").send({ origin: "X", initialWeightG: 100 })).status,
|
||||
403,
|
||||
);
|
||||
const created = await agent
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ origin: "Huila", initialWeightG: 1000 });
|
||||
assert.equal(created.status, 201);
|
||||
assert.equal(
|
||||
(await agent.put(`/api/inventory/${created.body.lot.id}`).send({ origin: "Y" })).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await agent.delete(`/api/inventory/${created.body.lot.id}`)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await agent
|
||||
.post(`/api/inventory/${created.body.lot.id}/consume`)
|
||||
.send({ weightG: 10 })
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
});
|
||||
|
||||
test("inventory: create, list, and ownership isolation", async () => {
|
||||
const { app } = await setup();
|
||||
const first = request.agent(app);
|
||||
const second = request.agent(app);
|
||||
const { csrf: firstCsrf } = await signup(first, "[email protected]");
|
||||
await signup(second, "[email protected]");
|
||||
|
||||
const bad = await first
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", firstCsrf)
|
||||
.send({ origin: "", initialWeightG: 100 });
|
||||
assert.equal(bad.status, 400);
|
||||
const badWeight = await first
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", firstCsrf)
|
||||
.send({ origin: "Huila", initialWeightG: -5 });
|
||||
assert.equal(badWeight.status, 400);
|
||||
|
||||
const created = await first
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", firstCsrf)
|
||||
.send({ origin: "Huila, Colombia", variety: "Caturra", initialWeightG: 2000 });
|
||||
assert.equal(created.status, 201);
|
||||
assert.equal(created.body.lot.remainingWeightG, 2000);
|
||||
|
||||
assert.equal((await first.get("/api/inventory")).body.lots.length, 1);
|
||||
assert.equal((await second.get("/api/inventory")).body.lots.length, 0);
|
||||
assert.equal((await second.get(`/api/inventory/${created.body.lot.id}`)).status, 404);
|
||||
});
|
||||
|
||||
test("inventory: consume decrements remaining, allows negative, is idempotent per plan, and logs", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const planId = await bootstrapPlan(agent, csrf);
|
||||
const lot = (
|
||||
await agent
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ origin: "Huila", initialWeightG: 300 })
|
||||
).body.lot;
|
||||
|
||||
const first = await agent
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ weightG: 250, roastPlanId: planId });
|
||||
assert.equal(first.status, 201);
|
||||
assert.equal(first.body.lot.remainingWeightG, 50);
|
||||
|
||||
// A second draw against the SAME plan is rejected — one draw-down per roast plan, ever.
|
||||
const dupe = await agent
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ weightG: 10, roastPlanId: planId });
|
||||
assert.equal(dupe.status, 409);
|
||||
assert.equal(dupe.body.code, "already_consumed");
|
||||
assert.equal(
|
||||
(await agent.get(`/api/inventory/${lot.id}`)).body.lot.remainingWeightG,
|
||||
50,
|
||||
"remaining must be unchanged after the rejected duplicate",
|
||||
);
|
||||
|
||||
// A manual (plan-less) draw is unlimited and can push remaining negative — an honest
|
||||
// signal of paperwork/shelf drift, not clamped.
|
||||
const manual = await agent
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ weightG: 100 });
|
||||
assert.equal(manual.status, 201);
|
||||
assert.equal(manual.body.lot.remainingWeightG, -50);
|
||||
|
||||
const zero = await agent
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ weightG: 0 });
|
||||
assert.equal(zero.status, 400);
|
||||
|
||||
const withLog = await agent.get(`/api/inventory/${lot.id}`);
|
||||
assert.equal(withLog.body.log.length, 2);
|
||||
// Newest first: the manual (plan-less) draw has no plan title; the earlier draw does.
|
||||
assert.equal(withLog.body.log[0].roastPlanId, null);
|
||||
assert.equal(withLog.body.log[0].planTitle, null);
|
||||
assert.equal(withLog.body.log[1].roastPlanId, planId);
|
||||
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
|
||||
});
|
||||
|
||||
test("inventory: consuming against another user's plan is refused", async () => {
|
||||
const { app } = await setup();
|
||||
const owner = request.agent(app);
|
||||
const attacker = request.agent(app);
|
||||
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
|
||||
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
|
||||
const planId = await bootstrapPlan(owner, ownerCsrf);
|
||||
const lot = (
|
||||
await attacker
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", attackerCsrf)
|
||||
.send({ origin: "Huila", initialWeightG: 500 })
|
||||
).body.lot;
|
||||
const consume = await attacker
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", attackerCsrf)
|
||||
.send({ weightG: 50, roastPlanId: planId });
|
||||
assert.equal(consume.status, 404);
|
||||
});
|
||||
|
||||
test("inventory: edit never accepts remainingWeightG directly, but shifting initialWeightG shifts remaining by the delta", async () => {
|
||||
const { app } = await setup();
|
||||
const agent = request.agent(app);
|
||||
const { csrf } = await signup(agent, "[email protected]");
|
||||
const lot = (
|
||||
await agent
|
||||
.post("/api/inventory")
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ origin: "Huila", initialWeightG: 1000 })
|
||||
).body.lot;
|
||||
await agent
|
||||
.post(`/api/inventory/${lot.id}/consume`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ weightG: 400 });
|
||||
|
||||
// Attempting to set remainingWeightG directly is silently ignored.
|
||||
const sneaky = await agent
|
||||
.put(`/api/inventory/${lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ remainingWeightG: 999999 });
|
||||
assert.equal(sneaky.body.lot.remainingWeightG, 600);
|
||||
|
||||
// Correcting the recorded initial weight (e.g. a scale error) shifts remaining by the delta.
|
||||
const corrected = await agent
|
||||
.put(`/api/inventory/${lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ initialWeightG: 1100 });
|
||||
assert.equal(corrected.body.lot.initialWeightG, 1100);
|
||||
assert.equal(corrected.body.lot.remainingWeightG, 700);
|
||||
|
||||
const archived = await agent
|
||||
.put(`/api/inventory/${lot.id}`)
|
||||
.set("x-csrf-token", csrf)
|
||||
.send({ archived: true });
|
||||
assert.equal(archived.body.lot.archived, true);
|
||||
|
||||
assert.equal(
|
||||
(await agent.delete(`/api/inventory/${lot.id}`).set("x-csrf-token", csrf)).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await agent.get(`/api/inventory/${lot.id}`)).status, 404);
|
||||
});
|
||||
Reference in New Issue
Block a user