Add Equipment page: owned brewers and grinders filter the Brews UI
Test and deploy / test-and-deploy (push) Successful in 59s
Test and deploy / test-and-deploy (push) Successful in 59s
- user_gear table (migration 008) + GET/PUT /api/gear (brewer keys validated against the taxonomy, grinders deduped/trimmed) - /gear page: silhouette multi-select for owned brewers (autosaves) and a grinder list; nav gains Brewing → Equipment - Brews page: with gear configured, the picker shows only owned brewers (plus the edited brew's method) with a show-all toggle; grinder field suggests your grinders; empty-gear state links to /gear - Included in full backups, per-user export, and the OpenAPI spec Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3bceb44ae3
commit
e62b9601a2
@@ -48,6 +48,7 @@ const PUBLIC_SHELL_FILES = new Set([
|
||||
"/roasts.html",
|
||||
"/beans.html",
|
||||
"/brews.html",
|
||||
"/gear.html",
|
||||
"/api-docs.html",
|
||||
]);
|
||||
|
||||
@@ -142,6 +143,7 @@ export function createApp({
|
||||
req.path === "/roasts" ||
|
||||
req.path === "/beans" ||
|
||||
req.path === "/brews" ||
|
||||
req.path === "/gear" ||
|
||||
req.path === "/api-docs"
|
||||
)
|
||||
res.set("Cache-Control", "no-store, private");
|
||||
@@ -422,6 +424,7 @@ export function createApp({
|
||||
for (const [route, file] of [
|
||||
["/beans", "beans.html"],
|
||||
["/brews", "brews.html"],
|
||||
["/gear", "gear.html"],
|
||||
["/api-docs", "api-docs.html"],
|
||||
]) {
|
||||
app.get(route, async (req, res, next) => {
|
||||
@@ -2390,6 +2393,7 @@ export function createApp({
|
||||
],
|
||||
],
|
||||
["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
|
||||
["user_gear", ["user_id", "brewers", "grinders", "updated_at"]],
|
||||
["audit_events", ["id", "actor_user_id", "action", "target", "created_at"]],
|
||||
];
|
||||
const BACKUP_VERSION = 1;
|
||||
@@ -2718,6 +2722,58 @@ export function createApp({
|
||||
.json({ ok: false, code: "unparseable_alog", error: err.message });
|
||||
}
|
||||
});
|
||||
// ─── Equipment (owned brewers + grinders) ──────────────────────────────
|
||||
app.get("/api/gear", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const row = (
|
||||
await db.query("SELECT brewers,grinders FROM user_gear WHERE user_id=$1", [
|
||||
req.user.id,
|
||||
])
|
||||
).rows[0];
|
||||
res.json({
|
||||
ok: true,
|
||||
gear: { brewers: row?.brewers ?? [], grinders: row?.grinders ?? [] },
|
||||
});
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
app.put("/api/gear", requireAuth, csrf, async (req, res, next) => {
|
||||
try {
|
||||
const rawBrewers = req.body.brewers;
|
||||
const rawGrinders = req.body.grinders;
|
||||
if (!Array.isArray(rawBrewers) || !Array.isArray(rawGrinders))
|
||||
return res.status(400).json({ ok: false, code: "bad_gear" });
|
||||
const brewers = [...new Set(rawBrewers)];
|
||||
if (
|
||||
brewers.length > BREW_METHODS.length ||
|
||||
!brewers.every((k) => BREW_METHOD_KEYS.has(k))
|
||||
)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, code: "bad_gear", error: "unknown brewer key" });
|
||||
const grinders = [
|
||||
...new Set(
|
||||
rawGrinders
|
||||
.filter((g) => typeof g === "string")
|
||||
.map((g) => g.trim().slice(0, 100))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 20);
|
||||
const row = (
|
||||
await db.query(
|
||||
`INSERT INTO user_gear(user_id,brewers,grinders,updated_at) VALUES($1,$2,$3,now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET brewers=$2, grinders=$3, updated_at=now()
|
||||
RETURNING brewers,grinders`,
|
||||
[req.user.id, JSON.stringify(brewers), JSON.stringify(grinders)],
|
||||
)
|
||||
).rows[0];
|
||||
res.json({ ok: true, gear: { brewers: row.brewers, grinders: row.grinders } });
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/brew-methods", requireAuth, (_req, res) =>
|
||||
res.json({
|
||||
ok: true,
|
||||
|
||||
@@ -146,6 +146,10 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
|
||||
put: { tags: ["beans"], summary: "Update a bean", parameters: [idParam], requestBody: jsonBody(beanBody), responses: { 200: ok("Updated"), 404: err("Not found") } },
|
||||
delete: { tags: ["beans"], summary: "Delete a bean", parameters: [idParam], responses: { 200: ok("Deleted"), 404: err("Not found") } },
|
||||
},
|
||||
"/api/gear": {
|
||||
get: { tags: ["brews"], summary: "Owned equipment (brewers + grinders)", responses: { 200: ok("Gear", obj({ ok: bool, gear: obj({ brewers: arr(str), grinders: arr(str) }) })) } },
|
||||
put: { tags: ["brews"], summary: "Set owned equipment", requestBody: jsonBody(obj({ brewers: arr({ ...str, description: "brew-method keys" }), grinders: arr(str) }, ["brewers", "grinders"])), responses: { 200: ok("Saved"), 400: err("Unknown brewer key") } },
|
||||
},
|
||||
"/api/brew-methods": { get: { tags: ["brews"], summary: "Brew-method taxonomy (categories + methods)", responses: { 200: ok("Methods", obj({ ok: bool, categories: arr(obj({ key: str, name: str })), methods: arr(obj({ key: str, name: str, category: str })) })) } } },
|
||||
|
||||
// ── Brews ──
|
||||
|
||||
Reference in New Issue
Block a user