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) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 18:05:24 +02:00
parent 7eb5335a88
commit 52dce7b72b
6 changed files with 153 additions and 0 deletions

View File

@@ -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") {

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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