From feb47b1f8e472f9c96cd8c2bbe8bce5c5dfd92b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20Farkas?= Date: Thu, 26 Dec 2024 13:49:26 +0100 Subject: [PATCH] refactor: properly rename connections-related tables; top-level namespaces --- Database/ConnectionsContext.cs | 27 +- Database/Entities/CategoriesCard.cs | 30 -- Database/Entities/CategoriesCategory.cs | 35 -- Database/Entities/CategoriesColor.cs | 10 - Database/Entities/CategoriesPuzzle.cs | 47 -- Database/Entities/ConnectionsCard.cs | 29 ++ Database/Entities/ConnectionsCategory.cs | 34 ++ Database/Entities/ConnectionsColor.cs | 10 + Database/Entities/ConnectionsPuzzle.cs | 45 ++ ...26124356_RenameTablesCorrectly.Designer.cs | 136 ++++++ .../20241226124356_RenameTablesCorrectly.cs | 134 ++++++ ...6124529_RenameCollectionsTable.Designer.cs | 136 ++++++ .../20241226124529_RenameCollectionsTable.cs | 78 ++++ .../ConnectionsContextModelSnapshot.cs | 47 +- Database/Repository/PuzzleRepository.cs | 113 +++-- Events/PuzzleSyncEvent.cs | 59 +-- Features/Puzzle/Get/GetPuzzleEndpoint.cs | 4 +- Features/Puzzle/List/ListPuzzlesEndpoint.cs | 4 +- Models/PuzzleDTO.cs | 10 +- SyncScheduler.cs | 2 +- Utility/EnvironmentUtility.cs | 10 +- Utility/HashUtility.cs | 12 +- Utility/SyncUtility.cs | 436 +++++++++--------- Utility/TimezoneUtility.cs | 14 +- 24 files changed, 967 insertions(+), 495 deletions(-) delete mode 100644 Database/Entities/CategoriesCard.cs delete mode 100644 Database/Entities/CategoriesCategory.cs delete mode 100644 Database/Entities/CategoriesColor.cs delete mode 100644 Database/Entities/CategoriesPuzzle.cs create mode 100644 Database/Entities/ConnectionsCard.cs create mode 100644 Database/Entities/ConnectionsCategory.cs create mode 100644 Database/Entities/ConnectionsColor.cs create mode 100644 Database/Entities/ConnectionsPuzzle.cs create mode 100644 Database/Migrations/20241226124356_RenameTablesCorrectly.Designer.cs create mode 100644 Database/Migrations/20241226124356_RenameTablesCorrectly.cs create mode 100644 Database/Migrations/20241226124529_RenameCollectionsTable.Designer.cs create mode 100644 Database/Migrations/20241226124529_RenameCollectionsTable.cs diff --git a/Database/ConnectionsContext.cs b/Database/ConnectionsContext.cs index 43870f9..5196db5 100644 --- a/Database/ConnectionsContext.cs +++ b/Database/ConnectionsContext.cs @@ -1,23 +1,22 @@ using ConnectionsAPI.Database.Entities; using Microsoft.EntityFrameworkCore; -namespace ConnectionsAPI.Database +namespace ConnectionsAPI.Database; +public class ConnectionsContext(DbContextOptions dbContextOptions) : DbContext(dbContextOptions) { - public class ConnectionsContext(DbContextOptions dbContextOptions) : DbContext(dbContextOptions) + public required DbSet ConnectionsPuzzles { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) { - public DbSet CategoriesPuzzles { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .HasIndex(x => x.PrintDate).IsUnique(); - modelBuilder.Entity() - .Ignore(x => x.NextPrintDate); - modelBuilder.Entity() - .Ignore(x => x.PrevPrintDate); + modelBuilder.Entity() + .HasIndex(x => x.PrintDate).IsUnique(); + modelBuilder.Entity() + .Ignore(x => x.NextPrintDate); + modelBuilder.Entity() + .Ignore(x => x.PrevPrintDate); - base.OnModelCreating(modelBuilder); - } + base.OnModelCreating(modelBuilder); } } + diff --git a/Database/Entities/CategoriesCard.cs b/Database/Entities/CategoriesCard.cs deleted file mode 100644 index 2ef78c6..0000000 --- a/Database/Entities/CategoriesCard.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace ConnectionsAPI.Database.Entities -{ - public class CategoriesCard - { - /// - /// Primary key of the entity - /// - public int Id { get; set; } - - /// - /// The contents of this card (the word) - /// - public string Content { get; set; } = string.Empty; - - /// - /// The initial position of this card on the grid - /// - public int Position { get; set; } - - /// - /// The ID of the associated Connections category - /// - public int CategoriesCategoryId { get; set; } - - /// - /// The associated category instance - /// - public virtual CategoriesCategory? Category { get; set; } - } -} \ No newline at end of file diff --git a/Database/Entities/CategoriesCategory.cs b/Database/Entities/CategoriesCategory.cs deleted file mode 100644 index e276515..0000000 --- a/Database/Entities/CategoriesCategory.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace ConnectionsAPI.Database.Entities -{ - public class CategoriesCategory - { - /// - /// Primary key of the entity - /// - public int Id { get; set; } - - /// - /// The name of the category in this Connections puzzle - /// - public string Name { get; set; } = string.Empty; - - /// - /// The color of the category in this Connections puzzle; Also used for sorting - /// - public CategoriesColor Color { get; set; } - - /// - /// The ID of the associated Connections puzzle - /// - public int CategoriesPuzzleId { get; set; } - - /// - /// The associated puzzle instance - /// - public virtual CategoriesPuzzle? CategoriesPuzzle { get; set; } - - /// - /// The cards associated with this category - /// - public ICollection CategoriesPuzzleCards { get; set; } = []; - } -} diff --git a/Database/Entities/CategoriesColor.cs b/Database/Entities/CategoriesColor.cs deleted file mode 100644 index 2483343..0000000 --- a/Database/Entities/CategoriesColor.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ConnectionsAPI.Database.Entities -{ - public enum CategoriesColor - { - Yellow = 1, - Green = 2, - Blue = 3, - Purple = 4, - } -} diff --git a/Database/Entities/CategoriesPuzzle.cs b/Database/Entities/CategoriesPuzzle.cs deleted file mode 100644 index b2b1a1b..0000000 --- a/Database/Entities/CategoriesPuzzle.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; - -namespace ConnectionsAPI.Database.Entities -{ - public class CategoriesPuzzle - { - /// - /// Primary key of the entity - /// - public int Id { get; set; } - - /// - /// When the entity was created (is the sync date) - /// - public DateTime CreatedDate { get; set; } - - /// - /// When the puzzle was "printed" online - /// - public string PrintDate { get; set; } = string.Empty; - - /// - /// The name of the editor for the puzzle - /// - public string EditorName { get; set; } = string.Empty; - - /// - /// The actual count of the puzzle - /// - public int Index { get; set; } - - /// - /// The MD5 hash for the source content used to sync this puzzle - /// - public string ContentMD5 { get; set; } = string.Empty; - - /// - /// The categories associated with this puzzle - /// - public virtual ICollection Categories { get; set; } = []; - - [NotMapped] - public string? PrevPrintDate { get; set; } - [NotMapped] - public string? NextPrintDate { get; set; } - } -} diff --git a/Database/Entities/ConnectionsCard.cs b/Database/Entities/ConnectionsCard.cs new file mode 100644 index 0000000..7ff32dd --- /dev/null +++ b/Database/Entities/ConnectionsCard.cs @@ -0,0 +1,29 @@ +namespace ConnectionsAPI.Database.Entities; + +public class ConnectionsCard +{ + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// The contents of this card (the word) + /// + public string Content { get; set; } = string.Empty; + + /// + /// The initial position of this card on the grid + /// + public int Position { get; set; } + + /// + /// The ID of the associated Connections category + /// + public int ConnectionsCategoryId { get; set; } + + /// + /// The associated category instance + /// + public virtual ConnectionsCategory? Category { get; set; } +} diff --git a/Database/Entities/ConnectionsCategory.cs b/Database/Entities/ConnectionsCategory.cs new file mode 100644 index 0000000..b12aad4 --- /dev/null +++ b/Database/Entities/ConnectionsCategory.cs @@ -0,0 +1,34 @@ +namespace ConnectionsAPI.Database.Entities; + +public class ConnectionsCategory +{ + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// The name of the category in this Connections puzzle + /// + public string Name { get; set; } = string.Empty; + + /// + /// The color of the category in this Connections puzzle; Also used for sorting + /// + public ConnectionsColor Color { get; set; } + + /// + /// The ID of the associated Connections puzzle + /// + public int ConnectionsPuzzleId { get; set; } + + /// + /// The associated puzzle instance + /// + public virtual ConnectionsPuzzle? ConnectionsPuzzle { get; set; } + + /// + /// The cards associated with this category + /// + public ICollection Cards { get; set; } = []; +} diff --git a/Database/Entities/ConnectionsColor.cs b/Database/Entities/ConnectionsColor.cs new file mode 100644 index 0000000..22c8680 --- /dev/null +++ b/Database/Entities/ConnectionsColor.cs @@ -0,0 +1,10 @@ +namespace ConnectionsAPI.Database.Entities; + +public enum ConnectionsColor +{ + Yellow = 1, + Green = 2, + Blue = 3, + Purple = 4, +} + diff --git a/Database/Entities/ConnectionsPuzzle.cs b/Database/Entities/ConnectionsPuzzle.cs new file mode 100644 index 0000000..79d0cd1 --- /dev/null +++ b/Database/Entities/ConnectionsPuzzle.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace ConnectionsAPI.Database.Entities; +public class ConnectionsPuzzle +{ + /// + /// Primary key of the entity + /// + public int Id { get; set; } + + /// + /// When the entity was created (is the sync date) + /// + public DateTime CreatedDate { get; set; } + + /// + /// When the puzzle was "printed" online + /// + public string PrintDate { get; set; } = string.Empty; + + /// + /// The name of the editor for the puzzle + /// + public string EditorName { get; set; } = string.Empty; + + /// + /// The actual count of the puzzle + /// + public int Index { get; set; } + + /// + /// The MD5 hash for the source content used to sync this puzzle + /// + public string ContentMD5 { get; set; } = string.Empty; + + /// + /// The categories associated with this puzzle + /// + public virtual ICollection Categories { get; set; } = []; + + [NotMapped] + public string? PrevPrintDate { get; set; } + [NotMapped] + public string? NextPrintDate { get; set; } +} diff --git a/Database/Migrations/20241226124356_RenameTablesCorrectly.Designer.cs b/Database/Migrations/20241226124356_RenameTablesCorrectly.Designer.cs new file mode 100644 index 0000000..93e67ce --- /dev/null +++ b/Database/Migrations/20241226124356_RenameTablesCorrectly.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using ConnectionsAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + [DbContext(typeof(ConnectionsContext))] + [Migration("20241226124356_RenameTablesCorrectly")] + partial class RenameTablesCorrectly + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("ConnectionsCard"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("ConnectionsPuzzleId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConnectionsPuzzleId"); + + b.ToTable("ConnectionsCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentMD5") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("EditorName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("PrintDate") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrintDate") + .IsUnique(); + + b.ToTable("CategoriesPuzzles"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsCategory", "Category") + .WithMany("Cards") + .HasForeignKey("CategoryId"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", "ConnectionsPuzzle") + .WithMany("Categories") + .HasForeignKey("ConnectionsPuzzleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ConnectionsPuzzle"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => + { + b.Navigation("Categories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Database/Migrations/20241226124356_RenameTablesCorrectly.cs b/Database/Migrations/20241226124356_RenameTablesCorrectly.cs new file mode 100644 index 0000000..4f7e0b4 --- /dev/null +++ b/Database/Migrations/20241226124356_RenameTablesCorrectly.cs @@ -0,0 +1,134 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + /// + public partial class RenameTablesCorrectly : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CategoriesCard"); + + migrationBuilder.DropTable( + name: "CategoriesCategory"); + + migrationBuilder.CreateTable( + name: "ConnectionsCategory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Color = table.Column(type: "INTEGER", nullable: false), + ConnectionsPuzzleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ConnectionsCategory", x => x.Id); + table.ForeignKey( + name: "FK_ConnectionsCategory_CategoriesPuzzles_ConnectionsPuzzleId", + column: x => x.ConnectionsPuzzleId, + principalTable: "CategoriesPuzzles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ConnectionsCard", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Content = table.Column(type: "TEXT", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false), + CategoriesCategoryId = table.Column(type: "INTEGER", nullable: false), + CategoryId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ConnectionsCard", x => x.Id); + table.ForeignKey( + name: "FK_ConnectionsCard_ConnectionsCategory_CategoryId", + column: x => x.CategoryId, + principalTable: "ConnectionsCategory", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_ConnectionsCard_CategoryId", + table: "ConnectionsCard", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_ConnectionsCategory_ConnectionsPuzzleId", + table: "ConnectionsCategory", + column: "ConnectionsPuzzleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ConnectionsCard"); + + migrationBuilder.DropTable( + name: "ConnectionsCategory"); + + migrationBuilder.CreateTable( + name: "CategoriesCategory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CategoriesPuzzleId = table.Column(type: "INTEGER", nullable: false), + Color = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoriesCategory", x => x.Id); + table.ForeignKey( + name: "FK_CategoriesCategory_CategoriesPuzzles_CategoriesPuzzleId", + column: x => x.CategoriesPuzzleId, + principalTable: "CategoriesPuzzles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "CategoriesCard", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CategoriesCategoryId = table.Column(type: "INTEGER", nullable: false), + Content = table.Column(type: "TEXT", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CategoriesCard", x => x.Id); + table.ForeignKey( + name: "FK_CategoriesCard_CategoriesCategory_CategoriesCategoryId", + column: x => x.CategoriesCategoryId, + principalTable: "CategoriesCategory", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CategoriesCard_CategoriesCategoryId", + table: "CategoriesCard", + column: "CategoriesCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_CategoriesCategory_CategoriesPuzzleId", + table: "CategoriesCategory", + column: "CategoriesPuzzleId"); + } + } +} diff --git a/Database/Migrations/20241226124529_RenameCollectionsTable.Designer.cs b/Database/Migrations/20241226124529_RenameCollectionsTable.Designer.cs new file mode 100644 index 0000000..2d806fc --- /dev/null +++ b/Database/Migrations/20241226124529_RenameCollectionsTable.Designer.cs @@ -0,0 +1,136 @@ +// +using System; +using ConnectionsAPI.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + [DbContext(typeof(ConnectionsContext))] + [Migration("20241226124529_RenameCollectionsTable")] + partial class RenameCollectionsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CategoriesCategoryId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.ToTable("ConnectionsCard"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasColumnType("INTEGER"); + + b.Property("ConnectionsPuzzleId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConnectionsPuzzleId"); + + b.ToTable("ConnectionsCategory"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ContentMD5") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedDate") + .HasColumnType("TEXT"); + + b.Property("EditorName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("PrintDate") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PrintDate") + .IsUnique(); + + b.ToTable("ConnectionsPuzzles"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsCategory", "Category") + .WithMany("Cards") + .HasForeignKey("CategoryId"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", "ConnectionsPuzzle") + .WithMany("Categories") + .HasForeignKey("ConnectionsPuzzleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ConnectionsPuzzle"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => + { + b.Navigation("Cards"); + }); + + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => + { + b.Navigation("Categories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Database/Migrations/20241226124529_RenameCollectionsTable.cs b/Database/Migrations/20241226124529_RenameCollectionsTable.cs new file mode 100644 index 0000000..46e393d --- /dev/null +++ b/Database/Migrations/20241226124529_RenameCollectionsTable.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ConnectionsAPI.Database.Migrations +{ + /// + public partial class RenameCollectionsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ConnectionsCategory_CategoriesPuzzles_ConnectionsPuzzleId", + table: "ConnectionsCategory"); + + migrationBuilder.DropPrimaryKey( + name: "PK_CategoriesPuzzles", + table: "CategoriesPuzzles"); + + migrationBuilder.RenameTable( + name: "CategoriesPuzzles", + newName: "ConnectionsPuzzles"); + + migrationBuilder.RenameIndex( + name: "IX_CategoriesPuzzles_PrintDate", + table: "ConnectionsPuzzles", + newName: "IX_ConnectionsPuzzles_PrintDate"); + + migrationBuilder.AddPrimaryKey( + name: "PK_ConnectionsPuzzles", + table: "ConnectionsPuzzles", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_ConnectionsCategory_ConnectionsPuzzles_ConnectionsPuzzleId", + table: "ConnectionsCategory", + column: "ConnectionsPuzzleId", + principalTable: "ConnectionsPuzzles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ConnectionsCategory_ConnectionsPuzzles_ConnectionsPuzzleId", + table: "ConnectionsCategory"); + + migrationBuilder.DropPrimaryKey( + name: "PK_ConnectionsPuzzles", + table: "ConnectionsPuzzles"); + + migrationBuilder.RenameTable( + name: "ConnectionsPuzzles", + newName: "CategoriesPuzzles"); + + migrationBuilder.RenameIndex( + name: "IX_ConnectionsPuzzles_PrintDate", + table: "CategoriesPuzzles", + newName: "IX_CategoriesPuzzles_PrintDate"); + + migrationBuilder.AddPrimaryKey( + name: "PK_CategoriesPuzzles", + table: "CategoriesPuzzles", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_ConnectionsCategory_CategoriesPuzzles_ConnectionsPuzzleId", + table: "ConnectionsCategory", + column: "ConnectionsPuzzleId", + principalTable: "CategoriesPuzzles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/Database/Migrations/ConnectionsContextModelSnapshot.cs b/Database/Migrations/ConnectionsContextModelSnapshot.cs index 1ae13a3..35a38fb 100644 --- a/Database/Migrations/ConnectionsContextModelSnapshot.cs +++ b/Database/Migrations/ConnectionsContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace ConnectionsAPI.Database.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesCard", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -26,6 +26,9 @@ namespace ConnectionsAPI.Database.Migrations b.Property("CategoriesCategoryId") .HasColumnType("INTEGER"); + b.Property("CategoryId") + .HasColumnType("INTEGER"); + b.Property("Content") .IsRequired() .HasColumnType("TEXT"); @@ -35,21 +38,21 @@ namespace ConnectionsAPI.Database.Migrations b.HasKey("Id"); - b.HasIndex("CategoriesCategoryId"); + b.HasIndex("CategoryId"); - b.ToTable("CategoriesCard"); + b.ToTable("ConnectionsCard"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesCategory", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("CategoriesPuzzleId") + b.Property("Color") .HasColumnType("INTEGER"); - b.Property("Color") + b.Property("ConnectionsPuzzleId") .HasColumnType("INTEGER"); b.Property("Name") @@ -58,12 +61,12 @@ namespace ConnectionsAPI.Database.Migrations b.HasKey("Id"); - b.HasIndex("CategoriesPuzzleId"); + b.HasIndex("ConnectionsPuzzleId"); - b.ToTable("CategoriesCategory"); + b.ToTable("ConnectionsCategory"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesPuzzle", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -92,37 +95,35 @@ namespace ConnectionsAPI.Database.Migrations b.HasIndex("PrintDate") .IsUnique(); - b.ToTable("CategoriesPuzzles"); + b.ToTable("ConnectionsPuzzles"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesCard", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCard", b => { - b.HasOne("ConnectionsAPI.Database.Entities.CategoriesCategory", "Category") - .WithMany("CategoriesPuzzleCards") - .HasForeignKey("CategoriesCategoryId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsCategory", "Category") + .WithMany("Cards") + .HasForeignKey("CategoryId"); b.Navigation("Category"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesCategory", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => { - b.HasOne("ConnectionsAPI.Database.Entities.CategoriesPuzzle", "CategoriesPuzzle") + b.HasOne("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", "ConnectionsPuzzle") .WithMany("Categories") - .HasForeignKey("CategoriesPuzzleId") + .HasForeignKey("ConnectionsPuzzleId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("CategoriesPuzzle"); + b.Navigation("ConnectionsPuzzle"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesCategory", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsCategory", b => { - b.Navigation("CategoriesPuzzleCards"); + b.Navigation("Cards"); }); - modelBuilder.Entity("ConnectionsAPI.Database.Entities.CategoriesPuzzle", b => + modelBuilder.Entity("ConnectionsAPI.Database.Entities.ConnectionsPuzzle", b => { b.Navigation("Categories"); }); diff --git a/Database/Repository/PuzzleRepository.cs b/Database/Repository/PuzzleRepository.cs index fb970f6..999d38c 100644 --- a/Database/Repository/PuzzleRepository.cs +++ b/Database/Repository/PuzzleRepository.cs @@ -1,76 +1,75 @@ using ConnectionsAPI.Database.Entities; using Microsoft.EntityFrameworkCore; -namespace ConnectionsAPI.Database.Repository +namespace ConnectionsAPI.Database.Repository; + +public class PuzzleRepository(ConnectionsContext _db) { - public class PuzzleRepository(ConnectionsContext _db) + private readonly ConnectionsContext _db = _db; + + public async Task GetPuzzleByDateAsync(string printDate, bool includeSolutions = true) { - private readonly ConnectionsContext _db = _db; + // query for the puzzle + var query = _db.ConnectionsPuzzles.AsNoTracking(); - public async Task GetPuzzleByDateAsync(string printDate, bool includeSolutions = true) + if (includeSolutions) { - // query for the puzzle - var query = _db.CategoriesPuzzles.AsNoTracking(); + query = query + .Include(x => x.Categories) + .ThenInclude(x => x.Cards); + } - if (includeSolutions) - { - query = query - .Include(x => x.Categories) - .ThenInclude(x => x.CategoriesPuzzleCards); - } + var puzzle = await query.FirstOrDefaultAsync(x => x.PrintDate == printDate); - var puzzle = await query.FirstOrDefaultAsync(x => x.PrintDate == printDate); + // if not found, we're done here + if (puzzle == null) + { + return null; + } - // if not found, we're done here - if (puzzle == null) - { - return null; - } + await EnhancePuzzleWithDatesAsync(puzzle); + return puzzle; + } + + public async Task> GetAllPuzzlesAsync(bool includeSolutions = true) + { + // query all, ordered by print date + var query = _db.ConnectionsPuzzles + .AsNoTracking(); + + if (includeSolutions) + { + query = query + .Include(x => x.Categories) + .ThenInclude(x => x.Cards); + } + + var result = (await query.OrderBy(x => x.PrintDate).ToListAsync()) ?? []; + + foreach (var puzzle in result) + { await EnhancePuzzleWithDatesAsync(puzzle); - - return puzzle; } - public async Task> GetAllPuzzlesAsync(bool includeSolutions = true) - { - // query all, ordered by print date - var query = _db.CategoriesPuzzles - .AsNoTracking(); + return result; + } - if (includeSolutions) - { - query = query - .Include(x => x.Categories) - .ThenInclude(x => x.CategoriesPuzzleCards); - } + private async Task EnhancePuzzleWithDatesAsync(ConnectionsPuzzle puzzle) + { + string? previousPuzzleDate = await _db.ConnectionsPuzzles.AsNoTracking() + .Where(x => x.PrintDate.CompareTo(puzzle.PrintDate) < 0) + .OrderByDescending(x => x.PrintDate) + .Select(x => x.PrintDate) + .FirstOrDefaultAsync(); - var result = (await query.OrderBy(x => x.PrintDate).ToListAsync()) ?? []; + string? nextPuzzleDate = await _db.ConnectionsPuzzles.AsNoTracking() + .Where(x => x.PrintDate.CompareTo(puzzle.PrintDate) > 0) + .OrderBy(x => x.PrintDate) + .Select(x => x.PrintDate) + .FirstOrDefaultAsync(); - foreach (var puzzle in result) - { - await EnhancePuzzleWithDatesAsync(puzzle); - } - - return result; - } - - private async Task EnhancePuzzleWithDatesAsync(CategoriesPuzzle puzzle) - { - string? previousPuzzleDate = await _db.CategoriesPuzzles.AsNoTracking() - .Where(x => x.PrintDate.CompareTo(puzzle.PrintDate) < 0) - .OrderByDescending(x => x.PrintDate) - .Select(x => x.PrintDate) - .FirstOrDefaultAsync(); - - string? nextPuzzleDate = await _db.CategoriesPuzzles.AsNoTracking() - .Where(x => x.PrintDate.CompareTo(puzzle.PrintDate) > 0) - .OrderBy(x => x.PrintDate) - .Select(x => x.PrintDate) - .FirstOrDefaultAsync(); - - puzzle.PrevPrintDate = previousPuzzleDate; - puzzle.NextPrintDate = nextPuzzleDate; - } + puzzle.PrevPrintDate = previousPuzzleDate; + puzzle.NextPrintDate = nextPuzzleDate; } } diff --git a/Events/PuzzleSyncEvent.cs b/Events/PuzzleSyncEvent.cs index 11caaa6..1801534 100644 --- a/Events/PuzzleSyncEvent.cs +++ b/Events/PuzzleSyncEvent.cs @@ -3,39 +3,40 @@ using ConnectionsAPI.Database; using ConnectionsAPI.Utility; using System.Diagnostics; -namespace ConnectionsAPI.Events +namespace ConnectionsAPI.Events; + +public class ConnectionsSyncEvent : IEvent { } + +public class ConnectionsSyncHandler(ILogger logger, + IServiceScopeFactory scopeFactory) : IEventHandler { - public class PuzzleSyncEvent : IEvent { } + private readonly ILogger _logger = logger; + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; - public class PuzzleSyncHandler(ILogger logger, IServiceScopeFactory scopeFactory) : IEventHandler + public async Task HandleAsync(ConnectionsSyncEvent eventModel, CancellationToken ct) { - private readonly ILogger _logger = logger; - private readonly IServiceScopeFactory _scopeFactory = scopeFactory; - - public async Task HandleAsync(PuzzleSyncEvent eventModel, CancellationToken ct) + Stopwatch stopwatch = Stopwatch.StartNew(); + _logger.LogInformation("Received Connections Sync Event"); + try { - Stopwatch stopwatch = Stopwatch.StartNew(); - _logger.LogInformation("Received Puzzle Sync Event"); - try - { - // construct scope - using var scope = _scopeFactory.CreateScope(); - // get dependencies - ConnectionsContext db = scope.ServiceProvider.GetRequiredService(); - HttpClient http = scope.ServiceProvider.GetRequiredService().CreateClient(); - ILogger syncLogger = scope.ServiceProvider.GetRequiredService>(); - // do the work - await new SyncUtility(db, syncLogger, http).SyncPuzzlesAsync(ct); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while executing puzzle sync event"); - } - finally - { - stopwatch.Stop(); - } - _logger.LogInformation("Puzzle Sync Event finished in {ts}", stopwatch.Elapsed); + // construct scope + using var scope = _scopeFactory.CreateScope(); + // get dependencies + ConnectionsContext db = scope.ServiceProvider.GetRequiredService(); + HttpClient http = scope.ServiceProvider.GetRequiredService().CreateClient(); + ILogger syncLogger = scope.ServiceProvider.GetRequiredService>(); + // do the work + await new SyncUtility(db, syncLogger, http).SyncPuzzlesAsync(ct); } + catch (Exception ex) + { + _logger.LogError(ex, "Error while executing Connections sync event"); + } + finally + { + stopwatch.Stop(); + } + _logger.LogInformation("Connections Sync Event finished in {ts}", stopwatch.Elapsed); } } + diff --git a/Features/Puzzle/Get/GetPuzzleEndpoint.cs b/Features/Puzzle/Get/GetPuzzleEndpoint.cs index 7a4459d..834defd 100644 --- a/Features/Puzzle/Get/GetPuzzleEndpoint.cs +++ b/Features/Puzzle/Get/GetPuzzleEndpoint.cs @@ -21,7 +21,7 @@ namespace ConnectionsAPI.Features.Puzzle.Get } } - public class GetPuzzleEndpoint(PuzzleRepository puzzleRepo, ILogger logger, IAppCache cache) : Endpoint + public class GetPuzzleEndpoint(PuzzleRepository puzzleRepo, ILogger logger, IAppCache cache) : Endpoint { private readonly PuzzleRepository _puzzleRepo = puzzleRepo; private readonly ILogger _logger = logger; @@ -58,7 +58,7 @@ namespace ConnectionsAPI.Features.Puzzle.Get } // get response from cache - var response = PuzzleDTO.FromEntity(puzzle); + var response = ConnectionsPuzzleDTO.FromEntity(puzzle); // done await SendAsync(response, cancellation: ct); diff --git a/Features/Puzzle/List/ListPuzzlesEndpoint.cs b/Features/Puzzle/List/ListPuzzlesEndpoint.cs index 8fa6b6e..de007c8 100644 --- a/Features/Puzzle/List/ListPuzzlesEndpoint.cs +++ b/Features/Puzzle/List/ListPuzzlesEndpoint.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; namespace ConnectionsAPI.Features.Puzzle.List { - public class ListPuzzlesEndpoint(PuzzleRepository puzzleRepo, IAppCache cache) : EndpointWithoutRequest> + public class ListPuzzlesEndpoint(PuzzleRepository puzzleRepo, IAppCache cache) : EndpointWithoutRequest> { private readonly PuzzleRepository _puzzleRepo = puzzleRepo; private readonly IAppCache _cache = cache; @@ -25,7 +25,7 @@ namespace ConnectionsAPI.Features.Puzzle.List var puzzles = await _puzzleRepo.GetAllPuzzlesAsync(includeSolutions: !hideSolutions); // map to response object - var response = puzzles.Select(PuzzleDTO.FromEntity).ToList(); + var response = puzzles.Select(ConnectionsPuzzleDTO.FromEntity).ToList(); // done await SendAsync(response, cancellation: ct); diff --git a/Models/PuzzleDTO.cs b/Models/PuzzleDTO.cs index cb0d791..c8f465b 100644 --- a/Models/PuzzleDTO.cs +++ b/Models/PuzzleDTO.cs @@ -1,8 +1,8 @@ namespace ConnectionsAPI.Models { - public class PuzzleDTO + public class ConnectionsPuzzleDTO { - public static PuzzleDTO FromEntity(Database.Entities.CategoriesPuzzle dbPuzzle) => + public static ConnectionsPuzzleDTO FromEntity(Database.Entities.ConnectionsPuzzle dbPuzzle) => new() { PuzzleNumber = dbPuzzle.Index, @@ -25,11 +25,11 @@ public class PuzzleCategoryDTO { - public static PuzzleCategoryDTO FromEntity(Database.Entities.CategoriesCategory dbCategory) => + public static PuzzleCategoryDTO FromEntity(Database.Entities.ConnectionsCategory dbCategory) => new() { Title = dbCategory.Name, - Cards = dbCategory.CategoriesPuzzleCards.OrderBy(x => x.Content).Select(PuzzleCardDTO.FromEntity).ToList(), + Cards = dbCategory.Cards.OrderBy(x => x.Content).Select(PuzzleCardDTO.FromEntity).ToList(), Color = dbCategory.Color.ToString().ToLower(), OrderingKey = (int)dbCategory.Color }; @@ -42,7 +42,7 @@ public class PuzzleCardDTO { - public static PuzzleCardDTO FromEntity(Database.Entities.CategoriesCard dbCard) => + public static PuzzleCardDTO FromEntity(Database.Entities.ConnectionsCard dbCard) => new() { Content = dbCard.Content, diff --git a/SyncScheduler.cs b/SyncScheduler.cs index 38b1b33..452f5ee 100644 --- a/SyncScheduler.cs +++ b/SyncScheduler.cs @@ -56,7 +56,7 @@ namespace ConnectionsAPI } private static Task SendSyncEvent(CancellationToken stoppingToken, bool wait = false) => - new PuzzleSyncEvent { }.PublishAsync(wait ? Mode.WaitForAll : Mode.WaitForNone, stoppingToken); + new ConnectionsSyncEvent { }.PublishAsync(wait ? Mode.WaitForAll : Mode.WaitForNone, stoppingToken); private async Task WaitForNextSchedule(CronExpression cron, CancellationToken ct) { diff --git a/Utility/EnvironmentUtility.cs b/Utility/EnvironmentUtility.cs index 0be742a..d468843 100644 --- a/Utility/EnvironmentUtility.cs +++ b/Utility/EnvironmentUtility.cs @@ -1,8 +1,6 @@ -namespace ConnectionsAPI.Utility +namespace ConnectionsAPI.Utility; +public static class EnvironmentUtility { - public static class EnvironmentUtility - { - public static bool IsContainer => - Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; - } + public static bool IsContainer => + Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; } diff --git a/Utility/HashUtility.cs b/Utility/HashUtility.cs index a8cd0c4..1b1d12d 100644 --- a/Utility/HashUtility.cs +++ b/Utility/HashUtility.cs @@ -1,14 +1,12 @@ using System.Security.Cryptography; using System.Text; -namespace ConnectionsAPI.Utility +namespace ConnectionsAPI.Utility; +public static class HashUtility { - public static class HashUtility + public static string CalculateMD5(string input) { - public static string CalculateMD5(string input) - { - byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); - return Convert.ToHexString(hash).Replace("-", string.Empty).ToLower(); - } + byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(hash).Replace("-", string.Empty).ToLower(); } } diff --git a/Utility/SyncUtility.cs b/Utility/SyncUtility.cs index 8e0d7fa..b210f77 100644 --- a/Utility/SyncUtility.cs +++ b/Utility/SyncUtility.cs @@ -6,238 +6,236 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -namespace ConnectionsAPI.Utility +namespace ConnectionsAPI.Utility; +public class SyncUtility(ConnectionsContext db, ILogger logger, HttpClient http) { - public class SyncUtility(ConnectionsContext db, ILogger logger, HttpClient http) + #region Response types + class NYTConnectionsPuzzle { - #region Response types - class NYTConnectionsPuzzle + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + [JsonPropertyName("print_date")] + public string PrintDate { get; set; } = string.Empty; + [JsonPropertyName("editor")] + public string Editor { get; set; } = string.Empty; + [JsonPropertyName("categories")] + public IReadOnlyList Categories { get; set; } = []; + + [JsonIgnore] + public string Md5 { get; set; } = string.Empty; + } + + class NYTConnectionsPuzzleCategory + { + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + [JsonPropertyName("cards")] + public IReadOnlyList Cards { get; set; } = []; + } + + public class NYTConnectionsPuzzleCard + { + [JsonPropertyName("content")] + public string Content { get; set; } = string.Empty; + [JsonPropertyName("position")] + public int Position { get; set; } + } + #endregion + + private static readonly string SHORT_DATE = "yyyy-MM-dd"; + + private readonly ConnectionsContext _db = db; + private readonly ILogger _logger = logger; + private readonly HttpClient _http = http; + + public async Task SyncPuzzlesAsync(CancellationToken ct) + { + _logger.LogInformation("Calculating puzzle sync dates"); + + // calculate the date ranges for the sync + var syncDates = await GetSyncDatesAsync(ct); + + _logger.LogInformation("Syncing puzzles between {start} - {end}", syncDates[0], syncDates[^1]); + + // run the HTTP requests in batches + ConcurrentDictionary responses = new(); + foreach (var batch in syncDates.Chunk(5)) { - [JsonPropertyName("status")] - public string Status { get; set; } = string.Empty; - [JsonPropertyName("print_date")] - public string PrintDate { get; set; } = string.Empty; - [JsonPropertyName("editor")] - public string Editor { get; set; } = string.Empty; - [JsonPropertyName("categories")] - public IReadOnlyList Categories { get; set; } = []; - - [JsonIgnore] - public string Md5 { get; set; } = string.Empty; - } - - class NYTConnectionsPuzzleCategory - { - [JsonPropertyName("title")] - public string Title { get; set; } = string.Empty; - [JsonPropertyName("cards")] - public IReadOnlyList Cards { get; set; } = []; - } - - public class NYTConnectionsPuzzleCard - { - [JsonPropertyName("content")] - public string Content { get; set; } = string.Empty; - [JsonPropertyName("position")] - public int Position { get; set; } - } - #endregion - - private static readonly string SHORT_DATE = "yyyy-MM-dd"; - - private readonly ConnectionsContext _db = db; - private readonly ILogger _logger = logger; - private readonly HttpClient _http = http; - - public async Task SyncPuzzlesAsync(CancellationToken ct) - { - _logger.LogInformation("Calculating puzzle sync dates"); - - // calculate the date ranges for the sync - var syncDates = await GetSyncDatesAsync(ct); - - _logger.LogInformation("Syncing puzzles between {start} - {end}", syncDates[0], syncDates[^1]); - - // run the HTTP requests in batches - ConcurrentDictionary responses = new(); - foreach (var batch in syncDates.Chunk(5)) - { - ConcurrentBag batchPuzzles = []; - await Task.WhenAll( - batch.Select(x => GetConnectionsResponseAsync(x, ct).ContinueWith(t => + ConcurrentBag batchPuzzles = []; + await Task.WhenAll( + batch.Select(x => GetConnectionsResponseAsync(x, ct).ContinueWith(t => + { + string? result = t.Result; + if (!string.IsNullOrWhiteSpace(result)) { - string? result = t.Result; - if (!string.IsNullOrWhiteSpace(result)) + try { - try - { - var nytResponseJson = JsonSerializer.Deserialize(result) - ?? throw new InvalidDataException("Connections response deserialized to null"); + var nytResponseJson = JsonSerializer.Deserialize(result) + ?? throw new InvalidDataException("Connections response deserialized to null"); - string md5 = HashUtility.CalculateMD5(result); - nytResponseJson.Md5 = md5; - batchPuzzles.Add(nytResponseJson); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to deserialize Connections response for {date}", x); - } + string md5 = HashUtility.CalculateMD5(result); + nytResponseJson.Md5 = md5; + batchPuzzles.Add(nytResponseJson); } - else + catch (Exception ex) { - _logger.LogWarning("Puzzle {date} non-success response, skipping", x); + _logger.LogError(ex, "Failed to deserialize Connections response for {date}", x); } - })) - ); - - foreach (var puzzle in batchPuzzles.OrderBy(x => x.PrintDate)) - { - await UpsertPuzzleDataAsync(puzzle); - } - await _db.SaveChangesAsync(ct); - } - } - - private async Task UpsertPuzzleDataAsync(NYTConnectionsPuzzle nytPuzzle) - { - // get a tracking reference to the puzzle matching by print date, either by querying or creating a new entity - var puzzle = await _db.CategoriesPuzzles - .Include(x => x.Categories) - .ThenInclude(x => x.CategoriesPuzzleCards) - .FirstOrDefaultAsync(x => x.PrintDate == nytPuzzle.PrintDate); - if (puzzle == null) - { - _logger.LogTrace("No puzzle found for {printDate}, puzzle will be created", nytPuzzle.PrintDate); - puzzle = new Database.Entities.CategoriesPuzzle - { - Categories = [], - CreatedDate = DateTime.UtcNow - }; - _db.CategoriesPuzzles.Add(puzzle); - } - - // if the content hash matches, no update needed - if (puzzle.ContentMD5 == nytPuzzle.Md5) - { - _logger.LogTrace("JSON content hash for {printDate} matches, no need for update", nytPuzzle.PrintDate); - return; - } - - puzzle.ContentMD5 = nytPuzzle.Md5; - puzzle.PrintDate = nytPuzzle.PrintDate; - puzzle.EditorName = nytPuzzle.Editor; - puzzle.Index = CalculateConnectionsDayIndex(nytPuzzle.PrintDate); - puzzle.Categories ??= []; - - // mark items for deletion and also remove them from here to be readded - _db.RemoveRange(puzzle.Categories); - puzzle.Categories.Clear(); - - // construct the entities - int idx = 1; - foreach (var nytCategory in nytPuzzle.Categories) - { - CategoriesCategory category = new() - { - Color = (CategoriesColor)idx++, - Name = nytCategory.Title, - CategoriesPuzzle = puzzle, - CategoriesPuzzleCards = [] - }; - - foreach (var nytCard in nytCategory.Cards) - { - CategoriesCard card = new() + } + else { - Content = nytCard.Content, - Position = nytCard.Position, - Category = category, - }; - category.CategoriesPuzzleCards.Add(card); - } + _logger.LogWarning("Puzzle {date} non-success response, skipping", x); + } + })) + ); - puzzle.Categories.Add(category); - } - // done - } - - private static int CalculateConnectionsDayIndex(string printDate) - { - DateTime connectionsDate = DateTime.ParseExact(printDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; - return Convert.ToInt32(Math.Max((connectionsDate - Constants.ConnectionsStartDate).TotalDays, 0)) + 1; - } - - private async Task GetConnectionsResponseAsync(string printDate, CancellationToken ct) - { - string url = $"https://www.nytimes.com/svc/connections/v2/{printDate}.json"; - using var resp = await _http.GetAsync(url, ct); - if (resp == null || !resp.IsSuccessStatusCode) + foreach (var puzzle in batchPuzzles.OrderBy(x => x.PrintDate)) { - return null; + await UpsertPuzzleDataAsync(puzzle); } - string responseContent = await resp.Content.ReadAsStringAsync(ct); - return responseContent; - } - - private async Task> GetSyncDatesAsync(CancellationToken ct) - { - // query the last puzzle we have in the database - string? lastSyncedPuzzleDate = await _db.CategoriesPuzzles.AsNoTracking() - .OrderByDescending(x => x.PrintDate) - .Select(x => x.PrintDate) - .FirstOrDefaultAsync(cancellationToken: ct); - - // calculate the starting date of the sync - string startDate; - - // if no puzzle was synced before, we use the start day of connections as a start (we want to sync every puzzle ever) - if (string.IsNullOrWhiteSpace(lastSyncedPuzzleDate)) - { - startDate = Constants.ConnectionsStartDate.ToString(SHORT_DATE); - } - else - { - string todayPrintDate = DateTimeOffset.UtcNow.UtcDateTime.ToString(SHORT_DATE); - // if we have a puzzle, we check the latest print date we have - // if the print date is earlier than today's date, we use that day as a base - // if the print date is after today, we use today as a base - if (lastSyncedPuzzleDate.CompareTo(todayPrintDate) < 0) - { - startDate = lastSyncedPuzzleDate; - } - else - { - startDate = todayPrintDate; - } - } - - // construct a list of dates - List dates = [startDate]; - - // we iterate on every day between the start date and UTC tomorrow (this should handle +12 timezones as well) - DateTime syncBeginDate = DateTime.ParseExact(startDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; - DateTime syncEndDate; - - // try to find the latest date that is currently going on in the world - TimeZoneInfo? latestTimezone = TimezoneUtility.GetLatestTimezoneOnSystem(); - if (latestTimezone != null) - { - DateTime currentDateInLatestTimezone = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, latestTimezone); - syncEndDate = new DateTime(currentDateInLatestTimezone.Year, currentDateInLatestTimezone.Month, currentDateInLatestTimezone.Day, 0, 0, 0, DateTimeKind.Utc); - } - // default to UTC date + 1 day - else - { - syncEndDate = DateTime.UtcNow.Date.AddDays(1); - } - - foreach (var date in Enumerable.Repeat(0, Convert.ToInt32((syncEndDate - syncBeginDate).TotalDays)).Select((_, idx) => syncBeginDate.AddDays(idx + 1))) - { - dates.Add(date.ToString(SHORT_DATE)); - } - - // done - return dates; + await _db.SaveChangesAsync(ct); } } + + private async Task UpsertPuzzleDataAsync(NYTConnectionsPuzzle nytPuzzle) + { + // get a tracking reference to the puzzle matching by print date, either by querying or creating a new entity + var puzzle = await _db.ConnectionsPuzzles + .Include(x => x.Categories) + .ThenInclude(x => x.Cards) + .FirstOrDefaultAsync(x => x.PrintDate == nytPuzzle.PrintDate); + if (puzzle == null) + { + _logger.LogTrace("No puzzle found for {printDate}, puzzle will be created", nytPuzzle.PrintDate); + puzzle = new Database.Entities.ConnectionsPuzzle + { + Categories = [], + CreatedDate = DateTime.UtcNow + }; + _db.ConnectionsPuzzles.Add(puzzle); + } + + // if the content hash matches, no update needed + if (puzzle.ContentMD5 == nytPuzzle.Md5) + { + _logger.LogTrace("JSON content hash for {printDate} matches, no need for update", nytPuzzle.PrintDate); + return; + } + + puzzle.ContentMD5 = nytPuzzle.Md5; + puzzle.PrintDate = nytPuzzle.PrintDate; + puzzle.EditorName = nytPuzzle.Editor; + puzzle.Index = CalculateConnectionsDayIndex(nytPuzzle.PrintDate); + puzzle.Categories ??= []; + + // mark items for deletion and also remove them from here to be readded + _db.RemoveRange(puzzle.Categories); + puzzle.Categories.Clear(); + + // construct the entities + int idx = 1; + foreach (var nytCategory in nytPuzzle.Categories) + { + ConnectionsCategory category = new() + { + Color = (ConnectionsColor)idx++, + Name = nytCategory.Title, + ConnectionsPuzzle = puzzle, + Cards = [] + }; + + foreach (var nytCard in nytCategory.Cards) + { + ConnectionsCard card = new() + { + Content = nytCard.Content, + Position = nytCard.Position, + Category = category, + }; + category.Cards.Add(card); + } + + puzzle.Categories.Add(category); + } + // done + } + + private static int CalculateConnectionsDayIndex(string printDate) + { + DateTime connectionsDate = DateTime.ParseExact(printDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; + return Convert.ToInt32(Math.Max((connectionsDate - Constants.ConnectionsStartDate).TotalDays, 0)) + 1; + } + + private async Task GetConnectionsResponseAsync(string printDate, CancellationToken ct) + { + string url = $"https://www.nytimes.com/svc/connections/v2/{printDate}.json"; + using var resp = await _http.GetAsync(url, ct); + if (resp == null || !resp.IsSuccessStatusCode) + { + return null; + } + string responseContent = await resp.Content.ReadAsStringAsync(ct); + return responseContent; + } + + private async Task> GetSyncDatesAsync(CancellationToken ct) + { + // query the last puzzle we have in the database + string? lastSyncedPuzzleDate = await _db.ConnectionsPuzzles.AsNoTracking() + .OrderByDescending(x => x.PrintDate) + .Select(x => x.PrintDate) + .FirstOrDefaultAsync(cancellationToken: ct); + + // calculate the starting date of the sync + string startDate; + + // if no puzzle was synced before, we use the start day of connections as a start (we want to sync every puzzle ever) + if (string.IsNullOrWhiteSpace(lastSyncedPuzzleDate)) + { + startDate = Constants.ConnectionsStartDate.ToString(SHORT_DATE); + } + else + { + string todayPrintDate = DateTimeOffset.UtcNow.UtcDateTime.ToString(SHORT_DATE); + // if we have a puzzle, we check the latest print date we have + // if the print date is earlier than today's date, we use that day as a base + // if the print date is after today, we use today as a base + if (lastSyncedPuzzleDate.CompareTo(todayPrintDate) < 0) + { + startDate = lastSyncedPuzzleDate; + } + else + { + startDate = todayPrintDate; + } + } + + // construct a list of dates + List dates = [startDate]; + + // we iterate on every day between the start date and UTC tomorrow (this should handle +12 timezones as well) + DateTime syncBeginDate = DateTime.ParseExact(startDate, SHORT_DATE, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).Date; + DateTime syncEndDate; + + // try to find the latest date that is currently going on in the world + TimeZoneInfo? latestTimezone = TimezoneUtility.GetLatestTimezoneOnSystem(); + if (latestTimezone != null) + { + DateTime currentDateInLatestTimezone = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, latestTimezone); + syncEndDate = new DateTime(currentDateInLatestTimezone.Year, currentDateInLatestTimezone.Month, currentDateInLatestTimezone.Day, 0, 0, 0, DateTimeKind.Utc); + } + // default to UTC date + 1 day + else + { + syncEndDate = DateTime.UtcNow.Date.AddDays(1); + } + + foreach (var date in Enumerable.Repeat(0, Convert.ToInt32((syncEndDate - syncBeginDate).TotalDays)).Select((_, idx) => syncBeginDate.AddDays(idx + 1))) + { + dates.Add(date.ToString(SHORT_DATE)); + } + + // done + return dates; + } } diff --git a/Utility/TimezoneUtility.cs b/Utility/TimezoneUtility.cs index 62bf9a4..74aba5a 100644 --- a/Utility/TimezoneUtility.cs +++ b/Utility/TimezoneUtility.cs @@ -1,10 +1,8 @@ -namespace ConnectionsAPI.Utility +namespace ConnectionsAPI.Utility; +public static class TimezoneUtility { - public static class TimezoneUtility - { - public static TimeZoneInfo? GetLatestTimezoneOnSystem() => - TimeZoneInfo.GetSystemTimeZones() - .OrderByDescending(x => x.GetUtcOffset(DateTime.UtcNow)) - .FirstOrDefault(); - } + public static TimeZoneInfo? GetLatestTimezoneOnSystem() => + TimeZoneInfo.GetSystemTimeZones() + .OrderByDescending(x => x.GetUtcOffset(DateTime.UtcNow)) + .FirstOrDefault(); }