diff --git a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
index 4d74b43..2d0470b 100644
--- a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
+++ b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
@@ -18,6 +18,7 @@
+
diff --git a/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs b/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs
new file mode 100644
index 0000000..3d74f73
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs
@@ -0,0 +1,23 @@
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+
+namespace BlueLaminate.Cli.Logging;
+
+///
+/// Minimal console sink for the OpenTelemetry log pipeline: one line per record
+/// as "{utc timestamp} {message}". Requires IncludeFormattedMessage so the
+/// message arrives with its template arguments already substituted.
+///
+public sealed class CompactConsoleLogExporter : BaseExporter
+{
+ public override ExportResult Export(in Batch batch)
+ {
+ foreach (var record in batch)
+ {
+ var message = record.FormattedMessage ?? record.Body ?? string.Empty;
+ Console.WriteLine($"{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff'Z'} {message}");
+ }
+
+ return ExportResult.Success;
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs
index a5f68c3..df096d0 100644
--- a/BlueLaminate/BlueLaminate.Cli/Program.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Program.cs
@@ -1,8 +1,25 @@
-using System.CommandLine;
using BlueLaminate.Cli;
+using BlueLaminate.Cli.Logging;
using BlueLaminate.EFCore.Data;
-using BlueLaminate.Scraper.Weapons;
-using BlueLaminate.Scraper.Wiki;
+using BlueLaminate.Scraper.Skins;
+using Microsoft.Extensions.Logging;
+using OpenTelemetry;
+using OpenTelemetry.Resources;
+using System.CommandLine;
+
+// OpenTelemetry logging through a compact console sink that prints one
+// "{utc timestamp} {message}" line per record. Swapping in an OTLP exporter
+// later is a change here. Disposed at process exit so buffered records flush.
+using var loggerFactory = LoggerFactory.Create(logging =>
+{
+ logging.AddOpenTelemetry(otel =>
+ {
+ otel.SetResourceBuilder(
+ ResourceBuilder.CreateDefault().AddService("BlueLaminate.Cli"));
+ otel.IncludeFormattedMessage = true;
+ otel.AddProcessor(new SimpleLogRecordExportProcessor(new CompactConsoleLogExporter()));
+ });
+});
// Entry point: System.CommandLine builds the command tree, parsing, and help.
// New features are added as additional commands here as they're implemented.
@@ -12,56 +29,73 @@ var forceOption = new Option("--force")
};
var dryRunOption = new Option("--dry-run")
{
- Description = "Scrape and print the weapons without writing to the database."
+ Description = "Load and print the skins without writing to the database."
};
-var syncWeapons = new Command(
- "sync-weapons",
- "Scrape the CS2 weapon catalogue from the wiki and upsert it (throttled to once a month).")
+var syncSkins = new Command(
+ "sync-skins",
+ "Load the CS2 skin catalogue from the CSGO-API dataset and upsert it (throttled to once a month).")
{
forceOption,
dryRunOption,
};
-syncWeapons.SetAction((parseResult, ct) =>
- SyncWeaponsAsync(parseResult.GetValue(forceOption), parseResult.GetValue(dryRunOption), ct));
+syncSkins.SetAction((parseResult, ct) =>
+ SyncSkinsAsync(
+ parseResult.GetValue(forceOption),
+ parseResult.GetValue(dryRunOption),
+ loggerFactory,
+ ct));
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
- syncWeapons,
+ syncSkins,
};
return await root.Parse(args).InvokeAsync();
-// Fetch the CS2 weapon catalogue from the wiki and upsert it. Throttled to once
-// a month unless --force is passed; --dry-run scrapes and prints without a DB.
-static async Task SyncWeaponsAsync(bool force, bool dryRun, CancellationToken ct)
+// Load the CS2 skin catalogue from the CSGO-API dataset and upsert it. Weapons
+// and collections are derived from the skins themselves. Throttled to once a
+// month unless --force; --dry-run loads and prints without a DB.
+static async Task SyncSkinsAsync(
+ bool force, bool dryRun, ILoggerFactory loggerFactory, CancellationToken ct)
{
- var scraper = new WeaponWikiScraper(new WikiPageFetcher(CreateHttpClient()));
+ var logger = loggerFactory.CreateLogger("BlueLaminate.Cli.SyncSkins");
+ var client = new SkinCatalogClient(CreateHttpClient());
if (dryRun)
{
- var weapons = await scraper.ScrapeAsync(ct);
- Console.WriteLine($"Scraped {weapons.Count} weapons (dry run, nothing written):");
- foreach (var w in weapons)
- Console.WriteLine($" {w.Name,-20} {w.Type,-16} {w.Team}");
+ logger.LogInformation("Loading skin catalogue (dry run — nothing will be written).");
+ var skins = await client.FetchAsync(ct);
+ logger.LogInformation("Loaded {Count} skins.", skins.Count);
+ Console.WriteLine($"Loaded {skins.Count} skins (dry run, nothing written):");
+ foreach (var s in skins)
+ {
+ var tags = (s.StatTrakAvailable ? " ST" : "") + (s.SouvenirAvailable ? " SV" : "");
+ var range = s.FloatMin is not null ? $"{s.FloatMin:0.00}-{s.FloatMax:0.00}" : "—";
+ var sources = s.Sources.Count > 0 ? string.Join(", ", s.Sources.Select(x => x.Name)) : "—";
+ Console.WriteLine(
+ $" {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
+ }
return 0;
}
using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
- var result = await new WeaponSyncService(db, scraper).SyncAsync(force, ct);
+ var service = new SkinSyncService(db, client, loggerFactory.CreateLogger());
+ var result = await service.SyncAsync(force, ct);
if (result.Skipped)
{
Console.WriteLine(
- $"Skipped: weapons were last synced {result.LastRanAt:u}. "
+ $"Skipped: skins were last synced {result.LastRanAt:u}. "
+ "Next run allowed one month later — pass --force to override.");
}
else
{
Console.WriteLine(
- $"Synced {result.Scraped} weapons: {result.Inserted} inserted, "
+ $"Synced {result.Loaded} skins: {result.Inserted} inserted, "
+ $"{result.Updated} updated, "
- + $"{result.Scraped - result.Inserted - result.Updated} unchanged.");
+ + $"{result.Loaded - result.Inserted - result.Updated} unchanged "
+ + $"({result.WeaponsCreated} weapons, {result.CollectionsCreated} collections created).");
}
return 0;
@@ -70,10 +104,7 @@ static async Task SyncWeaponsAsync(bool force, bool dryRun, CancellationTok
static HttpClient CreateHttpClient()
{
var http = new HttpClient();
- // The wiki is fronted by Cloudflare; a browser-like User-Agent is accepted
- // on the MediaWiki API endpoint the scraper uses.
- http.DefaultRequestHeaders.UserAgent.ParseAdd(
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
- + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36");
+ http.Timeout = TimeSpan.FromMinutes(2);
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate.Cli");
return http;
}
diff --git a/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs b/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
new file mode 100644
index 0000000..ca110be
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
@@ -0,0 +1,201 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using BlueLaminate.Scraper.Skins;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace BlueLaminate.Cli;
+
+/// True when the monthly throttle suppressed the run.
+/// When the previous successful run happened, if any.
+public sealed record SkinSyncResult(
+ bool Skipped,
+ DateTimeOffset? LastRanAt,
+ int Loaded,
+ int Inserted,
+ int Updated,
+ int WeaponsCreated,
+ int CollectionsCreated);
+
+///
+/// Loads the CS2 skin catalogue from the CSGO-API dataset and upserts it. The
+/// weapon list and the collections/containers are derived from the skins
+/// themselves, so any that are missing are created on the fly and no skin is
+/// dropped. Throttled to once a month unless forced, since the catalogue changes
+/// slowly.
+///
+public sealed class SkinSyncService
+{
+ public const string Source = "skins";
+
+ private readonly SkinTrackerDbContext _db;
+ private readonly SkinCatalogClient _client;
+ private readonly ILogger _logger;
+
+ public SkinSyncService(
+ SkinTrackerDbContext db,
+ SkinCatalogClient client,
+ ILogger logger)
+ {
+ _db = db;
+ _client = client;
+ _logger = logger;
+ }
+
+ public async Task SyncAsync(bool force = false, CancellationToken ct = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var lastRanAt = await _db.ScrapeRuns
+ .Where(r => r.Source == Source)
+ .OrderByDescending(r => r.RanAt)
+ .Select(r => (DateTimeOffset?)r.RanAt)
+ .FirstOrDefaultAsync(ct);
+
+ if (!force && lastRanAt is { } last && last.AddMonths(1) > now)
+ {
+ _logger.LogInformation(
+ "Skipping skin sync; last run was {LastRanAt:u} (throttled to monthly).", last);
+ return new SkinSyncResult(true, last, 0, 0, 0, 0, 0);
+ }
+
+ _logger.LogInformation("Starting skin sync (force: {Force}).", force);
+ var catalog = await _client.FetchAsync(ct);
+ _logger.LogInformation("Loaded {Count} skins from the catalogue.", catalog.Count);
+
+ var weapons = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct);
+ var collections = await _db.Collections.ToDictionaryAsync(c => c.Slug, ct);
+ var existing = await _db.Skins
+ .Include(s => s.Collections)
+ .ToDictionaryAsync(s => s.Slug, ct);
+
+ var inserted = 0;
+ var updated = 0;
+ var weaponsCreated = 0;
+ var collectionsCreated = 0;
+
+ foreach (var s in catalog)
+ {
+ var weapon = ResolveWeapon(weapons, s, ref weaponsCreated);
+ var sources = ResolveCollections(collections, s, ref collectionsCreated);
+
+ if (existing.TryGetValue(s.Id, out var skin))
+ {
+ if (Apply(skin, s, weapon, sources))
+ updated++;
+ }
+ else
+ {
+ skin = new Skin { Slug = s.Id };
+ Apply(skin, s, weapon, sources);
+ _db.Skins.Add(skin);
+ existing[s.Id] = skin;
+ inserted++;
+ }
+ }
+
+ _db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = catalog.Count });
+ await _db.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "Skin sync complete: {Loaded} loaded, {Inserted} inserted, {Updated} updated, "
+ + "{WeaponsCreated} weapons created, {CollectionsCreated} collections created.",
+ catalog.Count, inserted, updated, weaponsCreated, collectionsCreated);
+
+ return new SkinSyncResult(
+ false, lastRanAt, catalog.Count, inserted, updated, weaponsCreated, collectionsCreated);
+ }
+
+ private Weapon ResolveWeapon(Dictionary weapons, CatalogSkin s, ref int created)
+ {
+ if (weapons.TryGetValue(s.WeaponName, out var weapon))
+ {
+ // Category/team can be refined as the catalogue grows; keep them current.
+ weapon.Type = s.Category;
+ weapon.Team = s.Team;
+ return weapon;
+ }
+
+ weapon = new Weapon { Name = s.WeaponName, Type = s.Category, Team = s.Team };
+ _db.Weapons.Add(weapon);
+ weapons[s.WeaponName] = weapon;
+ created++;
+ return weapon;
+ }
+
+ private List ResolveCollections(
+ Dictionary collections, CatalogSkin s, ref int created)
+ {
+ var resolved = new List(s.Sources.Count);
+ foreach (var source in s.Sources)
+ {
+ if (!collections.TryGetValue(source.Id, out var collection))
+ {
+ collection = new Collection { Slug = source.Id, Name = source.Name, Type = source.Type };
+ _db.Collections.Add(collection);
+ collections[source.Id] = collection;
+ created++;
+ }
+ resolved.Add(collection);
+ }
+ return resolved;
+ }
+
+ // Copies catalogue values onto the entity. Returns true if anything changed.
+ // The weapon navigation is assigned directly (a newly created weapon has no
+ // id yet to compare against, so reference-assigning is the only correct way
+ // to wire the FK). The collection links are reconciled against the current set.
+ private static bool Apply(Skin skin, CatalogSkin s, Weapon weapon, List sources)
+ {
+ skin.Weapon = weapon;
+
+ var changed = false;
+
+ void Set(Func get, Action set, T value)
+ {
+ if (!EqualityComparer.Default.Equals(get(), value))
+ {
+ set(value);
+ changed = true;
+ }
+ }
+
+ Set(() => skin.Name, v => skin.Name = v, s.Name);
+ Set(() => skin.Rarity, v => skin.Rarity = v, s.Rarity);
+ Set(() => skin.Description, v => skin.Description = v, s.Description);
+ Set(() => skin.ImageUrl, v => skin.ImageUrl = v, s.ImageUrl);
+ Set(() => skin.StatTrakAvailable, v => skin.StatTrakAvailable = v, s.StatTrakAvailable);
+ Set(() => skin.SouvenirAvailable, v => skin.SouvenirAvailable = v, s.SouvenirAvailable);
+ Set(() => skin.FloatMin, v => skin.FloatMin = v, s.FloatMin);
+ Set(() => skin.FloatMax, v => skin.FloatMax = v, s.FloatMax);
+
+ if (ReconcileCollections(skin.Collections, sources))
+ changed = true;
+
+ return changed;
+ }
+
+ // Adds collections the skin newly belongs to and removes ones it no longer
+ // does, comparing by slug. Returns true if the set changed.
+ private static bool ReconcileCollections(ICollection current, List desired)
+ {
+ var changed = false;
+
+ foreach (var collection in desired)
+ {
+ if (!current.Any(c => c.Slug == collection.Slug))
+ {
+ current.Add(collection);
+ changed = true;
+ }
+ }
+
+ foreach (var collection in current.Where(c => desired.All(d => d.Slug != c.Slug)).ToList())
+ {
+ current.Remove(collection);
+ changed = true;
+ }
+
+ return changed;
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs b/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs
deleted file mode 100644
index a42da1f..0000000
--- a/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using BlueLaminate.EFCore.Data;
-using BlueLaminate.EFCore.Entities;
-using BlueLaminate.Scraper.Weapons;
-using Microsoft.EntityFrameworkCore;
-
-namespace BlueLaminate.Cli;
-
-/// True when the monthly throttle suppressed the run.
-/// When the previous successful run happened, if any.
-public sealed record WeaponSyncResult(
- bool Skipped,
- DateTimeOffset? LastRanAt,
- int Scraped,
- int Inserted,
- int Updated);
-
-///
-/// Fetches the CS2 weapon catalogue and upserts it into the database. The
-/// catalogue changes rarely, so a run is throttled to at most once a month
-/// unless explicitly forced.
-///
-public sealed class WeaponSyncService
-{
- public const string Source = "weapons";
-
- private readonly SkinTrackerDbContext _db;
- private readonly WeaponWikiScraper _scraper;
-
- public WeaponSyncService(SkinTrackerDbContext db, WeaponWikiScraper scraper)
- {
- _db = db;
- _scraper = scraper;
- }
-
- public async Task SyncAsync(bool force = false, CancellationToken ct = default)
- {
- var now = DateTimeOffset.UtcNow;
-
- var lastRanAt = await _db.ScrapeRuns
- .Where(r => r.Source == Source)
- .OrderByDescending(r => r.RanAt)
- .Select(r => (DateTimeOffset?)r.RanAt)
- .FirstOrDefaultAsync(ct);
-
- if (!force && lastRanAt is { } last && last.AddMonths(1) > now)
- return new WeaponSyncResult(Skipped: true, last, Scraped: 0, Inserted: 0, Updated: 0);
-
- var scraped = await _scraper.ScrapeAsync(ct);
-
- var existing = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct);
- var inserted = 0;
- var updated = 0;
-
- foreach (var s in scraped)
- {
- if (existing.TryGetValue(s.Name, out var weapon))
- {
- if (weapon.Type != s.Type || weapon.Team != s.Team)
- {
- weapon.Type = s.Type;
- weapon.Team = s.Team;
- updated++;
- }
- }
- else
- {
- _db.Weapons.Add(new Weapon { Name = s.Name, Type = s.Type, Team = s.Team });
- inserted++;
- }
- }
-
- _db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = scraped.Count });
- await _db.SaveChangesAsync(ct);
-
- return new WeaponSyncResult(Skipped: false, lastRanAt, scraped.Count, inserted, updated);
- }
-}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/CollectionConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/CollectionConfiguration.cs
new file mode 100644
index 0000000..c42bc1b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/CollectionConfiguration.cs
@@ -0,0 +1,14 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class CollectionConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // Slug is the natural key the sync upserts against.
+ entity.HasIndex(e => e.Slug).IsUnique();
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
index 18f87f4..039fea3 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
@@ -8,20 +8,27 @@ public class SkinConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder entity)
{
- entity.Property(e => e.FloatMin)
- .HasColumnType("numeric(10,9)")
- .HasDefaultValue(0.0m);
- entity.Property(e => e.FloatMax)
- .HasColumnType("numeric(10,9)")
- .HasDefaultValue(1.0m);
+ // Nullable: null means the catalogue gives no wear range (e.g. vanilla
+ // knives), distinct from a genuine 0.0–1.0 range.
+ entity.Property(e => e.FloatMin).HasColumnType("numeric(10,9)");
+ entity.Property(e => e.FloatMax).HasColumnType("numeric(10,9)");
entity.Property(e => e.TrueFloat)
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", stored: true);
entity.HasIndex(e => e.TrueFloat);
+ // Slug is the natural key the sync upserts against.
+ entity.HasIndex(e => e.Slug).IsUnique();
+
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
.HasForeignKey(e => e.WeaponId);
+
+ // A skin can come from many collections and containers, and each of those
+ // holds many skins.
+ entity.HasMany(e => e.Collections)
+ .WithMany(c => c.Skins)
+ .UsingEntity(join => join.ToTable("skin_collections"));
}
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
index aaf5f2d..5ca1af2 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
@@ -20,6 +20,7 @@ public class SkinTrackerDbContext : DbContext
public DbSet Weapons => Set();
public DbSet ScrapeRuns => Set();
+ public DbSet Collections => Set();
public DbSet Skins => Set();
public DbSet SkinConditions => Set();
public DbSet SteamUsers => Set();
@@ -38,6 +39,7 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new WeaponConfiguration());
modelBuilder.ApplyConfiguration(new ScrapeRunConfiguration());
+ modelBuilder.ApplyConfiguration(new CollectionConfiguration());
modelBuilder.ApplyConfiguration(new SkinConfiguration());
modelBuilder.ApplyConfiguration(new SkinConditionConfiguration());
modelBuilder.ApplyConfiguration(new SteamUserConfiguration());
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs
new file mode 100644
index 0000000..c592f8c
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs
@@ -0,0 +1,21 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// A source a skin originates from: either an in-game collection (e.g.
+/// "The Dead Hand Collection") or a container/case (e.g. "Glove Case").
+///
+public class Collection
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = null!;
+
+ /// Stable id from the CSGO-API catalogue, e.g. "collection-set-community-37"
+ /// or "crate-4288". The natural key.
+ public string Slug { get; set; } = null!;
+
+ /// "Collection" or "Container".
+ public string Type { get; set; } = null!;
+
+ public ICollection Skins { get; set; } = new List();
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
index 2272d80..f1049c5 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
@@ -6,17 +6,29 @@ public class Skin
public int WeaponId { get; set; }
public Weapon Weapon { get; set; } = null!;
+ /// Stable id from the CSGO-API catalogue, e.g. "skin-e757fd7191f9". The natural key.
+ public string Slug { get; set; } = null!;
+
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }
public string? ImageUrl { get; set; }
- public decimal FloatMin { get; set; }
- public decimal FloatMax { get; set; }
+ public bool StatTrakAvailable { get; set; }
+ public bool SouvenirAvailable { get; set; }
- // Computed in the database: float_min = 0.0 AND float_max = 1.0.
- // A skin with a capped float range behaves differently in tradeup calculations.
- public bool TrueFloat { get; private set; }
+ /// Every collection and container this skin originates from.
+ public ICollection Collections { get; set; } = new List();
+
+ // Null when the catalogue gives no wear range (e.g. vanilla knives). Callers
+ // must treat null as "unknown", not as a full 0.0–1.0 range.
+ public decimal? FloatMin { get; set; }
+ public decimal? FloatMax { get; set; }
+
+ // Computed in the database: float_min = 0.0 AND float_max = 1.0; null while the
+ // bounds are unknown. A skin with a capped float range behaves differently in
+ // tradeup calculations.
+ public bool? TrueFloat { get; private set; }
public ICollection Conditions { get; set; } = new List();
public ICollection Instances { get; set; } = new List();
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs
new file mode 100644
index 0000000..0e8e518
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs
@@ -0,0 +1,680 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260529192841_AddSkinCatalogFields")]
+ partial class AddSkinCatalogFields
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CollectionId")
+ .HasColumnType("integer")
+ .HasColumnName("collection_id");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(10,9)")
+ .HasDefaultValue(1.0m)
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("numeric(10,9)")
+ .HasDefaultValue(0.0m)
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("CollectionId")
+ .HasDatabaseName("ix_skins_collection_id");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("MaxFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("max_float");
+
+ b.Property("MinFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("min_float");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_value");
+
+ b.Property("PaintSeed")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("paint_seed");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Souvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir");
+
+ b.Property("StatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_instances");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_skin_instances_condition_id");
+
+ b.HasIndex("FloatValue")
+ .HasDatabaseName("ix_skin_instances_float_value");
+
+ b.HasIndex("PaintSeed")
+ .HasDatabaseName("ix_skin_instances_paint_seed");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_instances_skin_id");
+
+ b.ToTable("skin_instances", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_synced_at");
+
+ b.Property("SteamId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("steam_id");
+
+ b.HasKey("Id")
+ .HasName("pk_steam_users");
+
+ b.HasIndex("SteamId")
+ .IsUnique()
+ .HasDatabaseName("ix_steam_users_steam_id");
+
+ b.ToTable("steam_users", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FromUserId")
+ .HasColumnType("integer")
+ .HasColumnName("from_user_id");
+
+ b.Property("SteamTradeId")
+ .HasColumnType("text")
+ .HasColumnName("steam_trade_id");
+
+ b.Property("ToUserId")
+ .HasColumnType("integer")
+ .HasColumnName("to_user_id");
+
+ b.Property("TradedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("traded_at");
+
+ b.HasKey("Id")
+ .HasName("pk_trades");
+
+ b.HasIndex("FromUserId")
+ .HasDatabaseName("ix_trades_from_user_id");
+
+ b.HasIndex("ToUserId")
+ .HasDatabaseName("ix_trades_to_user_id");
+
+ b.ToTable("trades", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InventoryItemId")
+ .HasColumnType("integer")
+ .HasColumnName("inventory_item_id");
+
+ b.Property("TradeId")
+ .HasColumnType("integer")
+ .HasColumnName("trade_id");
+
+ b.HasKey("Id")
+ .HasName("pk_trade_items");
+
+ b.HasIndex("InventoryItemId")
+ .HasDatabaseName("ix_trade_items_inventory_item_id");
+
+ b.HasIndex("TradeId")
+ .HasDatabaseName("ix_trade_items_trade_id");
+
+ b.ToTable("trade_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Team")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("team");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_weapons");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_weapons_name");
+
+ b.ToTable("weapons", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
+ .WithMany("InventoryItems")
+ .HasForeignKey("SkinInstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
+ .WithMany("InventoryItems")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_steam_users_user_id");
+
+ b.Navigation("SkinInstance");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("PriceHistories")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("PriceHistories")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
+ .WithMany("Skins")
+ .HasForeignKey("CollectionId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_skins_collections_collection_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
+ .WithMany("Skins")
+ .HasForeignKey("WeaponId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skins_weapons_weapon_id");
+
+ b.Navigation("Collection");
+
+ b.Navigation("Weapon");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Conditions")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_conditions_skins_skin_id");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("Instances")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Instances")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
+ .WithMany("TradesSent")
+ .HasForeignKey("FromUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_from_user_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
+ .WithMany("TradesReceived")
+ .HasForeignKey("ToUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_to_user_id");
+
+ b.Navigation("FromUser");
+
+ b.Navigation("ToUser");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
+ .WithMany("TradeItems")
+ .HasForeignKey("InventoryItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
+ .WithMany("TradeItems")
+ .HasForeignKey("TradeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_trades_trade_id");
+
+ b.Navigation("InventoryItem");
+
+ b.Navigation("Trade");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Navigation("Skins");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Navigation("Conditions");
+
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Navigation("InventoryItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Navigation("InventoryItems");
+
+ b.Navigation("TradesReceived");
+
+ b.Navigation("TradesSent");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Navigation("Skins");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.cs
new file mode 100644
index 0000000..76565f8
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.cs
@@ -0,0 +1,135 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ ///
+ public partial class AddSkinCatalogFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "collection_id",
+ schema: "skintracker",
+ table: "skins",
+ type: "integer",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "slug",
+ schema: "skintracker",
+ table: "skins",
+ type: "text",
+ nullable: false,
+ defaultValue: "");
+
+ migrationBuilder.AddColumn(
+ name: "souvenir_available",
+ schema: "skintracker",
+ table: "skins",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "stat_trak_available",
+ schema: "skintracker",
+ table: "skins",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.CreateTable(
+ name: "collections",
+ schema: "skintracker",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "text", nullable: false),
+ slug = table.Column(type: "text", nullable: false),
+ type = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_collections", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_skins_collection_id",
+ schema: "skintracker",
+ table: "skins",
+ column: "collection_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_skins_slug",
+ schema: "skintracker",
+ table: "skins",
+ column: "slug",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_collections_slug",
+ schema: "skintracker",
+ table: "collections",
+ column: "slug",
+ unique: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_skins_collections_collection_id",
+ schema: "skintracker",
+ table: "skins",
+ column: "collection_id",
+ principalSchema: "skintracker",
+ principalTable: "collections",
+ principalColumn: "id",
+ onDelete: ReferentialAction.SetNull);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_skins_collections_collection_id",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropTable(
+ name: "collections",
+ schema: "skintracker");
+
+ migrationBuilder.DropIndex(
+ name: "ix_skins_collection_id",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropIndex(
+ name: "ix_skins_slug",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "collection_id",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "slug",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "souvenir_available",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "stat_trak_available",
+ schema: "skintracker",
+ table: "skins");
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs
new file mode 100644
index 0000000..64551a0
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs
@@ -0,0 +1,676 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260529200100_MakeSkinFloatsNullable")]
+ partial class MakeSkinFloatsNullable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CollectionId")
+ .HasColumnType("integer")
+ .HasColumnName("collection_id");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("CollectionId")
+ .HasDatabaseName("ix_skins_collection_id");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("MaxFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("max_float");
+
+ b.Property("MinFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("min_float");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_value");
+
+ b.Property("PaintSeed")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("paint_seed");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Souvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir");
+
+ b.Property("StatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_instances");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_skin_instances_condition_id");
+
+ b.HasIndex("FloatValue")
+ .HasDatabaseName("ix_skin_instances_float_value");
+
+ b.HasIndex("PaintSeed")
+ .HasDatabaseName("ix_skin_instances_paint_seed");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_instances_skin_id");
+
+ b.ToTable("skin_instances", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_synced_at");
+
+ b.Property("SteamId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("steam_id");
+
+ b.HasKey("Id")
+ .HasName("pk_steam_users");
+
+ b.HasIndex("SteamId")
+ .IsUnique()
+ .HasDatabaseName("ix_steam_users_steam_id");
+
+ b.ToTable("steam_users", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FromUserId")
+ .HasColumnType("integer")
+ .HasColumnName("from_user_id");
+
+ b.Property("SteamTradeId")
+ .HasColumnType("text")
+ .HasColumnName("steam_trade_id");
+
+ b.Property("ToUserId")
+ .HasColumnType("integer")
+ .HasColumnName("to_user_id");
+
+ b.Property("TradedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("traded_at");
+
+ b.HasKey("Id")
+ .HasName("pk_trades");
+
+ b.HasIndex("FromUserId")
+ .HasDatabaseName("ix_trades_from_user_id");
+
+ b.HasIndex("ToUserId")
+ .HasDatabaseName("ix_trades_to_user_id");
+
+ b.ToTable("trades", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InventoryItemId")
+ .HasColumnType("integer")
+ .HasColumnName("inventory_item_id");
+
+ b.Property("TradeId")
+ .HasColumnType("integer")
+ .HasColumnName("trade_id");
+
+ b.HasKey("Id")
+ .HasName("pk_trade_items");
+
+ b.HasIndex("InventoryItemId")
+ .HasDatabaseName("ix_trade_items_inventory_item_id");
+
+ b.HasIndex("TradeId")
+ .HasDatabaseName("ix_trade_items_trade_id");
+
+ b.ToTable("trade_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Team")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("team");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_weapons");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_weapons_name");
+
+ b.ToTable("weapons", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
+ .WithMany("InventoryItems")
+ .HasForeignKey("SkinInstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
+ .WithMany("InventoryItems")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_steam_users_user_id");
+
+ b.Navigation("SkinInstance");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("PriceHistories")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("PriceHistories")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
+ .WithMany("Skins")
+ .HasForeignKey("CollectionId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_skins_collections_collection_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
+ .WithMany("Skins")
+ .HasForeignKey("WeaponId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skins_weapons_weapon_id");
+
+ b.Navigation("Collection");
+
+ b.Navigation("Weapon");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Conditions")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_conditions_skins_skin_id");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("Instances")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Instances")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
+ .WithMany("TradesSent")
+ .HasForeignKey("FromUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_from_user_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
+ .WithMany("TradesReceived")
+ .HasForeignKey("ToUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_to_user_id");
+
+ b.Navigation("FromUser");
+
+ b.Navigation("ToUser");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
+ .WithMany("TradeItems")
+ .HasForeignKey("InventoryItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
+ .WithMany("TradeItems")
+ .HasForeignKey("TradeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_trades_trade_id");
+
+ b.Navigation("InventoryItem");
+
+ b.Navigation("Trade");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Navigation("Skins");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Navigation("Conditions");
+
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Navigation("InventoryItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Navigation("InventoryItems");
+
+ b.Navigation("TradesReceived");
+
+ b.Navigation("TradesSent");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Navigation("Skins");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.cs
new file mode 100644
index 0000000..6b3b970
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.cs
@@ -0,0 +1,87 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ ///
+ public partial class MakeSkinFloatsNullable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "float_min",
+ schema: "skintracker",
+ table: "skins",
+ type: "numeric(10,9)",
+ nullable: true,
+ oldClrType: typeof(decimal),
+ oldType: "numeric(10,9)",
+ oldDefaultValue: 0.0m);
+
+ migrationBuilder.AlterColumn(
+ name: "float_max",
+ schema: "skintracker",
+ table: "skins",
+ type: "numeric(10,9)",
+ nullable: true,
+ oldClrType: typeof(decimal),
+ oldType: "numeric(10,9)",
+ oldDefaultValue: 1.0m);
+
+ migrationBuilder.AlterColumn(
+ name: "true_float",
+ schema: "skintracker",
+ table: "skins",
+ type: "boolean",
+ nullable: true,
+ computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
+ stored: true,
+ oldClrType: typeof(bool),
+ oldType: "boolean",
+ oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
+ oldStored: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn(
+ name: "float_min",
+ schema: "skintracker",
+ table: "skins",
+ type: "numeric(10,9)",
+ nullable: false,
+ defaultValue: 0.0m,
+ oldClrType: typeof(decimal),
+ oldType: "numeric(10,9)",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn(
+ name: "float_max",
+ schema: "skintracker",
+ table: "skins",
+ type: "numeric(10,9)",
+ nullable: false,
+ defaultValue: 1.0m,
+ oldClrType: typeof(decimal),
+ oldType: "numeric(10,9)",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn(
+ name: "true_float",
+ schema: "skintracker",
+ table: "skins",
+ type: "boolean",
+ nullable: false,
+ computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
+ stored: true,
+ oldClrType: typeof(bool),
+ oldType: "boolean",
+ oldNullable: true,
+ oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
+ oldStored: true);
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs
new file mode 100644
index 0000000..b690162
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs
@@ -0,0 +1,692 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260529211544_UseStaticSkinCatalog")]
+ partial class UseStaticSkinCatalog
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("MaxFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("max_float");
+
+ b.Property("MinFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("min_float");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property