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>
145 lines
5.3 KiB
C#
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; }
|
|
}
|