Files
LudosData/backend/src/LudosData.Api/Domain/Game.cs
T
ckochandClaude Opus 5 d34e6b4ced Make PriceCharting runs auditable and pin matches by product id
Two changes aimed at the first real run, since the integration cannot be
exercised here without a subscription.

Refresh now reports which product each game matched — name, console and the
source's id — next to the prices, and dry-run surfaces it before anything is
written. This is the failure that would otherwise go unnoticed: a lookup for
the DS "Chrono Trigger" resolving to the SNES original returns entirely
plausible numbers for the wrong game, and nothing in a bare price would say
so.

The matched id is then stored on the game, and later refreshes look it up
directly instead of repeating the title search. Cheaper, and stable — a
search that drifts to a different edition next month cannot silently
re-price something that was already matched correctly.

IPriceProvider takes an optional sourceId so this stays provider-agnostic.
eBay ignores it, having no stable per-product identifier in Browse.

139 backend tests.

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

145 lines
5.3 KiB
C#

using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Domain;
/// <summary>
/// A single entry in a user's game library. Mirrors the columns of the original
/// MySQL `games` table so the 2018 dump imports without transformation, with the
/// addition of ownership and audit fields.
/// </summary>
public class Game
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// <summary>Console/platform the game runs on, e.g. "SNES", "PS2".</summary>
[MaxLength(50)]
public string? System { get; set; }
[MaxLength(50)]
public string? Genre { get; set; }
/// <summary>
/// Release year. Kept as a string rather than an int: the original column was
/// varchar(50) and holds values like "" and "1996" — some entries were never
/// filled in, and a few real-world cases want ranges.
/// </summary>
[MaxLength(50)]
public string? Year { get; set; }
[MaxLength(100)]
public string? Developer { get; set; }
[MaxLength(100)]
public string? Publisher { get; set; }
/// <summary>Filename of the uploaded box art, relative to the owner's upload folder.</summary>
[MaxLength(200)]
public string? Art { get; set; }
public string? Description { get; set; }
public bool Own { get; set; }
public bool Dumped { get; set; }
public bool Played { get; set; }
public bool Finished { get; set; }
// ---- collector fields ------------------------------------------------
/// <summary>Personal score out of 10. Null means unrated, which is not zero.</summary>
[Range(1, 10)]
public int? Rating { get; set; }
/// <summary>
/// Free-form personal notes. Kept separate from Description, which is
/// derived from an external source and may be overwritten by the enricher.
/// </summary>
public string? Notes { get; set; }
public GameCondition Condition { get; set; } = GameCondition.Unspecified;
public GameRegion Region { get; set; } = GameRegion.Unspecified;
/// <summary>What was paid for this copy. A fixed historical fact.</summary>
public decimal? PurchasePrice { get; set; }
public DateOnly? PurchaseDate { get; set; }
// ---- market value ----------------------------------------------------
//
// Distinct from PurchasePrice: an estimate of what a copy sells for now,
// expected to be refreshed from a price feed. Stored with the moment it was
// captured and where it came from, because a figure with neither is not
// something you can reason about — a total is only as good as its staleness.
/// <summary>
/// The figure used for totals, sorting and display: the tier matching this
/// copy's condition when tiers are known, otherwise whatever was entered by
/// hand. Denormalised deliberately — SQLite can sort and SUM a column, and
/// recomputing a CASE across three nullable columns in every query is worse
/// than keeping one value in step via <see cref="RecalculateEffectiveValue"/>.
/// </summary>
public decimal? MarketValue { get; set; }
public DateTimeOffset? MarketValueUpdatedAt { get; set; }
/// <summary>Provenance, e.g. a price feed's name, or "manual".</summary>
[MaxLength(100)]
public string? MarketValueSource { get; set; }
// Price sources quote per condition, and the spread between them is
// routinely a multiple. Keeping all three means changing a copy's condition
// re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete".
/// <summary>
/// The price source's identifier for this game, kept after the first match.
/// Later refreshes look it up directly instead of repeating a fuzzy search,
/// which makes them both cheaper and stable — a title search that drifts to
/// a different edition next month would silently re-price the wrong thing.
/// </summary>
[MaxLength(100)]
public string? PriceSourceId { get; set; }
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
/// <summary>The tier that applies to a given condition, if it is known.</summary>
public decimal? TierFor(GameCondition condition) => condition switch
{
GameCondition.Sealed => ValueNew,
GameCondition.Cib => ValueCib,
GameCondition.Loose => ValueLoose,
// Digital has no physical tier, and an unspecified condition is most
// often a loose cart or disc, which is also the conservative estimate.
_ => ValueLoose,
};
/// <summary>
/// Brings <see cref="MarketValue"/> back in step with the tiers. A hand-typed
/// figure survives: it is only replaced once a source has supplied tiers.
/// </summary>
public void RecalculateEffectiveValue()
{
var tier = TierFor(Condition);
if (tier is not null)
{
MarketValue = tier;
}
}
/// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
/// </summary>
[Required]
public string OwnerId { get; set; } = string.Empty;
public AppUser? Owner { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}