diff --git a/src/server/index.ts b/src/server/index.ts index a22c592..0e6f2c9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ import { totalRoutes } from "./routes/totals.ts"; import { imageRoutes } from "./routes/images.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { threadRoutes } from "./routes/threads.ts"; +import { setupRoutes } from "./routes/setups.ts"; // Seed default data on startup seedDefaults(); @@ -25,6 +26,7 @@ app.route("/api/totals", totalRoutes); app.route("/api/images", imageRoutes); app.route("/api/settings", settingsRoutes); app.route("/api/threads", threadRoutes); +app.route("/api/setups", setupRoutes); // Serve uploaded images app.use("/uploads/*", serveStatic({ root: "./" })); diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts new file mode 100644 index 0000000..c553754 --- /dev/null +++ b/src/server/routes/setups.ts @@ -0,0 +1,84 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { + createSetupSchema, + updateSetupSchema, + syncSetupItemsSchema, +} from "../../shared/schemas.ts"; +import { + getAllSetups, + getSetupWithItems, + createSetup, + updateSetup, + deleteSetup, + syncSetupItems, + removeSetupItem, +} from "../services/setup.service.ts"; + +type Env = { Variables: { db?: any } }; + +const app = new Hono(); + +// Setup CRUD + +app.get("/", (c) => { + 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); +}); + +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); +}); + +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); +}); + +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 }); +}); + +// 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 setup = getSetupWithItems(db, id); + if (!setup) return c.json({ error: "Setup not found" }, 404); + + 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 }); +}); + +export { app as setupRoutes }; diff --git a/src/server/services/setup.service.ts b/src/server/services/setup.service.ts new file mode 100644 index 0000000..f19bdb5 --- /dev/null +++ b/src/server/services/setup.service.ts @@ -0,0 +1,111 @@ +import { eq, sql } from "drizzle-orm"; +import { setups, setupItems, items, categories } from "../../db/schema.ts"; +import { db as prodDb } from "../../db/index.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(); +} + +export function getAllSetups(db: Db = prodDb) { + return db + .select({ + id: setups.id, + name: setups.name, + createdAt: setups.createdAt, + updatedAt: setups.updatedAt, + itemCount: sql`COALESCE(( + SELECT COUNT(*) FROM setup_items + WHERE setup_items.setup_id = setups.id + ), 0)`.as("item_count"), + totalWeight: sql`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`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(); +} + +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 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, + categoryEmoji: categories.emoji, + }) + .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 }; +} + +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(); +} + +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; + + 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(); + + // 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(); +}