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 { authRoutes } from "./routes/auth.ts";
|
||||
import { categoryRoutes } from "./routes/categories.ts";
|
||||
import { communityPriceRoutes } from "./routes/community-prices.ts";
|
||||
import { discoveryRoutes } from "./routes/discovery.ts";
|
||||
import { exchangeRateRoutes } from "./routes/exchange-rates.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)
|
||||
if (c.req.path.startsWith("/api/market-prices") && c.req.method === "GET")
|
||||
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
|
||||
return requireAuth(c, next);
|
||||
});
|
||||
@@ -240,6 +244,7 @@ app.route("/api/onboarding", onboardingRoutes);
|
||||
app.route("/api/tags", tagRoutes);
|
||||
app.route("/api/exchange-rates", exchangeRateRoutes);
|
||||
app.route("/api/market-prices", marketPriceRoutes);
|
||||
app.route("/api/community-prices", communityPriceRoutes);
|
||||
|
||||
// MCP server (conditionally mounted)
|
||||
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"),
|
||||
globalItemId: items.globalItemId,
|
||||
purchasePriceCents: items.purchasePriceCents,
|
||||
priceCurrency: items.priceCurrency,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
@@ -153,6 +154,7 @@ export async function getSetupWithItemsById(db: Db, setupId: number) {
|
||||
)`.as("image_filename"),
|
||||
globalItemId: items.globalItemId,
|
||||
purchasePriceCents: items.purchasePriceCents,
|
||||
priceCurrency: items.priceCurrency,
|
||||
createdAt: items.createdAt,
|
||||
updatedAt: items.updatedAt,
|
||||
categoryName: categories.name,
|
||||
|
||||
Reference in New Issue
Block a user