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:
2026-04-13 18:06:48 +02:00
parent 7d6c548811
commit 3df9eece83
4 changed files with 165 additions and 0 deletions

View File

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

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

View 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,
}));
}

View File

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