Files
LudosData/backend/src/LudosData.Api/Contracts/GameContracts.cs
T
ckochandClaude Opus 5 ca70bcef34 Add market value: tiered prices and an eBay Browse provider
Researched the options first. PriceCharting is the standard for retro prices
but requires a paid subscription for both its API and its bulk download.
eBay's sold-price data sits behind the Marketplace Insights API, which is a
limited release closed to new developers. The free game-price APIs cover
current digital storefronts, not physical retro copies. So there is no free
route to sold prices, and this uses eBay Browse — active listings, which are
asking prices, labelled as such rather than presented as valuations.

Schema now holds three prices per game (loose, CIB, new), with marketValue
as whichever tier matches that copy's condition. Changing a condition
re-prices from the stored tiers with no further lookup, and the dashboard
can later show both actual value and what a collection would be worth
complete.

The judgement lives in classification and aggregation, both pure and both
tested without credentials:

  * listings are sorted into tiers from their titles, and accessories,
    reproductions and multi-game lots are discarded — a "box only" listing
    at $45 counted as a copy would halve the loose estimate for a $130 cart
  * the discard qualifier is required. The first version matched a bare
    "box", which threw out "complete in box" and "with box and manual",
    i.e. most of the CIB tier, while keeping exactly the listings the
    filter existed to remove. A test asserting on tiers rather than counts
    caught it.
  * median with an interquartile trim, since one optimist asking 50x moves
    a mean and not a median
  * sample counts travel with the estimate, because a tier drawn from two
    listings warrants less confidence than one drawn from thirty

Credentials are optional: with none set, /api/prices/status reports
configured=false and refresh answers 503 with instructions, while the rest
of the app is unaffected.

101 backend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:21:32 -04:00

135 lines
4.7 KiB
C#

using System.ComponentModel.DataAnnotations;
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total)
{
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
}
/// <summary>
/// A game as returned to the client. <c>Art</c> is the stored filename; <c>ArtUrl</c>
/// is the ready-to-use URL built server-side, so the client never has to
/// string-concatenate upload paths the way the old grid did.
/// </summary>
public record GameResponse(
int Id,
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? ArtUrl,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished,
int? Rating,
string? Notes,
GameCondition Condition,
GameRegion Region,
decimal? PurchasePrice,
DateOnly? PurchaseDate,
decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt,
string? MarketValueSource,
decimal? ValueLoose,
decimal? ValueCib,
decimal? ValueNew,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// Create/update payload. Deliberately has no Id and no OwnerId — the route supplies
/// the former and the JWT the latter, so neither can be spoofed by the client.
/// </summary>
public record GameRequest
{
[Required(AllowEmptyStrings = false), MaxLength(200)]
public string Title { get; init; } = string.Empty;
[MaxLength(50)] public string? System { get; init; }
[MaxLength(50)] public string? Genre { get; init; }
[MaxLength(50)] public string? Year { get; init; }
[MaxLength(100)] public string? Developer { get; init; }
[MaxLength(100)] public string? Publisher { get; init; }
[MaxLength(200)] public string? Art { get; init; }
[MaxLength(10_000)] public string? Description { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
[Range(1, 10)] public int? Rating { get; init; }
[MaxLength(10_000)] public string? Notes { get; init; }
public GameCondition Condition { get; init; } = GameCondition.Unspecified;
public GameRegion Region { get; init; } = GameRegion.Unspecified;
[Range(0, 1_000_000)] public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
/// <summary>
/// Current estimated resale value. Accepted here so a figure can be entered
/// by hand; a price feed will later write the same field, stamping
/// MarketValueUpdatedAt and MarketValueSource as it goes.
/// </summary>
[Range(0, 1_000_000)] public decimal? MarketValue { get; init; }
[MaxLength(100)] public string? MarketValueSource { get; init; }
[Range(0, 1_000_000)] public decimal? ValueLoose { get; init; }
[Range(0, 1_000_000)] public decimal? ValueCib { get; init; }
[Range(0, 1_000_000)] public decimal? ValueNew { get; init; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
public record GameQuery
{
/// <summary>Free-text match against title, developer and publisher.</summary>
public string? Search { get; init; }
public string? System { get; init; }
public string? Genre { get; init; }
public bool? Own { get; init; }
public bool? Dumped { get; init; }
public bool? Played { get; init; }
public bool? Finished { get; init; }
public GameCondition? Condition { get; init; }
public GameRegion? Region { get; init; }
/// <summary>Lowest personal score to include. Unrated games are excluded when set.</summary>
[Range(1, 10)] public int? MinRating { get; init; }
/// <summary>Restrict to games that do, or do not, have a market value recorded.</summary>
public bool? HasValue { get; init; }
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
[Range(1, 100)] public int PageSize { get; init; } = 20;
/// <summary>
/// One of: title, system, genre, year, developer, publisher, rating,
/// value, price, purchased, created, updated.
/// </summary>
public string Sort { get; init; } = "title";
/// <summary>"asc" or "desc".</summary>
public string Dir { get; init; } = "asc";
}
/// <summary>Distinct values present in the user's library, for filter dropdowns.</summary>
public record FacetsResponse(IReadOnlyList<string> Systems, IReadOnlyList<string> Genres);
public record UploadResponse(string FileName, string Url);