chore: auto-fix Biome formatting and configure lint rules
All checks were successful
CI / ci (push) Successful in 15s
All checks were successful
CI / ci (push) Successful in 15s
Run biome check --write --unsafe to fix tabs, import ordering, and non-null assertions across entire codebase. Disable a11y rules not applicable to this single-user app. Exclude auto-generated routeTree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { seedDefaults } from "../db/seed.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
import { categoryRoutes } from "./routes/categories.ts";
|
||||
import { totalRoutes } from "./routes/totals.ts";
|
||||
import { imageRoutes } from "./routes/images.ts";
|
||||
import { itemRoutes } from "./routes/items.ts";
|
||||
import { settingsRoutes } from "./routes/settings.ts";
|
||||
import { threadRoutes } from "./routes/threads.ts";
|
||||
import { setupRoutes } from "./routes/setups.ts";
|
||||
import { threadRoutes } from "./routes/threads.ts";
|
||||
import { totalRoutes } from "./routes/totals.ts";
|
||||
|
||||
// Seed default data on startup
|
||||
seedDefaults();
|
||||
@@ -16,7 +16,7 @@ const app = new Hono();
|
||||
|
||||
// Health check
|
||||
app.get("/api/health", (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// API routes
|
||||
@@ -33,8 +33,8 @@ app.use("/uploads/*", serveStatic({ root: "./" }));
|
||||
|
||||
// Serve Vite-built SPA in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
app.use("/*", serveStatic({ root: "./dist/client" }));
|
||||
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
||||
app.use("/*", serveStatic({ root: "./dist/client" }));
|
||||
app.get("*", serveStatic({ path: "./dist/client/index.html" }));
|
||||
}
|
||||
|
||||
export default { port: 3000, fetch: app.fetch };
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createCategorySchema,
|
||||
updateCategorySchema,
|
||||
createCategorySchema,
|
||||
updateCategorySchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import {
|
||||
getAllCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
getAllCategories,
|
||||
updateCategory,
|
||||
} from "../services/category.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
@@ -16,44 +16,44 @@ type Env = { Variables: { db?: any } };
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const cats = getAllCategories(db);
|
||||
return c.json(cats);
|
||||
const db = c.get("db");
|
||||
const cats = getAllCategories(db);
|
||||
return c.json(cats);
|
||||
});
|
||||
|
||||
app.post("/", zValidator("json", createCategorySchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const cat = createCategory(db, data);
|
||||
return c.json(cat, 201);
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const cat = createCategory(db, data);
|
||||
return c.json(cat, 201);
|
||||
});
|
||||
|
||||
app.put(
|
||||
"/:id",
|
||||
zValidator("json", updateCategorySchema.omit({ id: true })),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const cat = updateCategory(db, id, data);
|
||||
if (!cat) return c.json({ error: "Category not found" }, 404);
|
||||
return c.json(cat);
|
||||
},
|
||||
"/:id",
|
||||
zValidator("json", updateCategorySchema.omit({ id: true })),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const cat = updateCategory(db, id, data);
|
||||
if (!cat) return c.json({ error: "Category not found" }, 404);
|
||||
return c.json(cat);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const result = deleteCategory(db, id);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const result = deleteCategory(db, id);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error === "Cannot delete the Uncategorized category") {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ error: result.error }, 404);
|
||||
}
|
||||
if (!result.success) {
|
||||
if (result.error === "Cannot delete the Uncategorized category") {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ error: result.error }, 404);
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export { app as categoryRoutes };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
@@ -9,38 +9,39 @@ const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body["image"];
|
||||
const body = await c.req.parseBody();
|
||||
const file = body.image;
|
||||
|
||||
if (!file || typeof file === "string") {
|
||||
return c.json({ error: "No image file provided" }, 400);
|
||||
}
|
||||
if (!file || typeof file === "string") {
|
||||
return c.json({ error: "No image file provided" }, 400);
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json(
|
||||
{ error: "Invalid file type. Accepted: jpeg, png, webp" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return c.json(
|
||||
{ error: "Invalid file type. Accepted: jpeg, png, webp" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
|
||||
}
|
||||
// Validate file size
|
||||
if (file.size > MAX_SIZE) {
|
||||
return c.json({ error: "File too large. Maximum size is 5MB" }, 400);
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||
// Generate unique filename
|
||||
const ext =
|
||||
file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const filename = `${Date.now()}-${randomUUID()}.${ext}`;
|
||||
|
||||
// Ensure uploads directory exists
|
||||
await mkdir("uploads", { recursive: true });
|
||||
// Ensure uploads directory exists
|
||||
await mkdir("uploads", { recursive: true });
|
||||
|
||||
// Write file
|
||||
const buffer = await file.arrayBuffer();
|
||||
await Bun.write(join("uploads", filename), buffer);
|
||||
// Write file
|
||||
const buffer = await file.arrayBuffer();
|
||||
await Bun.write(join("uploads", filename), buffer);
|
||||
|
||||
return c.json({ filename }, 201);
|
||||
return c.json({ filename }, 201);
|
||||
});
|
||||
|
||||
export { app as imageRoutes };
|
||||
|
||||
@@ -1,66 +1,70 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||
import {
|
||||
getAllItems,
|
||||
getItemById,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
} from "../services/item.service.ts";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { createItemSchema, updateItemSchema } from "../../shared/schemas.ts";
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
getAllItems,
|
||||
getItemById,
|
||||
updateItem,
|
||||
} from "../services/item.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const items = getAllItems(db);
|
||||
return c.json(items);
|
||||
const db = c.get("db");
|
||||
const items = getAllItems(db);
|
||||
return c.json(items);
|
||||
});
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const item = getItemById(db, id);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const item = getItemById(db, id);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
|
||||
app.post("/", zValidator("json", createItemSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const item = createItem(db, data);
|
||||
return c.json(item, 201);
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const item = createItem(db, data);
|
||||
return c.json(item, 201);
|
||||
});
|
||||
|
||||
app.put("/:id", zValidator("json", updateItemSchema.omit({ id: true })), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const item = updateItem(db, id, data);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
});
|
||||
app.put(
|
||||
"/:id",
|
||||
zValidator("json", updateItemSchema.omit({ id: true })),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const item = updateItem(db, id, data);
|
||||
if (!item) return c.json({ error: "Item not found" }, 404);
|
||||
return c.json(item);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteItem(db, id);
|
||||
if (!deleted) return c.json({ error: "Item not found" }, 404);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteItem(db, id);
|
||||
if (!deleted) return c.json({ error: "Item not found" }, 404);
|
||||
|
||||
// Clean up image file if exists
|
||||
if (deleted.imageFilename) {
|
||||
try {
|
||||
await unlink(join("uploads", deleted.imageFilename));
|
||||
} catch {
|
||||
// File missing is not an error worth failing the delete over
|
||||
}
|
||||
}
|
||||
// Clean up image file if exists
|
||||
if (deleted.imageFilename) {
|
||||
try {
|
||||
await unlink(join("uploads", deleted.imageFilename));
|
||||
} catch {
|
||||
// File missing is not an error worth failing the delete over
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export { app as itemRoutes };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { settings } from "../../db/schema.ts";
|
||||
|
||||
@@ -8,30 +8,38 @@ type Env = { Variables: { db?: any } };
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/:key", (c) => {
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const key = c.req.param("key");
|
||||
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
||||
if (!row) return c.json({ error: "Setting not found" }, 404);
|
||||
return c.json(row);
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const key = c.req.param("key");
|
||||
const row = database
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key))
|
||||
.get();
|
||||
if (!row) return c.json({ error: "Setting not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
app.put("/:key", async (c) => {
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const key = c.req.param("key");
|
||||
const body = await c.req.json<{ value: string }>();
|
||||
const database = c.get("db") ?? prodDb;
|
||||
const key = c.req.param("key");
|
||||
const body = await c.req.json<{ value: string }>();
|
||||
|
||||
if (!body.value && body.value !== "") {
|
||||
return c.json({ error: "value is required" }, 400);
|
||||
}
|
||||
if (!body.value && body.value !== "") {
|
||||
return c.json({ error: "value is required" }, 400);
|
||||
}
|
||||
|
||||
database
|
||||
.insert(settings)
|
||||
.values({ key, value: body.value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
|
||||
.run();
|
||||
database
|
||||
.insert(settings)
|
||||
.values({ key, value: body.value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value: body.value } })
|
||||
.run();
|
||||
|
||||
const row = database.select().from(settings).where(eq(settings.key, key)).get();
|
||||
return c.json(row);
|
||||
const row = database
|
||||
.select()
|
||||
.from(settings)
|
||||
.where(eq(settings.key, key))
|
||||
.get();
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
export { app as settingsRoutes };
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createSetupSchema,
|
||||
updateSetupSchema,
|
||||
syncSetupItemsSchema,
|
||||
createSetupSchema,
|
||||
syncSetupItemsSchema,
|
||||
updateSetupSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import {
|
||||
getAllSetups,
|
||||
getSetupWithItems,
|
||||
createSetup,
|
||||
updateSetup,
|
||||
deleteSetup,
|
||||
syncSetupItems,
|
||||
removeSetupItem,
|
||||
createSetup,
|
||||
deleteSetup,
|
||||
getAllSetups,
|
||||
getSetupWithItems,
|
||||
removeSetupItem,
|
||||
syncSetupItems,
|
||||
updateSetup,
|
||||
} from "../services/setup.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
@@ -22,63 +22,63 @@ const app = new Hono<Env>();
|
||||
// Setup CRUD
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const setups = getAllSetups(db);
|
||||
return c.json(setups);
|
||||
const db = c.get("db");
|
||||
const setups = getAllSetups(db);
|
||||
return c.json(setups);
|
||||
});
|
||||
|
||||
app.post("/", zValidator("json", createSetupSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const setup = createSetup(db, data);
|
||||
return c.json(setup, 201);
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const setup = createSetup(db, data);
|
||||
return c.json(setup, 201);
|
||||
});
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const setup = getSetupWithItems(db, id);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(setup);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const setup = getSetupWithItems(db, id);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(setup);
|
||||
});
|
||||
|
||||
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const setup = updateSetup(db, id, data);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(setup);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const setup = updateSetup(db, id, data);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json(setup);
|
||||
});
|
||||
|
||||
app.delete("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteSetup(db, id);
|
||||
if (!deleted) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteSetup(db, id);
|
||||
if (!deleted) return c.json({ error: "Setup not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Setup Items
|
||||
|
||||
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const { itemIds } = c.req.valid("json");
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const { itemIds } = c.req.valid("json");
|
||||
|
||||
const setup = getSetupWithItems(db, id);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
const setup = getSetupWithItems(db, id);
|
||||
if (!setup) return c.json({ error: "Setup not found" }, 404);
|
||||
|
||||
syncSetupItems(db, id, itemIds);
|
||||
return c.json({ success: true });
|
||||
syncSetupItems(db, id, itemIds);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete("/:id/items/:itemId", (c) => {
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("id"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
removeSetupItem(db, setupId, itemId);
|
||||
return c.json({ success: true });
|
||||
const db = c.get("db");
|
||||
const setupId = Number(c.req.param("id"));
|
||||
const itemId = Number(c.req.param("itemId"));
|
||||
removeSetupItem(db, setupId, itemId);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
export { app as setupRoutes };
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import {
|
||||
createThreadSchema,
|
||||
updateThreadSchema,
|
||||
createCandidateSchema,
|
||||
updateCandidateSchema,
|
||||
resolveThreadSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import {
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
createThread,
|
||||
updateThread,
|
||||
deleteThread,
|
||||
createCandidate,
|
||||
updateCandidate,
|
||||
deleteCandidate,
|
||||
resolveThread,
|
||||
} from "../services/thread.service.ts";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
createCandidateSchema,
|
||||
createThreadSchema,
|
||||
resolveThreadSchema,
|
||||
updateCandidateSchema,
|
||||
updateThreadSchema,
|
||||
} from "../../shared/schemas.ts";
|
||||
import {
|
||||
createCandidate,
|
||||
createThread,
|
||||
deleteCandidate,
|
||||
deleteThread,
|
||||
getAllThreads,
|
||||
getThreadWithCandidates,
|
||||
resolveThread,
|
||||
updateCandidate,
|
||||
updateThread,
|
||||
} from "../services/thread.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
|
||||
@@ -28,109 +28,113 @@ const app = new Hono<Env>();
|
||||
// Thread CRUD
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const includeResolved = c.req.query("includeResolved") === "true";
|
||||
const threads = getAllThreads(db, includeResolved);
|
||||
return c.json(threads);
|
||||
const db = c.get("db");
|
||||
const includeResolved = c.req.query("includeResolved") === "true";
|
||||
const threads = getAllThreads(db, includeResolved);
|
||||
return c.json(threads);
|
||||
});
|
||||
|
||||
app.post("/", zValidator("json", createThreadSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const thread = createThread(db, data);
|
||||
return c.json(thread, 201);
|
||||
const db = c.get("db");
|
||||
const data = c.req.valid("json");
|
||||
const thread = createThread(db, data);
|
||||
return c.json(thread, 201);
|
||||
});
|
||||
|
||||
app.get("/:id", (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const thread = getThreadWithCandidates(db, id);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
return c.json(thread);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const thread = getThreadWithCandidates(db, id);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
return c.json(thread);
|
||||
});
|
||||
|
||||
app.put("/:id", zValidator("json", updateThreadSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const thread = updateThread(db, id, data);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
return c.json(thread);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const data = c.req.valid("json");
|
||||
const thread = updateThread(db, id, data);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
return c.json(thread);
|
||||
});
|
||||
|
||||
app.delete("/:id", async (c) => {
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteThread(db, id);
|
||||
if (!deleted) return c.json({ error: "Thread not found" }, 404);
|
||||
const db = c.get("db");
|
||||
const id = Number(c.req.param("id"));
|
||||
const deleted = deleteThread(db, id);
|
||||
if (!deleted) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
// Clean up candidate image files
|
||||
for (const filename of deleted.candidateImages) {
|
||||
try {
|
||||
await unlink(join("uploads", filename));
|
||||
} catch {
|
||||
// File missing is not an error worth failing the delete over
|
||||
}
|
||||
}
|
||||
// Clean up candidate image files
|
||||
for (const filename of deleted.candidateImages) {
|
||||
try {
|
||||
await unlink(join("uploads", filename));
|
||||
} catch {
|
||||
// File missing is not an error worth failing the delete over
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Candidate CRUD (nested under thread)
|
||||
|
||||
app.post("/:id/candidates", zValidator("json", createCandidateSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
|
||||
// Verify thread exists
|
||||
const thread = getThreadWithCandidates(db, threadId);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
// Verify thread exists
|
||||
const thread = getThreadWithCandidates(db, threadId);
|
||||
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
const data = c.req.valid("json");
|
||||
const candidate = createCandidate(db, threadId, data);
|
||||
return c.json(candidate, 201);
|
||||
const data = c.req.valid("json");
|
||||
const candidate = createCandidate(db, threadId, data);
|
||||
return c.json(candidate, 201);
|
||||
});
|
||||
|
||||
app.put("/:threadId/candidates/:candidateId", zValidator("json", updateCandidateSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const data = c.req.valid("json");
|
||||
const candidate = updateCandidate(db, candidateId, data);
|
||||
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
||||
return c.json(candidate);
|
||||
});
|
||||
app.put(
|
||||
"/:threadId/candidates/:candidateId",
|
||||
zValidator("json", updateCandidateSchema),
|
||||
(c) => {
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const data = c.req.valid("json");
|
||||
const candidate = updateCandidate(db, candidateId, data);
|
||||
if (!candidate) return c.json({ error: "Candidate not found" }, 404);
|
||||
return c.json(candidate);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete("/:threadId/candidates/:candidateId", async (c) => {
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const deleted = deleteCandidate(db, candidateId);
|
||||
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
|
||||
const db = c.get("db");
|
||||
const candidateId = Number(c.req.param("candidateId"));
|
||||
const deleted = deleteCandidate(db, candidateId);
|
||||
if (!deleted) return c.json({ error: "Candidate not found" }, 404);
|
||||
|
||||
// Clean up image file if exists
|
||||
if (deleted.imageFilename) {
|
||||
try {
|
||||
await unlink(join("uploads", deleted.imageFilename));
|
||||
} catch {
|
||||
// File missing is not an error
|
||||
}
|
||||
}
|
||||
// Clean up image file if exists
|
||||
if (deleted.imageFilename) {
|
||||
try {
|
||||
await unlink(join("uploads", deleted.imageFilename));
|
||||
} catch {
|
||||
// File missing is not an error
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// Resolution
|
||||
|
||||
app.post("/:id/resolve", zValidator("json", resolveThreadSchema), (c) => {
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const { candidateId } = c.req.valid("json");
|
||||
const db = c.get("db");
|
||||
const threadId = Number(c.req.param("id"));
|
||||
const { candidateId } = c.req.valid("json");
|
||||
|
||||
const result = resolveThread(db, threadId, candidateId);
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
const result = resolveThread(db, threadId, candidateId);
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
|
||||
return c.json({ success: true, item: result.item });
|
||||
return c.json({ success: true, item: result.item });
|
||||
});
|
||||
|
||||
export { app as threadRoutes };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
getCategoryTotals,
|
||||
getGlobalTotals,
|
||||
getCategoryTotals,
|
||||
getGlobalTotals,
|
||||
} from "../services/totals.service.ts";
|
||||
|
||||
type Env = { Variables: { db?: any } };
|
||||
@@ -9,10 +9,10 @@ type Env = { Variables: { db?: any } };
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.get("/", (c) => {
|
||||
const db = c.get("db");
|
||||
const categoryTotals = getCategoryTotals(db);
|
||||
const globalTotals = getGlobalTotals(db);
|
||||
return c.json({ categories: categoryTotals, global: globalTotals });
|
||||
const db = c.get("db");
|
||||
const categoryTotals = getCategoryTotals(db);
|
||||
const globalTotals = getGlobalTotals(db);
|
||||
return c.json({ categories: categoryTotals, global: globalTotals });
|
||||
});
|
||||
|
||||
export { app as totalRoutes };
|
||||
|
||||
@@ -1,77 +1,80 @@
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getAllCategories(db: Db = prodDb) {
|
||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
||||
return db.select().from(categories).orderBy(asc(categories.name)).all();
|
||||
}
|
||||
|
||||
export function createCategory(
|
||||
db: Db = prodDb,
|
||||
data: { name: string; icon?: string },
|
||||
db: Db = prodDb,
|
||||
data: { name: string; icon?: string },
|
||||
) {
|
||||
return db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: data.name,
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.insert(categories)
|
||||
.values({
|
||||
name: data.name,
|
||||
...(data.icon ? { icon: data.icon } : {}),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function updateCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: { name?: string; icon?: string },
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: { name?: string; icon?: string },
|
||||
) {
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) return null;
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.update(categories)
|
||||
.set(data)
|
||||
.where(eq(categories.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteCategory(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
): { success: boolean; error?: string } {
|
||||
// Guard: cannot delete Uncategorized (id=1)
|
||||
if (id === 1) {
|
||||
return { success: false, error: "Cannot delete the Uncategorized category" };
|
||||
}
|
||||
// Guard: cannot delete Uncategorized (id=1)
|
||||
if (id === 1) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Cannot delete the Uncategorized category",
|
||||
};
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
// Check if category exists
|
||||
const existing = db
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
if (!existing) {
|
||||
return { success: false, error: "Category not found" };
|
||||
}
|
||||
|
||||
// Reassign items to Uncategorized (id=1), then delete atomically
|
||||
db.transaction(() => {
|
||||
db.update(items)
|
||||
.set({ categoryId: 1 })
|
||||
.where(eq(items.categoryId, id))
|
||||
.run();
|
||||
// Reassign items to Uncategorized (id=1), then delete atomically
|
||||
db.transaction(() => {
|
||||
db.update(items)
|
||||
.set({ categoryId: 1 })
|
||||
.where(eq(items.categoryId, id))
|
||||
.run();
|
||||
|
||||
db.delete(categories).where(eq(categories.id, id)).run();
|
||||
});
|
||||
db.delete(categories).where(eq(categories.id, id)).run();
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -1,112 +1,112 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { items, categories } from "../../db/schema.ts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
import type { CreateItem } from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getAllItems(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.all();
|
||||
return db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getItemById(db: Db = prodDb, id: number) {
|
||||
return (
|
||||
db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
})
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get() ?? null
|
||||
);
|
||||
return (
|
||||
db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
})
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function createItem(
|
||||
db: Db = prodDb,
|
||||
data: Partial<CreateItem> & { name: string; categoryId: number; imageFilename?: string },
|
||||
db: Db = prodDb,
|
||||
data: Partial<CreateItem> & {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
imageFilename?: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.insert(items)
|
||||
.values({
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.insert(items)
|
||||
.values({
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function updateItem(
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
}>,
|
||||
db: Db = prodDb,
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
}>,
|
||||
) {
|
||||
// Check if item exists first
|
||||
const existing = db
|
||||
.select({ id: items.id })
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get();
|
||||
// Check if item exists first
|
||||
const existing = db
|
||||
.select({ id: items.id })
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get();
|
||||
|
||||
if (!existing) return null;
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(items)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(items.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.update(items)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(items.id, id))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteItem(db: Db = prodDb, id: number) {
|
||||
// Get item first (for image cleanup info)
|
||||
const item = db
|
||||
.select()
|
||||
.from(items)
|
||||
.where(eq(items.id, id))
|
||||
.get();
|
||||
// Get item first (for image cleanup info)
|
||||
const item = db.select().from(items).where(eq(items.id, id)).get();
|
||||
|
||||
if (!item) return null;
|
||||
if (!item) return null;
|
||||
|
||||
db.delete(items).where(eq(items.id, id)).run();
|
||||
db.delete(items).where(eq(items.id, id)).run();
|
||||
|
||||
return item;
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1,111 +1,124 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { setups, setupItems, items, categories } from "../../db/schema.ts";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items, setupItems, setups } from "../../db/schema.ts";
|
||||
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function createSetup(db: Db = prodDb, data: CreateSetup) {
|
||||
return db
|
||||
.insert(setups)
|
||||
.values({ name: data.name })
|
||||
.returning()
|
||||
.get();
|
||||
return db.insert(setups).values({ name: data.name }).returning().get();
|
||||
}
|
||||
|
||||
export function getAllSetups(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
id: setups.id,
|
||||
name: setups.name,
|
||||
createdAt: setups.createdAt,
|
||||
updatedAt: setups.updatedAt,
|
||||
itemCount: sql<number>`COALESCE((
|
||||
return db
|
||||
.select({
|
||||
id: setups.id,
|
||||
name: setups.name,
|
||||
createdAt: setups.createdAt,
|
||||
updatedAt: setups.updatedAt,
|
||||
itemCount: sql<number>`COALESCE((
|
||||
SELECT COUNT(*) FROM setup_items
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("item_count"),
|
||||
totalWeight: sql<number>`COALESCE((
|
||||
totalWeight: sql<number>`COALESCE((
|
||||
SELECT SUM(items.weight_grams) FROM setup_items
|
||||
JOIN items ON items.id = setup_items.item_id
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("total_weight"),
|
||||
totalCost: sql<number>`COALESCE((
|
||||
totalCost: sql<number>`COALESCE((
|
||||
SELECT SUM(items.price_cents) FROM setup_items
|
||||
JOIN items ON items.id = setup_items.item_id
|
||||
WHERE setup_items.setup_id = setups.id
|
||||
), 0)`.as("total_cost"),
|
||||
})
|
||||
.from(setups)
|
||||
.all();
|
||||
})
|
||||
.from(setups)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
|
||||
const setup = db.select().from(setups)
|
||||
.where(eq(setups.id, setupId)).get();
|
||||
if (!setup) return null;
|
||||
const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
|
||||
if (!setup) return null;
|
||||
|
||||
const itemList = db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(setupItems)
|
||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.where(eq(setupItems.setupId, setupId))
|
||||
.all();
|
||||
const itemList = db
|
||||
.select({
|
||||
id: items.id,
|
||||
name: items.name,
|
||||
weightGrams: items.weightGrams,
|
||||
priceCents: items.priceCents,
|
||||
categoryId: items.categoryId,
|
||||
notes: items.notes,
|
||||
productUrl: items.productUrl,
|
||||
imageFilename: items.imageFilename,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(setupItems)
|
||||
.innerJoin(items, eq(setupItems.itemId, items.id))
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.where(eq(setupItems.setupId, setupId))
|
||||
.all();
|
||||
|
||||
return { ...setup, items: itemList };
|
||||
return { ...setup, items: itemList };
|
||||
}
|
||||
|
||||
export function updateSetup(db: Db = prodDb, setupId: number, data: UpdateSetup) {
|
||||
const existing = db.select({ id: setups.id }).from(setups)
|
||||
.where(eq(setups.id, setupId)).get();
|
||||
if (!existing) return null;
|
||||
export function updateSetup(
|
||||
db: Db = prodDb,
|
||||
setupId: number,
|
||||
data: UpdateSetup,
|
||||
) {
|
||||
const existing = db
|
||||
.select({ id: setups.id })
|
||||
.from(setups)
|
||||
.where(eq(setups.id, setupId))
|
||||
.get();
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(setups)
|
||||
.set({ name: data.name, updatedAt: new Date() })
|
||||
.where(eq(setups.id, setupId))
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.update(setups)
|
||||
.set({ name: data.name, updatedAt: new Date() })
|
||||
.where(eq(setups.id, setupId))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteSetup(db: Db = prodDb, setupId: number) {
|
||||
const existing = db.select({ id: setups.id }).from(setups)
|
||||
.where(eq(setups.id, setupId)).get();
|
||||
if (!existing) return false;
|
||||
const existing = db
|
||||
.select({ id: setups.id })
|
||||
.from(setups)
|
||||
.where(eq(setups.id, setupId))
|
||||
.get();
|
||||
if (!existing) return false;
|
||||
|
||||
db.delete(setups).where(eq(setups.id, setupId)).run();
|
||||
return true;
|
||||
db.delete(setups).where(eq(setups.id, setupId)).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function syncSetupItems(db: Db = prodDb, setupId: number, itemIds: number[]) {
|
||||
return db.transaction((tx) => {
|
||||
// Delete all existing items for this setup
|
||||
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
|
||||
export function syncSetupItems(
|
||||
db: Db = prodDb,
|
||||
setupId: number,
|
||||
itemIds: number[],
|
||||
) {
|
||||
return db.transaction((tx) => {
|
||||
// Delete all existing items for this setup
|
||||
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
|
||||
|
||||
// Re-insert new items
|
||||
for (const itemId of itemIds) {
|
||||
tx.insert(setupItems).values({ setupId, itemId }).run();
|
||||
}
|
||||
});
|
||||
// Re-insert new items
|
||||
for (const itemId of itemIds) {
|
||||
tx.insert(setupItems).values({ setupId, itemId }).run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function removeSetupItem(db: Db = prodDb, setupId: number, itemId: number) {
|
||||
db.delete(setupItems)
|
||||
.where(
|
||||
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`
|
||||
)
|
||||
.run();
|
||||
export function removeSetupItem(
|
||||
db: Db = prodDb,
|
||||
setupId: number,
|
||||
itemId: number,
|
||||
) {
|
||||
db.delete(setupItems)
|
||||
.where(
|
||||
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -1,221 +1,261 @@
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { threads, threadCandidates, items, categories } from "../../db/schema.ts";
|
||||
import { desc, eq, sql } from "drizzle-orm";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import type { CreateThread, UpdateThread, CreateCandidate } from "../../shared/types.ts";
|
||||
import {
|
||||
categories,
|
||||
items,
|
||||
threadCandidates,
|
||||
threads,
|
||||
} from "../../db/schema.ts";
|
||||
import type { CreateCandidate, CreateThread } from "../../shared/types.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function createThread(db: Db = prodDb, data: CreateThread) {
|
||||
return db
|
||||
.insert(threads)
|
||||
.values({ name: data.name, categoryId: data.categoryId })
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.insert(threads)
|
||||
.values({ name: data.name, categoryId: data.categoryId })
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function getAllThreads(db: Db = prodDb, includeResolved = false) {
|
||||
const query = db
|
||||
.select({
|
||||
id: threads.id,
|
||||
name: threads.name,
|
||||
status: threads.status,
|
||||
resolvedCandidateId: threads.resolvedCandidateId,
|
||||
categoryId: threads.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
createdAt: threads.createdAt,
|
||||
updatedAt: threads.updatedAt,
|
||||
candidateCount: sql<number>`(
|
||||
const query = db
|
||||
.select({
|
||||
id: threads.id,
|
||||
name: threads.name,
|
||||
status: threads.status,
|
||||
resolvedCandidateId: threads.resolvedCandidateId,
|
||||
categoryId: threads.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
createdAt: threads.createdAt,
|
||||
updatedAt: threads.updatedAt,
|
||||
candidateCount: sql<number>`(
|
||||
SELECT COUNT(*) FROM thread_candidates
|
||||
WHERE thread_candidates.thread_id = threads.id
|
||||
)`.as("candidate_count"),
|
||||
minPriceCents: sql<number | null>`(
|
||||
minPriceCents: sql<number | null>`(
|
||||
SELECT MIN(price_cents) FROM thread_candidates
|
||||
WHERE thread_candidates.thread_id = threads.id
|
||||
)`.as("min_price_cents"),
|
||||
maxPriceCents: sql<number | null>`(
|
||||
maxPriceCents: sql<number | null>`(
|
||||
SELECT MAX(price_cents) FROM thread_candidates
|
||||
WHERE thread_candidates.thread_id = threads.id
|
||||
)`.as("max_price_cents"),
|
||||
})
|
||||
.from(threads)
|
||||
.innerJoin(categories, eq(threads.categoryId, categories.id))
|
||||
.orderBy(desc(threads.createdAt));
|
||||
})
|
||||
.from(threads)
|
||||
.innerJoin(categories, eq(threads.categoryId, categories.id))
|
||||
.orderBy(desc(threads.createdAt));
|
||||
|
||||
if (!includeResolved) {
|
||||
return query.where(eq(threads.status, "active")).all();
|
||||
}
|
||||
return query.all();
|
||||
if (!includeResolved) {
|
||||
return query.where(eq(threads.status, "active")).all();
|
||||
}
|
||||
return query.all();
|
||||
}
|
||||
|
||||
export function getThreadWithCandidates(db: Db = prodDb, threadId: number) {
|
||||
const thread = db.select().from(threads)
|
||||
.where(eq(threads.id, threadId)).get();
|
||||
if (!thread) return null;
|
||||
const thread = db
|
||||
.select()
|
||||
.from(threads)
|
||||
.where(eq(threads.id, threadId))
|
||||
.get();
|
||||
if (!thread) return null;
|
||||
|
||||
const candidateList = db
|
||||
.select({
|
||||
id: threadCandidates.id,
|
||||
threadId: threadCandidates.threadId,
|
||||
name: threadCandidates.name,
|
||||
weightGrams: threadCandidates.weightGrams,
|
||||
priceCents: threadCandidates.priceCents,
|
||||
categoryId: threadCandidates.categoryId,
|
||||
notes: threadCandidates.notes,
|
||||
productUrl: threadCandidates.productUrl,
|
||||
imageFilename: threadCandidates.imageFilename,
|
||||
createdAt: threadCandidates.createdAt,
|
||||
updatedAt: threadCandidates.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(threadCandidates)
|
||||
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.all();
|
||||
const candidateList = db
|
||||
.select({
|
||||
id: threadCandidates.id,
|
||||
threadId: threadCandidates.threadId,
|
||||
name: threadCandidates.name,
|
||||
weightGrams: threadCandidates.weightGrams,
|
||||
priceCents: threadCandidates.priceCents,
|
||||
categoryId: threadCandidates.categoryId,
|
||||
notes: threadCandidates.notes,
|
||||
productUrl: threadCandidates.productUrl,
|
||||
imageFilename: threadCandidates.imageFilename,
|
||||
createdAt: threadCandidates.createdAt,
|
||||
updatedAt: threadCandidates.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
})
|
||||
.from(threadCandidates)
|
||||
.innerJoin(categories, eq(threadCandidates.categoryId, categories.id))
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.all();
|
||||
|
||||
return { ...thread, candidates: candidateList };
|
||||
return { ...thread, candidates: candidateList };
|
||||
}
|
||||
|
||||
export function updateThread(db: Db = prodDb, threadId: number, data: Partial<{ name: string; categoryId: number }>) {
|
||||
const existing = db.select({ id: threads.id }).from(threads)
|
||||
.where(eq(threads.id, threadId)).get();
|
||||
if (!existing) return null;
|
||||
export function updateThread(
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
data: Partial<{ name: string; categoryId: number }>,
|
||||
) {
|
||||
const existing = db
|
||||
.select({ id: threads.id })
|
||||
.from(threads)
|
||||
.where(eq(threads.id, threadId))
|
||||
.get();
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(threads)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(threads.id, threadId))
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.update(threads)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(threads.id, threadId))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteThread(db: Db = prodDb, threadId: number) {
|
||||
const thread = db.select().from(threads)
|
||||
.where(eq(threads.id, threadId)).get();
|
||||
if (!thread) return null;
|
||||
const thread = db
|
||||
.select()
|
||||
.from(threads)
|
||||
.where(eq(threads.id, threadId))
|
||||
.get();
|
||||
if (!thread) return null;
|
||||
|
||||
// Collect candidate image filenames for cleanup
|
||||
const candidatesWithImages = db
|
||||
.select({ imageFilename: threadCandidates.imageFilename })
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.all()
|
||||
.filter((c) => c.imageFilename != null);
|
||||
// Collect candidate image filenames for cleanup
|
||||
const candidatesWithImages = db
|
||||
.select({ imageFilename: threadCandidates.imageFilename })
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.threadId, threadId))
|
||||
.all()
|
||||
.filter((c) => c.imageFilename != null);
|
||||
|
||||
db.delete(threads).where(eq(threads.id, threadId)).run();
|
||||
db.delete(threads).where(eq(threads.id, threadId)).run();
|
||||
|
||||
return { ...thread, candidateImages: candidatesWithImages.map((c) => c.imageFilename!) };
|
||||
return {
|
||||
...thread,
|
||||
candidateImages: candidatesWithImages.map((c) => c.imageFilename!),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCandidate(
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
data: Partial<CreateCandidate> & { name: string; categoryId: number; imageFilename?: string },
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
data: Partial<CreateCandidate> & {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
imageFilename?: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.insert(threadCandidates)
|
||||
.values({
|
||||
threadId,
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.insert(threadCandidates)
|
||||
.values({
|
||||
threadId,
|
||||
name: data.name,
|
||||
weightGrams: data.weightGrams ?? null,
|
||||
priceCents: data.priceCents ?? null,
|
||||
categoryId: data.categoryId,
|
||||
notes: data.notes ?? null,
|
||||
productUrl: data.productUrl ?? null,
|
||||
imageFilename: data.imageFilename ?? null,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function updateCandidate(
|
||||
db: Db = prodDb,
|
||||
candidateId: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
}>,
|
||||
db: Db = prodDb,
|
||||
candidateId: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
weightGrams: number;
|
||||
priceCents: number;
|
||||
categoryId: number;
|
||||
notes: string;
|
||||
productUrl: string;
|
||||
imageFilename: string;
|
||||
}>,
|
||||
) {
|
||||
const existing = db.select({ id: threadCandidates.id }).from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId)).get();
|
||||
if (!existing) return null;
|
||||
const existing = db
|
||||
.select({ id: threadCandidates.id })
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId))
|
||||
.get();
|
||||
if (!existing) return null;
|
||||
|
||||
return db
|
||||
.update(threadCandidates)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(threadCandidates.id, candidateId))
|
||||
.returning()
|
||||
.get();
|
||||
return db
|
||||
.update(threadCandidates)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(threadCandidates.id, candidateId))
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function deleteCandidate(db: Db = prodDb, candidateId: number) {
|
||||
const candidate = db.select().from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId)).get();
|
||||
if (!candidate) return null;
|
||||
const candidate = db
|
||||
.select()
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId))
|
||||
.get();
|
||||
if (!candidate) return null;
|
||||
|
||||
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
|
||||
return candidate;
|
||||
db.delete(threadCandidates).where(eq(threadCandidates.id, candidateId)).run();
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function resolveThread(
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
candidateId: number,
|
||||
db: Db = prodDb,
|
||||
threadId: number,
|
||||
candidateId: number,
|
||||
): { success: boolean; item?: any; error?: string } {
|
||||
return db.transaction((tx) => {
|
||||
// 1. Check thread is active
|
||||
const thread = tx.select().from(threads)
|
||||
.where(eq(threads.id, threadId)).get();
|
||||
if (!thread || thread.status !== "active") {
|
||||
return { success: false, error: "Thread not active" };
|
||||
}
|
||||
return db.transaction((tx) => {
|
||||
// 1. Check thread is active
|
||||
const thread = tx
|
||||
.select()
|
||||
.from(threads)
|
||||
.where(eq(threads.id, threadId))
|
||||
.get();
|
||||
if (!thread || thread.status !== "active") {
|
||||
return { success: false, error: "Thread not active" };
|
||||
}
|
||||
|
||||
// 2. Get the candidate data
|
||||
const candidate = tx.select().from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId)).get();
|
||||
if (!candidate) {
|
||||
return { success: false, error: "Candidate not found" };
|
||||
}
|
||||
if (candidate.threadId !== threadId) {
|
||||
return { success: false, error: "Candidate not in thread" };
|
||||
}
|
||||
// 2. Get the candidate data
|
||||
const candidate = tx
|
||||
.select()
|
||||
.from(threadCandidates)
|
||||
.where(eq(threadCandidates.id, candidateId))
|
||||
.get();
|
||||
if (!candidate) {
|
||||
return { success: false, error: "Candidate not found" };
|
||||
}
|
||||
if (candidate.threadId !== threadId) {
|
||||
return { success: false, error: "Candidate not in thread" };
|
||||
}
|
||||
|
||||
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
|
||||
const category = tx.select({ id: categories.id }).from(categories)
|
||||
.where(eq(categories.id, candidate.categoryId)).get();
|
||||
const safeCategoryId = category ? candidate.categoryId : 1;
|
||||
// 3. Verify categoryId still exists, fallback to Uncategorized (id=1)
|
||||
const category = tx
|
||||
.select({ id: categories.id })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, candidate.categoryId))
|
||||
.get();
|
||||
const safeCategoryId = category ? candidate.categoryId : 1;
|
||||
|
||||
// 4. Create collection item from candidate data
|
||||
const newItem = tx
|
||||
.insert(items)
|
||||
.values({
|
||||
name: candidate.name,
|
||||
weightGrams: candidate.weightGrams,
|
||||
priceCents: candidate.priceCents,
|
||||
categoryId: safeCategoryId,
|
||||
notes: candidate.notes,
|
||||
productUrl: candidate.productUrl,
|
||||
imageFilename: candidate.imageFilename,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
// 4. Create collection item from candidate data
|
||||
const newItem = tx
|
||||
.insert(items)
|
||||
.values({
|
||||
name: candidate.name,
|
||||
weightGrams: candidate.weightGrams,
|
||||
priceCents: candidate.priceCents,
|
||||
categoryId: safeCategoryId,
|
||||
notes: candidate.notes,
|
||||
productUrl: candidate.productUrl,
|
||||
imageFilename: candidate.imageFilename,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
// 5. Archive the thread
|
||||
tx.update(threads)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedCandidateId: candidateId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(threads.id, threadId))
|
||||
.run();
|
||||
// 5. Archive the thread
|
||||
tx.update(threads)
|
||||
.set({
|
||||
status: "resolved",
|
||||
resolvedCandidateId: candidateId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(threads.id, threadId))
|
||||
.run();
|
||||
|
||||
return { success: true, item: newItem };
|
||||
});
|
||||
return { success: true, item: newItem };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { items, categories } from "../../db/schema.ts";
|
||||
import { db as prodDb } from "../../db/index.ts";
|
||||
import { categories, items } from "../../db/schema.ts";
|
||||
|
||||
type Db = typeof prodDb;
|
||||
|
||||
export function getCategoryTotals(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
categoryId: items.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.groupBy(items.categoryId)
|
||||
.all();
|
||||
return db
|
||||
.select({
|
||||
categoryId: items.categoryId,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.innerJoin(categories, eq(items.categoryId, categories.id))
|
||||
.groupBy(items.categoryId)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getGlobalTotals(db: Db = prodDb) {
|
||||
return db
|
||||
.select({
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.get();
|
||||
return db
|
||||
.select({
|
||||
totalWeight: sql<number>`COALESCE(SUM(${items.weightGrams}), 0)`,
|
||||
totalCost: sql<number>`COALESCE(SUM(${items.priceCents}), 0)`,
|
||||
itemCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(items)
|
||||
.get();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user