From b51f1d9f5f2ec124f8d7a23a8e68a2495740aadb Mon Sep 17 00:00:00 2001 From: bob Date: Fri, 29 May 2026 18:36:17 -0500 Subject: [PATCH] Change to static skin catalog population --- .../BlueLaminate.Cli/BlueLaminate.Cli.csproj | 1 + .../Logging/CompactConsoleLogExporter.cs | 23 + BlueLaminate/BlueLaminate.Cli/Program.cs | 85 ++- .../BlueLaminate.Cli/SkinSyncService.cs | 201 +++++ .../BlueLaminate.Cli/WeaponSyncService.cs | 77 -- .../Configurations/CollectionConfiguration.cs | 14 + .../Configurations/SkinConfiguration.cs | 19 +- .../Data/SkinTrackerDbContext.cs | 2 + .../Entities/Collection.cs | 21 + .../BlueLaminate.EFCore/Entities/Skin.cs | 22 +- ...529192841_AddSkinCatalogFields.Designer.cs | 680 +++++++++++++++++ .../20260529192841_AddSkinCatalogFields.cs | 135 ++++ ...9200100_MakeSkinFloatsNullable.Designer.cs | 676 +++++++++++++++++ .../20260529200100_MakeSkinFloatsNullable.cs | 87 +++ ...529211544_UseStaticSkinCatalog.Designer.cs | 692 ++++++++++++++++++ .../20260529211544_UseStaticSkinCatalog.cs | 93 +++ .../SkinTrackerDbContextModelSnapshot.cs | 97 ++- .../BlueLaminate.Scraper.csproj | 4 - .../BlueLaminate.Scraper/Skins/CatalogSkin.cs | 36 + .../Skins/SkinCatalogClient.cs | 105 +++ .../Weapons/ScrapedWeapon.cs | 7 - .../Weapons/WeaponWikiScraper.cs | 172 ----- .../Wiki/WikiPageFetcher.cs | 51 -- .../BlueLaminate.Scraper/Wiki/WikiText.cs | 14 - db/02_readonly_role.sql | 37 + db/03_catalog_audit.sql | 82 +++ 26 files changed, 3063 insertions(+), 370 deletions(-) create mode 100644 BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs create mode 100644 BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs delete mode 100644 BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Configurations/CollectionConfiguration.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/Skins/CatalogSkin.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/Skins/SkinCatalogClient.cs delete mode 100644 BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs delete mode 100644 BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs delete mode 100644 BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs delete mode 100644 BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs create mode 100644 db/02_readonly_role.sql create mode 100644 db/03_catalog_audit.sql 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("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("CollectionSkin", b => + { + b.Property("CollectionsId") + .HasColumnType("integer") + .HasColumnName("collections_id"); + + b.Property("SkinsId") + .HasColumnType("integer") + .HasColumnName("skins_id"); + + b.HasKey("CollectionsId", "SkinsId") + .HasName("pk_skin_collections"); + + b.HasIndex("SkinsId") + .HasDatabaseName("ix_skin_collections_skins_id"); + + b.ToTable("skin_collections", "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.Weapon", "Weapon") + .WithMany("Skins") + .HasForeignKey("WeaponId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skins_weapons_weapon_id"); + + 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("CollectionSkin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Collection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_collections_collections_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", null) + .WithMany() + .HasForeignKey("SkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_skins_skins_id"); + }); + + 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/20260529211544_UseStaticSkinCatalog.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.cs new file mode 100644 index 0000000..3c9cb45 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BlueLaminate.EFCore.Migrations +{ + /// + public partial class UseStaticSkinCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_skins_collections_collection_id", + schema: "skintracker", + table: "skins"); + + migrationBuilder.DropIndex( + name: "ix_skins_collection_id", + schema: "skintracker", + table: "skins"); + + migrationBuilder.DropColumn( + name: "collection_id", + schema: "skintracker", + table: "skins"); + + migrationBuilder.CreateTable( + name: "skin_collections", + schema: "skintracker", + columns: table => new + { + collections_id = table.Column(type: "integer", nullable: false), + skins_id = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_skin_collections", x => new { x.collections_id, x.skins_id }); + table.ForeignKey( + name: "fk_skin_collections_collections_collections_id", + column: x => x.collections_id, + principalSchema: "skintracker", + principalTable: "collections", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_skin_collections_skins_skins_id", + column: x => x.skins_id, + principalSchema: "skintracker", + principalTable: "skins", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_skin_collections_skins_id", + schema: "skintracker", + table: "skin_collections", + column: "skins_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "skin_collections", + schema: "skintracker"); + + migrationBuilder.AddColumn( + name: "collection_id", + schema: "skintracker", + table: "skins", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "ix_skins_collection_id", + schema: "skintracker", + table: "skins", + column: "collection_id"); + + migrationBuilder.AddForeignKey( + name: "fk_skins_collections_collection_id", + schema: "skintracker", + table: "skins", + column: "collection_id", + principalSchema: "skintracker", + principalTable: "collections", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs index 99c0979..05921e0 100644 --- a/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs @@ -23,6 +23,40 @@ namespace BlueLaminate.EFCore.Migrations 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") @@ -156,16 +190,12 @@ namespace BlueLaminate.EFCore.Migrations .HasColumnType("text") .HasColumnName("description"); - b.Property("FloatMax") - .ValueGeneratedOnAdd() + b.Property("FloatMax") .HasColumnType("numeric(10,9)") - .HasDefaultValue(1.0m) .HasColumnName("float_max"); - b.Property("FloatMin") - .ValueGeneratedOnAdd() + b.Property("FloatMin") .HasColumnType("numeric(10,9)") - .HasDefaultValue(0.0m) .HasColumnName("float_min"); b.Property("ImageUrl") @@ -182,7 +212,20 @@ namespace BlueLaminate.EFCore.Migrations .HasColumnType("text") .HasColumnName("rarity"); - b.Property("TrueFloat") + 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") @@ -195,6 +238,10 @@ namespace BlueLaminate.EFCore.Migrations b.HasKey("Id") .HasName("pk_skins"); + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_skins_slug"); + b.HasIndex("TrueFloat") .HasDatabaseName("ix_skins_true_float"); @@ -427,6 +474,25 @@ namespace BlueLaminate.EFCore.Migrations b.ToTable("weapons", "skintracker"); }); + modelBuilder.Entity("CollectionSkin", b => + { + b.Property("CollectionsId") + .HasColumnType("integer") + .HasColumnName("collections_id"); + + b.Property("SkinsId") + .HasColumnType("integer") + .HasColumnName("skins_id"); + + b.HasKey("CollectionsId", "SkinsId") + .HasName("pk_skin_collections"); + + b.HasIndex("SkinsId") + .HasDatabaseName("ix_skin_collections_skins_id"); + + b.ToTable("skin_collections", "skintracker"); + }); + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => { b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance") @@ -556,6 +622,23 @@ namespace BlueLaminate.EFCore.Migrations b.Navigation("Trade"); }); + modelBuilder.Entity("CollectionSkin", b => + { + b.HasOne("BlueLaminate.EFCore.Entities.Collection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_collections_collections_id"); + + b.HasOne("BlueLaminate.EFCore.Entities.Skin", null) + .WithMany() + .HasForeignKey("SkinsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_skin_collections_skins_skins_id"); + }); + modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b => { b.Navigation("TradeItems"); diff --git a/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj b/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj index 0a81ac4..9ed914b 100644 --- a/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj +++ b/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj @@ -6,8 +6,4 @@ enable - - - - diff --git a/BlueLaminate/BlueLaminate.Scraper/Skins/CatalogSkin.cs b/BlueLaminate/BlueLaminate.Scraper/Skins/CatalogSkin.cs new file mode 100644 index 0000000..d9776e7 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Skins/CatalogSkin.cs @@ -0,0 +1,36 @@ +namespace BlueLaminate.Scraper.Skins; + +/// A single CS2 skin from the CSGO-API static catalogue (skins.json). +/// Stable catalogue id, e.g. "skin-e757fd7191f9". Globally unique natural key. +/// Owning weapon, e.g. "AK-47", "Hand Wraps", "Bayonet". +/// Weapon category, e.g. "Rifles", "Knives", "Gloves". Becomes the weapon type. +/// "CT", "T", or "Both". +/// Skin/pattern name, e.g. "Dragon Lore"; "Vanilla" for knives with no finish. +/// Rarity tier, e.g. "Covert", "Classified", "Extraordinary". +/// Flavour/description text, or null. +/// Catalogue image URL, or null. +/// True if a StatTrak variant exists. +/// True if a Souvenir variant exists. +/// Minimum wear value, or null when the catalogue gives none (e.g. vanilla knives). +/// Maximum wear value, or null. +/// Collections and containers this skin belongs to. +public sealed record CatalogSkin( + string Id, + string WeaponName, + string Category, + string Team, + string Name, + string Rarity, + string? Description, + string? ImageUrl, + bool StatTrakAvailable, + bool SouvenirAvailable, + decimal? FloatMin, + decimal? FloatMax, + IReadOnlyList Sources); + +/// A collection or container a skin originates from. +/// Stable catalogue id, e.g. "collection-set-community-37" or "crate-4288". Natural key. +/// Display name, e.g. "The Dead Hand Collection", "Glove Case". +/// "Collection" or "Container". +public sealed record CatalogSource(string Id, string Name, string Type); diff --git a/BlueLaminate/BlueLaminate.Scraper/Skins/SkinCatalogClient.cs b/BlueLaminate/BlueLaminate.Scraper/Skins/SkinCatalogClient.cs new file mode 100644 index 0000000..34d27cf --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Skins/SkinCatalogClient.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace BlueLaminate.Scraper.Skins; + +/// +/// Loads the CS2 skin catalogue from the ByMykel/CSGO-API static dataset +/// (skins.json) and maps it to records. This replaces +/// the old HTML scraper: one JSON file carries every skin with its weapon, +/// category, rarity, wear range, and the collections/containers it comes from. +/// +public sealed class SkinCatalogClient +{ + public const string DefaultUrl = + "https://raw.githubusercontent.com/ByMykel/CSGO-API/refs/heads/main/public/api/en/skins.json"; + + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; + + private readonly HttpClient _http; + private readonly string _url; + + public SkinCatalogClient(HttpClient http, string? url = null) + { + _http = http; + _url = url ?? DefaultUrl; + } + + public async Task> FetchAsync(CancellationToken ct = default) + { + await using var stream = await _http.GetStreamAsync(_url, ct); + var dtos = await JsonSerializer.DeserializeAsync>(stream, Options, ct) + ?? throw new InvalidOperationException("skins.json deserialized to null."); + + return dtos.Select(Map).ToList(); + } + + private static CatalogSkin Map(SkinDto dto) + { + var sources = new List(); + AddSources(sources, dto.Collections, "Collection"); + AddSources(sources, dto.Crates, "Container"); + + return new CatalogSkin( + Id: dto.Id, + WeaponName: dto.Weapon?.Name ?? "Unknown", + Category: dto.Category?.Name ?? "Unknown", + Team: MapTeam(dto.Team?.Id), + // Knives with no finish carry a null pattern; "Vanilla" is the community term. + Name: dto.Pattern?.Name ?? "Vanilla", + Rarity: dto.Rarity?.Name ?? "Unknown", + Description: dto.Description, + ImageUrl: dto.Image, + StatTrakAvailable: dto.Stattrak, + SouvenirAvailable: dto.Souvenir, + FloatMin: dto.MinFloat, + FloatMax: dto.MaxFloat, + Sources: sources); + } + + private static void AddSources(List into, List? items, string type) + { + if (items is null) + return; + + foreach (var item in items) + { + if (string.IsNullOrEmpty(item.Id) || string.IsNullOrEmpty(item.Name)) + continue; + if (into.Any(s => s.Id == item.Id)) + continue; + into.Add(new CatalogSource(item.Id, item.Name, type)); + } + } + + private static string MapTeam(string? teamId) => teamId switch + { + "terrorists" => "T", + "counter-terrorists" => "CT", + _ => "Both", + }; + + private sealed record SkinDto( + string Id, + string? Name, + string? Description, + NamedDto? Weapon, + NamedDto? Category, + NamedDto? Pattern, + decimal? MinFloat, + decimal? MaxFloat, + NamedDto? Rarity, + bool Stattrak, + bool Souvenir, + string? Image, + NamedDto? Team, + List? Collections, + List? Crates); + + private sealed record NamedDto(string? Id, string? Name); +} diff --git a/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs b/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs deleted file mode 100644 index e890a85..0000000 --- a/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BlueLaminate.Scraper.Weapons; - -/// A single CS2 weapon parsed from the Counter-Strike wiki. -/// Display name, e.g. "AK-47". -/// Category from the wiki heading, e.g. "Pistols", "Assault Rifles". -/// "CT", "T", or "Both". -public sealed record ScrapedWeapon(string Name, string Type, string Team); diff --git a/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs b/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs deleted file mode 100644 index 15e3eef..0000000 --- a/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Text.RegularExpressions; -using BlueLaminate.Scraper.Wiki; -using HtmlAgilityPack; - -namespace BlueLaminate.Scraper.Weapons; - -/// -/// Scrapes the CS2 weapon catalogue from the wiki's "Weapons" page. -/// -/// Layout: the page has one "tabber" per weapon category, each immediately -/// preceded by a section heading (the category / Type). Inside each tabber the -/// "Global Offensive & Counter-Strike 2" tab holds a gallery of captions — -/// one per weapon, optionally suffixed with "(CT)" or "(T)" for team-locked -/// weapons. -/// -public sealed class WeaponWikiScraper -{ - private const string Page = "Weapons"; - private const string Cs2TabHash = "Global_Offensive_&_Counter-Strike_2"; - - // Matches a trailing "(CT)" / "(T)" team annotation, capturing the team. - private static readonly Regex TeamAnnotation = - new(@"\s*\((CT|T)\)\s*$", RegexOptions.Compiled); - - // The wiki labels the default knife "Stock Knife"; drop the prefix. - private static readonly Regex StockPrefix = - new(@"^Stock\s+", RegexOptions.Compiled); - - private readonly WikiPageFetcher _fetcher; - - public WeaponWikiScraper(WikiPageFetcher fetcher) => _fetcher = fetcher; - - public async Task> ScrapeAsync(CancellationToken ct = default) - { - var doc = await _fetcher.LoadAsync(Page, ct); - - // Headings and tabbers in document order so each tabber inherits the - // most recent heading as its category. - var nodes = doc.DocumentNode.SelectNodes( - "//h2 | //h3 | //h4 | " - + "//div[contains(concat(' ', normalize-space(@class), ' '), ' tabber ')]"); - - var aggregator = new WeaponAggregator(); - string? currentType = null; - - if (nodes is not null) - { - foreach (var node in nodes) - { - if (node.Name is "h2" or "h3" or "h4") - { - currentType = HeadingText(node); - continue; - } - - if (currentType is null) - continue; - - foreach (var caption in Cs2Captions(node)) - aggregator.Add(caption, currentType); - } - } - - return aggregator.Build(); - } - - /// Caption texts from the CS2 tab of a single tabber, if present. - private static IEnumerable Cs2Captions(HtmlNode tabber) - { - var tabs = tabber.SelectNodes( - ".//li[contains(concat(' ', normalize-space(@class), ' '), ' wds-tabs__tab ')]"); - if (tabs is null) - yield break; - - var index = -1; - for (var i = 0; i < tabs.Count; i++) - { - // HtmlAgilityPack returns attribute values un-decoded, and the wiki - // entity-encodes the "&" in this hash (&). - var hash = HtmlEntity.DeEntitize(tabs[i].GetAttributeValue("data-hash", string.Empty)); - if (hash == Cs2TabHash) - { - index = i; - break; - } - } - - if (index < 0) - yield break; - - var contents = tabber.SelectNodes( - ".//div[contains(concat(' ', normalize-space(@class), ' '), ' wds-tab__content ')]"); - if (contents is null || index >= contents.Count) - yield break; - - var captions = contents[index].SelectNodes( - ".//div[contains(concat(' ', normalize-space(@class), ' '), ' lightbox-caption ')]"); - if (captions is null) - yield break; - - foreach (var caption in captions) - yield return WikiText.Normalize(caption.InnerText); - } - - private static string HeadingText(HtmlNode heading) - { - var headline = heading.SelectSingleNode( - ".//span[contains(concat(' ', normalize-space(@class), ' '), ' mw-headline ')]"); - return WikiText.Normalize((headline ?? heading).InnerText); - } - - /// - /// Collapses the per-caption rows into one weapon per name, tracking which - /// teams it appeared for so a weapon shown as both "(CT)" and "(T)" (or with - /// no annotation) resolves to "Both". - /// - private sealed class WeaponAggregator - { - private sealed class Entry - { - public required string Type { get; init; } - public bool SawCt; - public bool SawT; - public bool SawUnannotated; - } - - private readonly Dictionary _byName = new(); - private readonly List _order = new(); - - public void Add(string caption, string type) - { - if (string.IsNullOrEmpty(caption)) - return; - - var match = TeamAnnotation.Match(caption); - var name = TeamAnnotation.Replace(caption, string.Empty); - name = StockPrefix.Replace(name, string.Empty).Trim(); - if (name.Length == 0) - return; - - if (!_byName.TryGetValue(name, out var entry)) - { - entry = new Entry { Type = type }; - _byName[name] = entry; - _order.Add(name); - } - - if (!match.Success) - entry.SawUnannotated = true; - else if (match.Groups[1].Value == "CT") - entry.SawCt = true; - else - entry.SawT = true; - } - - public IReadOnlyList Build() - { - var result = new List(_order.Count); - foreach (var name in _order) - { - var e = _byName[name]; - var team = - e.SawUnannotated || (e.SawCt && e.SawT) ? "Both" - : e.SawCt ? "CT" - : e.SawT ? "T" - : "Both"; - result.Add(new ScrapedWeapon(name, e.Type, team)); - } - return result; - } - } -} diff --git a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs deleted file mode 100644 index de5a41b..0000000 --- a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.Json; -using HtmlAgilityPack; - -namespace BlueLaminate.Scraper.Wiki; - -/// -/// Fetches a rendered page from the Counter-Strike Fandom wiki, shared by all -/// wiki scrapers. -/// -/// The rendered HTML pages sit behind Cloudflare, which 403s .NET's TLS -/// fingerprint regardless of headers. The MediaWiki action=parse API is -/// not challenged, so we fetch the same content as JSON from there and return -/// the embedded HTML as a parsed document. -/// -public sealed class WikiPageFetcher -{ - private const string ApiBase = "https://counterstrike.fandom.com/api.php"; - - private readonly HttpClient _http; - - public WikiPageFetcher(HttpClient http) => _http = http; - - /// Loads a wiki page (e.g. "Weapons") as a parsed HTML document. - public async Task LoadAsync(string page, CancellationToken ct = default) - { - var url = $"{ApiBase}?action=parse&page={Uri.EscapeDataString(page)}&prop=text&format=json"; - - using var resp = await _http.GetAsync(url, ct); - resp.EnsureSuccessStatusCode(); - - await using var stream = await resp.Content.ReadAsStreamAsync(ct); - using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct); - - if (json.RootElement.TryGetProperty("error", out var error)) - { - var info = error.TryGetProperty("info", out var i) ? i.GetString() : "unknown error"; - throw new InvalidOperationException($"Wiki API returned an error for page '{page}': {info}"); - } - - var html = json.RootElement - .GetProperty("parse") - .GetProperty("text") - .GetProperty("*") - .GetString() - ?? throw new InvalidOperationException($"Wiki API response for page '{page}' had no parsed text."); - - var doc = new HtmlDocument(); - doc.LoadHtml(html); - return doc; - } -} diff --git a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs deleted file mode 100644 index 7c72cfc..0000000 --- a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.RegularExpressions; -using HtmlAgilityPack; - -namespace BlueLaminate.Scraper.Wiki; - -/// Text helpers shared by wiki scrapers. -public static class WikiText -{ - private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); - - /// Decodes HTML entities and collapses whitespace runs to single spaces. - public static string Normalize(string raw) => - Whitespace.Replace(HtmlEntity.DeEntitize(raw) ?? string.Empty, " ").Trim(); -} diff --git a/db/02_readonly_role.sql b/db/02_readonly_role.sql new file mode 100644 index 0000000..867cec3 --- /dev/null +++ b/db/02_readonly_role.sql @@ -0,0 +1,37 @@ +-- ============================================================ +-- CS2 Skin Tracker — read-only reporting/BI role +-- Run as a superuser (e.g. postgres) OR as skintracker_app, +-- connected to the skintracker database. Safe to re-run. +-- +-- Replace the password placeholder before running. +-- This role can SELECT every table in the skintracker schema +-- (existing and future) and nothing else: no INSERT/UPDATE/ +-- DELETE, no DDL, no access to other schemas. +-- ============================================================ + +-- 1. Login role, guarded so the script is idempotent. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'skintracker_readonly') THEN + CREATE ROLE skintracker_readonly WITH LOGIN PASSWORD 'change-me-readonly-password'; + END IF; +END +$$; + +-- 2. Allow the role to connect to the database and resolve names +-- in the skintracker schema by default. +GRANT CONNECT ON DATABASE skintracker TO skintracker_readonly; +GRANT USAGE ON SCHEMA skintracker TO skintracker_readonly; +ALTER ROLE skintracker_readonly SET search_path = skintracker; + +-- 3. Read access to every existing table/view in the schema. +GRANT SELECT ON ALL TABLES IN SCHEMA skintracker TO skintracker_readonly; + +-- 4. Read access to tables the app role creates LATER (e.g. new +-- migrations). Without this, future tables would not be visible. +-- Must name the role that OWNS new objects — that is skintracker_app. +ALTER DEFAULT PRIVILEGES FOR ROLE skintracker_app IN SCHEMA skintracker + GRANT SELECT ON TABLES TO skintracker_readonly; + +-- 5. Belt-and-braces: ensure the role can never write to public either. +REVOKE CREATE ON SCHEMA public FROM skintracker_readonly; diff --git a/db/03_catalog_audit.sql b/db/03_catalog_audit.sql new file mode 100644 index 0000000..d623644 --- /dev/null +++ b/db/03_catalog_audit.sql @@ -0,0 +1,82 @@ +-- ============================================================ +-- CS2 Skin Tracker — catalog audit (read-only) +-- Run against the skintracker database as any role with SELECT +-- (e.g. skintracker_readonly). Pure SELECTs, safe to re-run. +-- +-- Purpose: sanity-check the weapon/glove skin catalog for the +-- gaps that break tradeup math and pricing joins. +-- ============================================================ + +SET search_path = skintracker; + +-- 1. Totals — quick scale check. +SELECT 'skins' AS table, count(*) FROM skins +UNION ALL SELECT 'weapons', count(*) FROM weapons +UNION ALL SELECT 'collections', count(*) FROM collections +UNION ALL SELECT 'skin_collections', count(*) FROM skin_collections +UNION ALL SELECT 'skin_conditions', count(*) FROM skin_conditions; + +-- 2. Orphan skins: belong to NO collection/container. +-- Expected: only "Howl" (Contraband, removed from its collection). +SELECT s.id, s.slug, s.name, s.rarity +FROM skins s +LEFT JOIN skin_collections sc ON sc.skins_id = s.id +WHERE sc.skins_id IS NULL +ORDER BY s.name; + +-- 3. Missing float bounds. +-- Expected: only Vanilla (default) knives, which have no wear range. +SELECT s.id, s.slug, s.name, s.float_min, s.float_max +FROM skins s +WHERE s.float_min IS NULL OR s.float_max IS NULL +ORDER BY s.name; + +-- 4. Rarity hygiene: any nulls/blanks, and the distribution. +SELECT coalesce(nullif(rarity, ''), '') AS rarity, count(*) +FROM skins +GROUP BY 1 +ORDER BY 2 DESC; + +-- 5. Inverted or out-of-range float bounds (should return 0 rows). +SELECT id, slug, name, float_min, float_max +FROM skins +WHERE float_min > float_max OR float_min < 0 OR float_max > 1; + +-- 6. Duplicate slugs (unique index should keep this empty). +SELECT slug, count(*) +FROM skins +GROUP BY slug +HAVING count(*) > 1; + +-- 7. Collection type split (expect only Collection / Container). +SELECT coalesce(type, '') AS type, count(*) +FROM collections +GROUP BY 1 +ORDER BY 2 DESC; + +-- 8. Skins with no weapon link (should return 0 rows). +SELECT s.id, s.slug +FROM skins s +LEFT JOIN weapons w ON w.id = s.weapon_id +WHERE w.id IS NULL; + +-- 9. Skin count per weapon type — confirms only weapon/glove items. +-- Note: Zeus x27 (type "Equipment") skins ARE valid catalog/tradeup items. +SELECT w.type, count(s.id) AS skins +FROM weapons w +LEFT JOIN skins s ON s.weapon_id = w.id +GROUP BY w.type +ORDER BY skins DESC; + +-- 10. StatTrak / Souvenir availability. +SELECT + sum(CASE WHEN stat_trak_available THEN 1 ELSE 0 END) AS stattrak, + sum(CASE WHEN souvenir_available THEN 1 ELSE 0 END) AS souvenir, + count(*) AS total +FROM skins; + +-- 11. true_float split (capped range vs full 0.0–1.0; null = unknown bounds). +SELECT coalesce(true_float::text, '') AS true_float, count(*) +FROM skins +GROUP BY 1 +ORDER BY 2 DESC;