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 { authRoutes } from "./routes/auth.ts";
|
||||||
import { categoryRoutes } from "./routes/categories.ts";
|
import { categoryRoutes } from "./routes/categories.ts";
|
||||||
import { discoveryRoutes } from "./routes/discovery.ts";
|
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||||
|
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
|
||||||
import { globalItemRoutes } from "./routes/global-items.ts";
|
import { globalItemRoutes } from "./routes/global-items.ts";
|
||||||
import { imageRoutes } from "./routes/images.ts";
|
import { imageRoutes } from "./routes/images.ts";
|
||||||
import { itemRoutes } from "./routes/items.ts";
|
import { itemRoutes } from "./routes/items.ts";
|
||||||
|
import { marketPriceRoutes } from "./routes/market-prices.ts";
|
||||||
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
|
import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts";
|
||||||
import { onboardingRoutes } from "./routes/onboarding.ts";
|
import { onboardingRoutes } from "./routes/onboarding.ts";
|
||||||
import { profileRoutes } from "./routes/profiles.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)
|
// Skip public global-items endpoint (GET /api/global-items)
|
||||||
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/global-items") && c.req.method === "GET")
|
||||||
return next();
|
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
|
// All other methods require auth for userId resolution
|
||||||
return requireAuth(c, next);
|
return requireAuth(c, next);
|
||||||
});
|
});
|
||||||
@@ -230,6 +238,8 @@ app.route("/api/discovery", discoveryRoutes);
|
|||||||
app.route("/api/global-items", globalItemRoutes);
|
app.route("/api/global-items", globalItemRoutes);
|
||||||
app.route("/api/onboarding", onboardingRoutes);
|
app.route("/api/onboarding", onboardingRoutes);
|
||||||
app.route("/api/tags", tagRoutes);
|
app.route("/api/tags", tagRoutes);
|
||||||
|
app.route("/api/exchange-rates", exchangeRateRoutes);
|
||||||
|
app.route("/api/market-prices", marketPriceRoutes);
|
||||||
|
|
||||||
// MCP server (conditionally mounted)
|
// MCP server (conditionally mounted)
|
||||||
if (process.env.GEARBOX_MCP !== "false") {
|
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}
|
${items.priceCents}
|
||||||
)`.as("price_cents"),
|
)`.as("price_cents"),
|
||||||
purchasePriceCents: items.purchasePriceCents,
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
priceCurrency: items.priceCurrency,
|
||||||
quantity: items.quantity,
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
@@ -73,6 +74,7 @@ export async function getItemById(db: Db, userId: number, id: number) {
|
|||||||
${items.priceCents}
|
${items.priceCents}
|
||||||
)`.as("price_cents"),
|
)`.as("price_cents"),
|
||||||
purchasePriceCents: items.purchasePriceCents,
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
priceCurrency: items.priceCurrency,
|
||||||
quantity: items.quantity,
|
quantity: items.quantity,
|
||||||
categoryId: items.categoryId,
|
categoryId: items.categoryId,
|
||||||
notes: items.notes,
|
notes: items.notes,
|
||||||
@@ -139,6 +141,7 @@ export async function createItem(
|
|||||||
imageSourceUrl: data.imageSourceUrl ?? null,
|
imageSourceUrl: data.imageSourceUrl ?? null,
|
||||||
globalItemId: data.globalItemId ?? null,
|
globalItemId: data.globalItemId ?? null,
|
||||||
purchasePriceCents: data.purchasePriceCents ?? null,
|
purchasePriceCents: data.purchasePriceCents ?? null,
|
||||||
|
priceCurrency: data.priceCurrency ?? null,
|
||||||
})
|
})
|
||||||
.returning();
|
.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,
|
pros: threadCandidates.pros,
|
||||||
cons: threadCandidates.cons,
|
cons: threadCandidates.cons,
|
||||||
globalItemId: threadCandidates.globalItemId,
|
globalItemId: threadCandidates.globalItemId,
|
||||||
|
foundPriceCents: threadCandidates.foundPriceCents,
|
||||||
|
foundPriceCurrency: threadCandidates.foundPriceCurrency,
|
||||||
|
foundPriceDate: threadCandidates.foundPriceDate,
|
||||||
createdAt: threadCandidates.createdAt,
|
createdAt: threadCandidates.createdAt,
|
||||||
updatedAt: threadCandidates.updatedAt,
|
updatedAt: threadCandidates.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
@@ -209,6 +212,11 @@ export async function createCandidate(
|
|||||||
cons: data.cons ?? null,
|
cons: data.cons ?? null,
|
||||||
sortOrder: nextSortOrder,
|
sortOrder: nextSortOrder,
|
||||||
globalItemId: data.globalItemId ?? null,
|
globalItemId: data.globalItemId ?? null,
|
||||||
|
foundPriceCents: data.foundPriceCents ?? null,
|
||||||
|
foundPriceCurrency: data.foundPriceCurrency ?? null,
|
||||||
|
foundPriceDate: data.foundPriceDate
|
||||||
|
? new Date(data.foundPriceDate)
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -231,6 +239,9 @@ export async function updateCandidate(
|
|||||||
status: "researching" | "ordered" | "arrived";
|
status: "researching" | "ordered" | "arrived";
|
||||||
pros: string;
|
pros: string;
|
||||||
cons: string;
|
cons: string;
|
||||||
|
foundPriceCents: number;
|
||||||
|
foundPriceCurrency: string;
|
||||||
|
foundPriceDate: string;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
// Verify the candidate's parent thread belongs to this user
|
// Verify the candidate's parent thread belongs to this user
|
||||||
|
|||||||
Reference in New Issue
Block a user