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:
@@ -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") {
|
||||
|
||||
16
src/server/routes/exchange-rates.ts
Normal file
16
src/server/routes/exchange-rates.ts
Normal 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 };
|
||||
42
src/server/routes/market-prices.ts
Normal file
42
src/server/routes/market-prices.ts
Normal 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 };
|
||||
@@ -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();
|
||||
|
||||
|
||||
71
src/server/services/market-price.service.ts
Normal file
71
src/server/services/market-price.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user