Add collector fields, including market value

Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:29:48 -04:00
co-authored by Claude Opus 5
parent b69a5c9d14
commit d5a0e42fed
29 changed files with 1585 additions and 105 deletions
@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
@@ -28,6 +30,15 @@ public record GameResponse(
bool Dumped,
bool Played,
bool Finished,
int? Rating,
string? Notes,
GameCondition Condition,
GameRegion Region,
decimal? PurchasePrice,
DateOnly? PurchaseDate,
decimal? MarketValue,
DateTimeOffset? MarketValueUpdatedAt,
string? MarketValueSource,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
@@ -52,6 +63,24 @@ public record GameRequest
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; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
@@ -68,12 +97,24 @@ public record GameQuery
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, created, updated.</summary>
/// <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>
@@ -1,3 +1,5 @@
using LudosData.Api.Domain;
namespace LudosData.Api.Contracts;
/// <summary>
@@ -28,6 +30,18 @@ public record ExportGame
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
// Collector fields travel with the export; a backup that quietly dropped
// ratings, notes and valuations would not be a backup.
public int? Rating { get; init; }
public string? Notes { get; init; }
public GameCondition Condition { get; init; }
public GameRegion Region { get; init; }
public decimal? PurchasePrice { get; init; }
public DateOnly? PurchaseDate { get; init; }
public decimal? MarketValue { get; init; }
public DateTimeOffset? MarketValueUpdatedAt { get; init; }
public string? MarketValueSource { get; init; }
}
/// <summary>Envelope written by the JSON exporter.</summary>
@@ -48,6 +48,18 @@ public class GamesController(
if (query.Played is { } played) q = q.Where(g => g.Played == played);
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
if (query.Condition is { } condition) q = q.Where(g => g.Condition == condition);
if (query.Region is { } region) q = q.Where(g => g.Region == region);
// An unrated game is not a zero-rated one, so it drops out of a
// minimum-rating filter rather than sorting to the bottom.
if (query.MinRating is { } minRating) q = q.Where(g => g.Rating >= minRating);
if (query.HasValue is { } hasValue)
{
q = hasValue ? q.Where(g => g.MarketValue != null) : q.Where(g => g.MarketValue == null);
}
var total = await q.CountAsync(ct);
q = ApplySort(q, query.Sort, query.Dir);
@@ -150,6 +162,10 @@ public class GamesController(
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
"rating" => descending ? q.OrderByDescending(g => g.Rating) : q.OrderBy(g => g.Rating),
"value" => descending ? q.OrderByDescending(g => g.MarketValue) : q.OrderBy(g => g.MarketValue),
"price" => descending ? q.OrderByDescending(g => g.PurchasePrice) : q.OrderBy(g => g.PurchasePrice),
"purchased" => descending ? q.OrderByDescending(g => g.PurchaseDate) : q.OrderBy(g => g.PurchaseDate),
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
@@ -170,10 +186,36 @@ public class GamesController(
game.Dumped = request.Dumped;
game.Played = request.Played;
game.Finished = request.Finished;
game.Rating = request.Rating;
game.Notes = request.Notes;
game.Condition = request.Condition;
game.Region = request.Region;
game.PurchasePrice = request.PurchasePrice;
game.PurchaseDate = request.PurchaseDate;
// Only stamp the valuation when the figure actually changes, so an
// unrelated edit does not make a stale price look freshly checked.
if (request.MarketValue != game.MarketValue)
{
game.MarketValue = request.MarketValue;
game.MarketValueUpdatedAt = request.MarketValue is null ? null : DateTimeOffset.UtcNow;
game.MarketValueSource = request.MarketValue is null
? null
: request.MarketValueSource?.Trim() ?? "manual";
}
else if (request.MarketValueSource is { } source && game.MarketValue is not null)
{
game.MarketValueSource = source.Trim();
}
}
private GameResponse ToResponse(Game g, string ownerId) => new(
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt);
g.Own, g.Dumped, g.Played, g.Finished,
g.Rating, g.Notes, g.Condition, g.Region,
g.PurchasePrice, g.PurchaseDate,
g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource,
g.CreatedAt, g.UpdatedAt);
}
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using LudosData.Api.Auth;
@@ -30,10 +31,18 @@ public class LibraryController(
[
"title", "system", "genre", "year", "developer", "publisher",
"description", "art", "own", "dumped", "played", "finished",
"rating", "notes", "condition", "region",
"purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt",
"marketValueSource",
];
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web) { WriteIndented = true };
// Must match the converter registered on the controllers, so an export
// written with enum names is readable by the importer.
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
// ---- export ----------------------------------------------------------
@@ -56,6 +65,16 @@ public class LibraryController(
g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Description, g.Art,
g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(),
g.Rating?.ToString(), g.Notes,
g.Condition == GameCondition.Unspecified ? null : g.Condition.ToString(),
g.Region == GameRegion.Unspecified ? null : g.Region.ToString(),
// Invariant culture throughout: a comma decimal separator would
// collide with the delimiter, and dates must not depend on locale.
g.PurchasePrice?.ToString(CultureInfo.InvariantCulture),
g.PurchaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
g.MarketValue?.ToString(CultureInfo.InvariantCulture),
g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture),
g.MarketValueSource,
}));
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
@@ -265,12 +284,48 @@ public class LibraryController(
Dumped = Flag(row, "dumped"),
Played = Flag(row, "played"),
Finished = Flag(row, "finished"),
Rating = ParseInt(Field(row, "rating")),
Notes = Field(row, "notes"),
Condition = ParseEnum<GameCondition>(Field(row, "condition")),
Region = ParseEnum<GameRegion>(Field(row, "region")),
PurchasePrice = ParseMoney(Field(row, "purchaseprice")),
PurchaseDate = ParseDate(Field(row, "purchasedate")),
MarketValue = ParseMoney(Field(row, "marketvalue")),
MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")),
MarketValueSource = Field(row, "marketvaluesource"),
});
}
return games;
}
// Parsers are forgiving: a spreadsheet round-trip is a normal way for these
// files to arrive, and one unreadable cell should not cost the whole row.
private static int? ParseInt(string? value) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
private static decimal? ParseMoney(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
// Tolerate a currency symbol and thousands separators from a spreadsheet.
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty);
return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)
? parsed : null;
}
private static DateOnly? ParseDate(string? value) =>
DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
? parsed : null;
private static DateTimeOffset? ParseTimestamp(string? value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
? parsed : null;
private static T ParseEnum<T>(string? value) where T : struct, Enum =>
Enum.TryParse<T>(value, ignoreCase: true, out var parsed) ? parsed : default;
private static string Key(string title, string? system) =>
$"{title.Trim().ToLowerInvariant()}{(system ?? string.Empty).Trim().ToLowerInvariant()}";
@@ -288,6 +343,19 @@ public class LibraryController(
target.Dumped = source.Dumped;
target.Played = source.Played;
target.Finished = source.Finished;
target.Rating = source.Rating;
target.Notes = Blank(source.Notes);
target.Condition = source.Condition;
target.Region = source.Region;
target.PurchasePrice = source.PurchasePrice;
target.PurchaseDate = source.PurchaseDate;
// The valuation's own timestamp is restored as recorded rather than
// reset to now: an import is a restore, not a fresh price check.
target.MarketValue = source.MarketValue;
target.MarketValueUpdatedAt = source.MarketValue is null ? null : source.MarketValueUpdatedAt;
target.MarketValueSource = source.MarketValue is null ? null : Blank(source.MarketValueSource);
}
private static string? Blank(string? value) =>
@@ -307,5 +375,14 @@ public class LibraryController(
Dumped = g.Dumped,
Played = g.Played,
Finished = g.Finished,
Rating = g.Rating,
Notes = g.Notes,
Condition = g.Condition,
Region = g.Region,
PurchasePrice = g.PurchasePrice,
PurchaseDate = g.PurchaseDate,
MarketValue = g.MarketValue,
MarketValueUpdatedAt = g.MarketValueUpdatedAt,
MarketValueSource = g.MarketValueSource,
};
}
@@ -1,6 +1,7 @@
using LudosData.Api.Domain;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace LudosData.Api.Data;
@@ -13,6 +14,15 @@ public class LudosDbContext(DbContextOptions<LudosDbContext> options)
{
base.OnModelCreating(builder);
// SQLite has no decimal type. EF Core's default is to store decimal as
// TEXT, which compares lexically — "9.00" sorts above "10.00", and SUM
// is not available at all. Money is therefore stored as integer minor
// units and converted on the way in and out, so ordering by value and
// totalling a collection both behave.
var moneyToCents = new ValueConverter<decimal?, long?>(
value => value == null ? null : (long)Math.Round(value.Value * 100m, MidpointRounding.AwayFromZero),
cents => cents == null ? null : cents.Value / 100m);
builder.Entity<Game>(game =>
{
game.HasOne(g => g.Owner)
@@ -20,11 +30,19 @@ public class LudosDbContext(DbContextOptions<LudosDbContext> options)
.HasForeignKey(g => g.OwnerId)
.OnDelete(DeleteBehavior.Cascade);
game.Property(g => g.PurchasePrice).HasConversion(moneyToCents);
game.Property(g => g.MarketValue).HasConversion(moneyToCents);
// Stored as an enum's underlying int; readable names live in the API.
game.Property(g => g.Condition).HasConversion<int>();
game.Property(g => g.Region).HasConversion<int>();
// Every list query filters by owner first, then narrows or sorts on
// these columns, so they lead the composite indexes.
game.HasIndex(g => new { g.OwnerId, g.Title });
game.HasIndex(g => new { g.OwnerId, g.System });
game.HasIndex(g => new { g.OwnerId, g.Genre });
game.HasIndex(g => new { g.OwnerId, g.Rating });
});
}
@@ -0,0 +1,397 @@
// <auto-generated />
using System;
using LudosData.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
[DbContext(typeof(LudosDbContext))]
[Migration("20260804171435_AddCollectorFields")]
partial class AddCollectorFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Art")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Developer")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("Dumped")
.HasColumnType("INTEGER");
b.Property<bool>("Finished")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("Played")
.HasColumnType("INTEGER");
b.Property<string>("Publisher")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Year")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
b.ToTable("Games");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
.WithMany("Games")
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("LudosData.Api.Domain.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
{
b.Navigation("Games");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,121 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LudosData.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddCollectorFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Condition",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<long>(
name: "MarketValue",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "MarketValueSource",
table: "Games",
type: "TEXT",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "MarketValueUpdatedAt",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateOnly>(
name: "PurchaseDate",
table: "Games",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PurchasePrice",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Rating",
table: "Games",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Region",
table: "Games",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games",
columns: new[] { "OwnerId", "Rating" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Games_OwnerId_Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Condition",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValue",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueSource",
table: "Games");
migrationBuilder.DropColumn(
name: "MarketValueUpdatedAt",
table: "Games");
migrationBuilder.DropColumn(
name: "Notes",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchaseDate",
table: "Games");
migrationBuilder.DropColumn(
name: "PurchasePrice",
table: "Games");
migrationBuilder.DropColumn(
name: "Rating",
table: "Games");
migrationBuilder.DropColumn(
name: "Region",
table: "Games");
}
}
}
@@ -103,6 +103,9 @@ namespace LudosData.Api.Data.Migrations
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Condition")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
@@ -123,6 +126,19 @@ namespace LudosData.Api.Data.Migrations
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("MarketValue")
.HasColumnType("INTEGER");
b.Property<string>("MarketValueSource")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("MarketValueUpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<bool>("Own")
.HasColumnType("INTEGER");
@@ -137,6 +153,18 @@ namespace LudosData.Api.Data.Migrations
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateOnly?>("PurchaseDate")
.HasColumnType("TEXT");
b.Property<long?>("PurchasePrice")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<int>("Region")
.HasColumnType("INTEGER");
b.Property<string>("System")
.HasMaxLength(50)
.HasColumnType("TEXT");
@@ -157,6 +185,8 @@ namespace LudosData.Api.Data.Migrations
b.HasIndex("OwnerId", "Genre");
b.HasIndex("OwnerId", "Rating");
b.HasIndex("OwnerId", "System");
b.HasIndex("OwnerId", "Title");
+34
View File
@@ -0,0 +1,34 @@
namespace LudosData.Api.Domain;
/// <summary>
/// Physical completeness. This is not cosmetic: market price feeds quote per
/// condition, and the gap between loose and sealed is routinely a multiple, so
/// this selects which quoted price applies to a copy.
/// </summary>
public enum GameCondition
{
Unspecified = 0,
/// <summary>Cartridge or disc only.</summary>
Loose = 1,
/// <summary>Complete in box — case, manual and inserts present.</summary>
Cib = 2,
/// <summary>Factory sealed, never opened.</summary>
Sealed = 3,
/// <summary>No physical copy; a download or licence.</summary>
Digital = 4,
}
/// <summary>
/// Release region. Affects both value and playability on a given console.
/// </summary>
public enum GameRegion
{
Unspecified = 0,
Ntsc = 1, // North America
Pal = 2, // Europe / Australia
NtscJ = 3, // Japan
}
+35
View File
@@ -47,6 +47,41 @@ public class Game
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.
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; }
/// <summary>
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
+11 -1
View File
@@ -1,4 +1,5 @@
using System.Text;
using System.Text.Json.Serialization;
using LudosData.Api.Auth;
using LudosData.Api.Data;
using LudosData.Api.Domain;
@@ -112,7 +113,16 @@ builder.Services.AddAuthorization();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<IImageStorage, ImageStorage>();
builder.Services.AddControllers();
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
// Enums travel as names, not ordinals. "Cib" is self-describing in a
// payload, an export file and a log line; 2 is not, and renumbering the
// enum would silently reinterpret every stored export.
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();