using System.ComponentModel.DataAnnotations; namespace LudosData.Api.Domain; /// /// 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. /// public class Game { public int Id { get; set; } [Required] [MaxLength(200)] public string Title { get; set; } = string.Empty; /// Console/platform the game runs on, e.g. "SNES", "PS2". [MaxLength(50)] public string? System { get; set; } [MaxLength(50)] public string? Genre { get; set; } /// /// 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. /// [MaxLength(50)] public string? Year { get; set; } [MaxLength(100)] public string? Developer { get; set; } [MaxLength(100)] public string? Publisher { get; set; } /// Filename of the uploaded box art, relative to the owner's upload folder. [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 ------------------------------------------------ /// Personal score out of 10. Null means unrated, which is not zero. [Range(1, 10)] public int? Rating { get; set; } /// /// Free-form personal notes. Kept separate from Description, which is /// derived from an external source and may be overwritten by the enricher. /// public string? Notes { get; set; } public GameCondition Condition { get; set; } = GameCondition.Unspecified; public GameRegion Region { get; set; } = GameRegion.Unspecified; /// What was paid for this copy. A fixed historical fact. 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. /// /// 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 . /// public decimal? MarketValue { get; set; } public DateTimeOffset? MarketValueUpdatedAt { get; set; } /// Provenance, e.g. a price feed's name, or "manual". [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". /// /// 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. /// [MaxLength(100)] public string? PriceSourceId { get; set; } public decimal? ValueLoose { get; set; } public decimal? ValueCib { get; set; } public decimal? ValueNew { get; set; } /// The tier that applies to a given condition, if it is known. 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, }; /// /// Brings back in step with the tiers. A hand-typed /// figure survives: it is only replaced once a source has supplied tiers. /// public void RecalculateEffectiveValue() { var tier = TierFor(Condition); if (tier is not null) { MarketValue = tier; } } /// /// Owning user. Every query is filtered on this server-side, from the JWT subject — /// it is never accepted from the client. /// [Required] public string OwnerId { get; set; } = string.Empty; public AppUser? Owner { get; set; } public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; } }