← back to Govarbitrage
src/lib/listing-detail.ts
82 lines
import { prisma } from "@/lib/db";
import { getCurrentTier, moneyMathVisible } from "@/lib/current-user";
import { Prisma, type Tier } from "@prisma/client";
// Single source of truth for the listing DETAIL payload and its tier gate.
// Visibility depends on the resolved tier only, never on the client type. All
// current tiers (FREE included) show the full analysis, so the redaction below
// is dormant — retained so a future gated tier can null the money-math
// relations (costBreakdown, research valuations, scores, comparables, buyer
// leads) consistently with the list endpoint.
export type ListingDetail = NonNullable<Awaited<ReturnType<typeof queryListingDetail>>>;
function queryListingDetail(id: string) {
return prisma.listing.findUnique({
where: { id },
include: {
research: true,
costBreakdown: true,
scores: { orderBy: { value: "desc" } },
comparables: { orderBy: { price: "desc" } },
notes: { orderBy: { createdAt: "desc" } },
events: { orderBy: { createdAt: "desc" }, take: 20 },
buyerLeads: { orderBy: { createdAt: "desc" } },
buyerPage: true,
outcome: true,
},
});
}
// Root-cause fix (TK-10279, 2026-09-03): every money-math field on Listing /
// Research / CostBreakdown is a Prisma Decimal. JSON.stringify() (what
// NextResponse.json() uses) serializes a Decimal via its own toJSON(), which
// returns a STRING (e.g. "103.93"), not a JSON number. The LIST endpoint
// (src/lib/listings.ts flattenListing()) already runs every Decimal through
// num()/numN() so it emits real numbers — but this DETAIL path returned the
// raw Prisma object untouched, so its JSON carried numeric-looking strings.
// The mobile client's Number.isFinite() render guards (fmtUSD/fmtPct/fmtScore)
// correctly reject a string as non-finite and render "—" — so a fully
// populated listing (proven identical to the list's $183 net profit / 191%
// ROI numbers) showed an all-null Valuation table + a null Recommended Max
// Bid + "Current bid: —" on the detail screen only. Recursively converting
// every Decimal to a plain number before the route serializes it makes the
// detail payload's JSON shape match the list's (and match the mobile client's
// own `ListingDetail`/`CostBreakdown`/`Research` types, which already declare
// these fields as `number | null`).
export function decimalsToNumbers<T>(value: T): T {
if (value instanceof Prisma.Decimal) return value.toNumber() as unknown as T;
if (Array.isArray(value)) return value.map((v) => decimalsToNumbers(v)) as unknown as T;
if (value instanceof Date) return value;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = decimalsToNumbers(v);
}
return out as T;
}
return value;
}
export async function getGatedListingDetail(
id: string
): Promise<{ listing: ListingDetail | null; tier: Tier; gated: boolean }> {
const tier = await getCurrentTier();
const gated = !(await moneyMathVisible(tier));
const raw = await queryListingDetail(id);
const listing = raw ? decimalsToNumbers(raw) : raw;
if (!listing || !gated) return { listing, tier, gated };
const redacted: ListingDetail = {
...listing,
research: null,
costBreakdown: null,
scores: [],
comparables: [],
buyerLeads: [],
buyerPage: null,
outcome: null,
};
return { listing: redacted, tier, gated };
}