feat(33-04): add community price service, API routes, and setup currency metadata
- Create community-price.service.ts with ownership validation, upsert, median aggregation - Create community-prices route (GET stats public, POST requires auth + ownership) - Register community-prices route with public GET access - Add priceCurrency to both getSetupWithItems and getSetupWithItemsById - Aggregation uses PERCENTILE_CONT(0.5) with 3-report minimum threshold Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { createRateLimit } from "./middleware/rateLimit.ts";
|
|||||||
import { accountRoutes } from "./routes/account.ts";
|
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 { communityPriceRoutes } from "./routes/community-prices.ts";
|
||||||
import { discoveryRoutes } from "./routes/discovery.ts";
|
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||||
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
|
import { exchangeRateRoutes } from "./routes/exchange-rates.ts";
|
||||||
import { globalItemRoutes } from "./routes/global-items.ts";
|
import { globalItemRoutes } from "./routes/global-items.ts";
|
||||||
@@ -219,6 +220,9 @@ app.use("/api/*", async (c, next) => {
|
|||||||
// Skip public market prices read endpoint (GET /api/market-prices)
|
// Skip public market prices read endpoint (GET /api/market-prices)
|
||||||
if (c.req.path.startsWith("/api/market-prices") && c.req.method === "GET")
|
if (c.req.path.startsWith("/api/market-prices") && c.req.method === "GET")
|
||||||
return next();
|
return next();
|
||||||
|
// Skip public community prices read endpoint (GET /api/community-prices)
|
||||||
|
if (c.req.path.startsWith("/api/community-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);
|
||||||
});
|
});
|
||||||
@@ -240,6 +244,7 @@ app.route("/api/onboarding", onboardingRoutes);
|
|||||||
app.route("/api/tags", tagRoutes);
|
app.route("/api/tags", tagRoutes);
|
||||||
app.route("/api/exchange-rates", exchangeRateRoutes);
|
app.route("/api/exchange-rates", exchangeRateRoutes);
|
||||||
app.route("/api/market-prices", marketPriceRoutes);
|
app.route("/api/market-prices", marketPriceRoutes);
|
||||||
|
app.route("/api/community-prices", communityPriceRoutes);
|
||||||
|
|
||||||
// MCP server (conditionally mounted)
|
// MCP server (conditionally mounted)
|
||||||
if (process.env.GEARBOX_MCP !== "false") {
|
if (process.env.GEARBOX_MCP !== "false") {
|
||||||
|
|||||||
48
src/server/routes/community-prices.ts
Normal file
48
src/server/routes/community-prices.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { submitCommunityPriceSchema } from "../../shared/schemas.ts";
|
||||||
|
import {
|
||||||
|
getCommunityPriceStats,
|
||||||
|
submitCommunityPrice,
|
||||||
|
} from "../services/community-price.service.ts";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// GET /:globalItemId — returns community price stats for a catalog item (public)
|
||||||
|
app.get("/:globalItemId", async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const globalItemId = Number(c.req.param("globalItemId"));
|
||||||
|
if (Number.isNaN(globalItemId))
|
||||||
|
return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
|
||||||
|
const market = c.req.query("market");
|
||||||
|
const stats = await getCommunityPriceStats(db, globalItemId, market);
|
||||||
|
return c.json(stats);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST / — submit a community price (authenticated, ownership validated)
|
||||||
|
app.post("/", zValidator("json", submitCommunityPriceSchema), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId");
|
||||||
|
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const data = c.req.valid("json");
|
||||||
|
const result = await submitCommunityPrice(db, {
|
||||||
|
...data,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"You must own an item linked to this catalog entry to submit a price",
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { app as communityPriceRoutes };
|
||||||
110
src/server/services/community-price.service.ts
Normal file
110
src/server/services/community-price.service.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import type { db as prodDb } from "../../db/index.ts";
|
||||||
|
import { communityPrices, items } from "../../db/schema.ts";
|
||||||
|
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user owns an item linked to a given global item.
|
||||||
|
* Per D-05: community price submissions require collection ownership.
|
||||||
|
*/
|
||||||
|
export async function validateOwnership(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
globalItemId: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(items)
|
||||||
|
.where(
|
||||||
|
and(eq(items.userId, userId), eq(items.globalItemId, globalItemId)),
|
||||||
|
);
|
||||||
|
return (row?.count ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit or update a community price for a global item.
|
||||||
|
* Returns null if ownership validation fails.
|
||||||
|
*/
|
||||||
|
export async function submitCommunityPrice(
|
||||||
|
db: Db,
|
||||||
|
data: {
|
||||||
|
globalItemId: number;
|
||||||
|
userId: number;
|
||||||
|
market: string;
|
||||||
|
currency: string;
|
||||||
|
priceCents: number;
|
||||||
|
priceDate?: string | null;
|
||||||
|
sourceType: "purchased" | "researched";
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
// Per D-05: ownership validation
|
||||||
|
const ownsItem = await validateOwnership(db, data.userId, data.globalItemId);
|
||||||
|
if (!ownsItem) return null;
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(communityPrices)
|
||||||
|
.values({
|
||||||
|
globalItemId: data.globalItemId,
|
||||||
|
userId: data.userId,
|
||||||
|
market: data.market,
|
||||||
|
currency: data.currency,
|
||||||
|
priceCents: data.priceCents,
|
||||||
|
priceDate: data.priceDate ? new Date(data.priceDate) : null,
|
||||||
|
sourceType: data.sourceType,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
communityPrices.globalItemId,
|
||||||
|
communityPrices.userId,
|
||||||
|
communityPrices.sourceType,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
priceCents: data.priceCents,
|
||||||
|
priceDate: data.priceDate ? new Date(data.priceDate) : null,
|
||||||
|
market: data.market,
|
||||||
|
currency: data.currency,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get community price statistics for a global item.
|
||||||
|
* Per D-21: returns per-market median and report count.
|
||||||
|
* Only returns stats where report count >= 3.
|
||||||
|
*/
|
||||||
|
export async function getCommunityPriceStats(
|
||||||
|
db: Db,
|
||||||
|
globalItemId: number,
|
||||||
|
market?: string,
|
||||||
|
) {
|
||||||
|
const conditions = [eq(communityPrices.globalItemId, globalItemId)];
|
||||||
|
if (market) {
|
||||||
|
conditions.push(eq(communityPrices.market, market));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
market: communityPrices.market,
|
||||||
|
currency: communityPrices.currency,
|
||||||
|
medianPrice:
|
||||||
|
sql<number>`PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ${communityPrices.priceCents})`.as(
|
||||||
|
"median_price",
|
||||||
|
),
|
||||||
|
reportCount: sql<number>`CAST(COUNT(*) AS INT)`.as("report_count"),
|
||||||
|
})
|
||||||
|
.from(communityPrices)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.groupBy(communityPrices.market, communityPrices.currency)
|
||||||
|
.having(sql`COUNT(*) >= 3`);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
market: r.market,
|
||||||
|
currency: r.currency,
|
||||||
|
medianPrice: Math.round(r.medianPrice),
|
||||||
|
reportCount: r.reportCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ export async function getSetupWithItems(
|
|||||||
)`.as("image_filename"),
|
)`.as("image_filename"),
|
||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
purchasePriceCents: items.purchasePriceCents,
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
priceCurrency: items.priceCurrency,
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
@@ -153,6 +154,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
|
|||||||
)`.as("image_filename"),
|
)`.as("image_filename"),
|
||||||
globalItemId: items.globalItemId,
|
globalItemId: items.globalItemId,
|
||||||
purchasePriceCents: items.purchasePriceCents,
|
purchasePriceCents: items.purchasePriceCents,
|
||||||
|
priceCurrency: items.priceCurrency,
|
||||||
createdAt: items.createdAt,
|
createdAt: items.createdAt,
|
||||||
updatedAt: items.updatedAt,
|
updatedAt: items.updatedAt,
|
||||||
categoryName: categories.name,
|
categoryName: categories.name,
|
||||||
|
|||||||
Reference in New Issue
Block a user