From 52dce7b72b11d4c82e82e9c0cbcf9de24f9d0b96 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 18:05:24 +0200 Subject: [PATCH] feat(33-03): add market prices API, exchange rates endpoint, currency context - Create market-price.service.ts with getMarketPrices, upsertMarketPrice - Create exchange-rates route (GET /api/exchange-rates, public) - Create market-prices route (GET/POST /api/market-prices/global-items/:id/prices) - Register new routes in server index with public GET access - Add priceCurrency to item service getAllItems/getItemById/createItem - Add foundPriceCents/Currency/Date to thread candidate select and create/update Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server/index.ts | 10 +++ src/server/routes/exchange-rates.ts | 16 +++++ src/server/routes/market-prices.ts | 42 ++++++++++++ src/server/services/item.service.ts | 3 + src/server/services/market-price.service.ts | 71 +++++++++++++++++++++ src/server/services/thread.service.ts | 11 ++++ 6 files changed, 153 insertions(+) create mode 100644 src/server/routes/exchange-rates.ts create mode 100644 src/server/routes/market-prices.ts create mode 100644 src/server/services/market-price.service.ts diff --git a/src/server/index.ts b/src/server/index.ts index 3a5e05c..4b48ea9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -15,9 +15,11 @@ import { accountRoutes } from "./routes/account.ts"; import { authRoutes } from "./routes/auth.ts"; import { categoryRoutes } from "./routes/categories.ts"; import { discoveryRoutes } from "./routes/discovery.ts"; +import { exchangeRateRoutes } from "./routes/exchange-rates.ts"; import { globalItemRoutes } from "./routes/global-items.ts"; import { imageRoutes } from "./routes/images.ts"; import { itemRoutes } from "./routes/items.ts"; +import { marketPriceRoutes } from "./routes/market-prices.ts"; import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { onboardingRoutes } from "./routes/onboarding.ts"; import { profileRoutes } from "./routes/profiles.ts"; @@ -211,6 +213,12 @@ app.use("/api/*", async (c, next) => { // Skip public global-items endpoint (GET /api/global-items) if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET") return next(); + // Skip public exchange rates endpoint (GET /api/exchange-rates) + if (c.req.path.startsWith("/api/exchange-rates") && c.req.method === "GET") + return next(); + // Skip public market prices read endpoint (GET /api/market-prices) + if (c.req.path.startsWith("/api/market-prices") && c.req.method === "GET") + return next(); // All other methods require auth for userId resolution return requireAuth(c, next); }); @@ -230,6 +238,8 @@ app.route("/api/discovery", discoveryRoutes); app.route("/api/global-items", globalItemRoutes); app.route("/api/onboarding", onboardingRoutes); app.route("/api/tags", tagRoutes); +app.route("/api/exchange-rates", exchangeRateRoutes); +app.route("/api/market-prices", marketPriceRoutes); // MCP server (conditionally mounted) if (process.env.GEARBOX_MCP !== "false") { diff --git a/src/server/routes/exchange-rates.ts b/src/server/routes/exchange-rates.ts new file mode 100644 index 0000000..516be3e --- /dev/null +++ b/src/server/routes/exchange-rates.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import { getExchangeRates } from "../services/currency.service.ts"; + +const app = new Hono(); + +// GET / — returns current exchange rates (public, no auth required) +app.get("/", async (c) => { + try { + const rates = await getExchangeRates(); + return c.json(rates); + } catch (error) { + return c.json({ error: "Exchange rates unavailable" }, 503); + } +}); + +export { app as exchangeRateRoutes }; diff --git a/src/server/routes/market-prices.ts b/src/server/routes/market-prices.ts new file mode 100644 index 0000000..7e50392 --- /dev/null +++ b/src/server/routes/market-prices.ts @@ -0,0 +1,42 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { upsertMarketPriceSchema } from "../../shared/schemas.ts"; +import { + getMarketPrices, + upsertMarketPrice, +} from "../services/market-price.service.ts"; + +const app = new Hono(); + +// GET /global-items/:id/prices — returns market prices for a catalog item (public) +app.get("/global-items/:id/prices", async (c) => { + const db = c.get("db"); + const id = Number(c.req.param("id")); + if (Number.isNaN(id)) return c.json({ error: "Invalid ID" }, 400); + + const prices = await getMarketPrices(db, id); + return c.json({ marketPrices: prices }); +}); + +// POST /global-items/:id/prices — create/update a market price (authenticated) +app.post( + "/global-items/:id/prices", + zValidator("json", upsertMarketPriceSchema), + async (c) => { + const db = c.get("db"); + const globalItemId = Number(c.req.param("id")); + if (Number.isNaN(globalItemId)) + return c.json({ error: "Invalid ID" }, 400); + + const data = c.req.valid("json"); + const result = await upsertMarketPrice(db, { + globalItemId, + ...data, + }); + + if (!result) return c.json({ error: "Global item not found" }, 404); + return c.json(result); + }, +); + +export { app as marketPriceRoutes }; diff --git a/src/server/services/item.service.ts b/src/server/services/item.service.ts index ff3e577..fd1cb2f 100644 --- a/src/server/services/item.service.ts +++ b/src/server/services/item.service.ts @@ -25,6 +25,7 @@ export async function getAllItems(db: Db, userId: number) { ${items.priceCents} )`.as("price_cents"), purchasePriceCents: items.purchasePriceCents, + priceCurrency: items.priceCurrency, quantity: items.quantity, categoryId: items.categoryId, notes: items.notes, @@ -73,6 +74,7 @@ export async function getItemById(db: Db, userId: number, id: number) { ${items.priceCents} )`.as("price_cents"), purchasePriceCents: items.purchasePriceCents, + priceCurrency: items.priceCurrency, quantity: items.quantity, categoryId: items.categoryId, notes: items.notes, @@ -139,6 +141,7 @@ export async function createItem( imageSourceUrl: data.imageSourceUrl ?? null, globalItemId: data.globalItemId ?? null, purchasePriceCents: data.purchasePriceCents ?? null, + priceCurrency: data.priceCurrency ?? null, }) .returning(); diff --git a/src/server/services/market-price.service.ts b/src/server/services/market-price.service.ts new file mode 100644 index 0000000..4ac97c5 --- /dev/null +++ b/src/server/services/market-price.service.ts @@ -0,0 +1,71 @@ +import { and, eq } from "drizzle-orm"; +import type { db as prodDb } from "../../db/index.ts"; +import { globalItems, marketPrices } from "../../db/schema.ts"; + +type Db = typeof prodDb; + +export async function getMarketPrices(db: Db, globalItemId: number) { + return db + .select() + .from(marketPrices) + .where(eq(marketPrices.globalItemId, globalItemId)) + .orderBy(marketPrices.market); +} + +export async function getMarketPricesForMarket( + db: Db, + globalItemId: number, + market: string, +) { + return db + .select() + .from(marketPrices) + .where( + and( + eq(marketPrices.globalItemId, globalItemId), + eq(marketPrices.market, market), + ), + ); +} + +export async function upsertMarketPrice( + db: Db, + data: { + globalItemId: number; + market: string; + currency: string; + priceCents: number; + source?: string; + }, +) { + // Verify global item exists + const [gi] = await db + .select({ id: globalItems.id }) + .from(globalItems) + .where(eq(globalItems.id, data.globalItemId)); + if (!gi) return null; + + const [row] = await db + .insert(marketPrices) + .values({ + globalItemId: data.globalItemId, + market: data.market, + currency: data.currency, + priceCents: data.priceCents, + source: data.source ?? null, + }) + .onConflictDoUpdate({ + target: [ + marketPrices.globalItemId, + marketPrices.market, + marketPrices.currency, + ], + set: { + priceCents: data.priceCents, + source: data.source ?? null, + }, + }) + .returning(); + + return row; +} diff --git a/src/server/services/thread.service.ts b/src/server/services/thread.service.ts index 2d36c3e..13f517c 100644 --- a/src/server/services/thread.service.ts +++ b/src/server/services/thread.service.ts @@ -107,6 +107,9 @@ export async function getThreadWithCandidates( pros: threadCandidates.pros, cons: threadCandidates.cons, globalItemId: threadCandidates.globalItemId, + foundPriceCents: threadCandidates.foundPriceCents, + foundPriceCurrency: threadCandidates.foundPriceCurrency, + foundPriceDate: threadCandidates.foundPriceDate, createdAt: threadCandidates.createdAt, updatedAt: threadCandidates.updatedAt, categoryName: categories.name, @@ -209,6 +212,11 @@ export async function createCandidate( cons: data.cons ?? null, sortOrder: nextSortOrder, globalItemId: data.globalItemId ?? null, + foundPriceCents: data.foundPriceCents ?? null, + foundPriceCurrency: data.foundPriceCurrency ?? null, + foundPriceDate: data.foundPriceDate + ? new Date(data.foundPriceDate) + : null, }) .returning(); @@ -231,6 +239,9 @@ export async function updateCandidate( status: "researching" | "ordered" | "arrived"; pros: string; cons: string; + foundPriceCents: number; + foundPriceCurrency: string; + foundPriceDate: string; }>, ) { // Verify the candidate's parent thread belongs to this user