From 1291b2e2c3c94264bf7b6eb3dbd3fcd630859f0a Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Tue, 9 Jul 2024 16:59:53 +0200 Subject: [PATCH 01/10] Added additional docker documentation --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0153449..2d311b9 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,91 @@ ENV SMTP_SENDER_EMAIL_ADDRESS=admin@thcdornbirn.at ENTRYPOINT ["dotnet", "ClubService.API.dll"] ``` A build container is used to build the software which is then deployed in the base container. -This is then used in the docker-compose file to start the tennis club service and all of the other containers, making it -easy to start the whole application. +This is then used in the `docker-compose.yml` file to start the tennis club service along with all other containers, except for Redis and Debezium-Postgres. +These two services are defined in the `docker-compose.override.yml` file. +The idea behind this is that any other service can use our `docker-compose.yml` file without removing the redis and debezium-postgres services. + +The `docker-compose.yml` file consists of four services: +```yml +version: '3.9' + +services: + club-service: + image: club-service + container_name: "club-service" + restart: always + build: + context: . + dockerfile: ./Dockerfile + environment: + - ASPNETCORE_ENVIRONMENT=DockerDevelopment + depends_on: + - debezium-postgres + - mailhog + ports: + - "5000:8080" + - "5001:8081" + networks: + - tennis-club-network + + debezium-postgres: + image: debezium/postgres:16-alpine + container_name: "club-service-postgres" + restart: always + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: club-service-event-store + networks: + - tennis-club-network + + debezium: + image: debezium/server + container_name: "club-service-debezium" + restart: always + ports: + - "54326:8080" + depends_on: + - debezium-postgres + volumes: + - ./debezium-conf:/debezium/conf + networks: + - tennis-club-network + + mailhog: + image: mailhog/mailhog:v1.0.1 + container_name: "club-service-mailhog" + restart: always + ports: + - "1025:1025" + - "8025:8025" + networks: + - tennis-club-network + +networks: + tennis-club-network: + name: tennis-club-network + driver: bridge +``` + +The `docker-compose.override.yml` file consists of two services: +```yml +version: '3.9' + +services: + redis: + image: redis:7.0-alpine + container_name: "club-service-redis" + restart: always + ports: + - "6379:6379" + networks: + - tennis-club-network + + debezium-postgres: + ports: + - "5432:5432" +``` ### Kubernetes From 5cb70dccb7f8ce2915d37466a10f2f7739ee58be Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Tue, 9 Jul 2024 23:08:52 +0200 Subject: [PATCH 02/10] Recreated initial migrations --- ClubService.API/Program.cs | 6 +- .../ClubService.Infrastructure.csproj | 2 + ...240704170701_InitialEventStore.Designer.cs | 63 ---------- .../20240704170701_InitialEventStore.cs | 34 ------ ...240709210357_InitialEventStore.Designer.cs | 112 ++++++++++++++++++ .../20240709210357_InitialEventStore.cs | 52 ++++++++ .../EventStoreDbContextModelSnapshot.cs | 47 ++++++++ ...240704171249_InitialLoginStore.Designer.cs | 47 -------- ...240709210524_InitialLoginStore.Designer.cs | 84 +++++++++++++ ...cs => 20240709210524_InitialLoginStore.cs} | 22 +++- .../LoginStoreDbContextModelSnapshot.cs | 7 ++ ...240709210752_InitialReadStore.Designer.cs} | 27 ++++- ....cs => 20240709210752_InitialReadStore.cs} | 57 +++++++-- .../ReadStoreDbContextModelSnapshot.cs | 17 +++ 14 files changed, 412 insertions(+), 165 deletions(-) delete mode 100644 ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.Designer.cs delete mode 100644 ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.cs create mode 100644 ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.Designer.cs create mode 100644 ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.cs delete mode 100644 ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.Designer.cs create mode 100644 ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.Designer.cs rename ClubService.Infrastructure/Migrations/LoginStore/{20240704171249_InitialLoginStore.cs => 20240709210524_InitialLoginStore.cs} (56%) rename ClubService.Infrastructure/Migrations/ReadStore/{20240704170935_InitialReadStore.Designer.cs => 20240709210752_InitialReadStore.Designer.cs} (92%) rename ClubService.Infrastructure/Migrations/ReadStore/{20240704170935_InitialReadStore.cs => 20240709210752_InitialReadStore.cs} (74%) diff --git a/ClubService.API/Program.cs b/ClubService.API/Program.cs index 83b2df7..6935919 100644 --- a/ClubService.API/Program.cs +++ b/ClubService.API/Program.cs @@ -47,17 +47,17 @@ { if (eventStoreDbContext.Database.GetPendingMigrations().Any()) { - eventStoreDbContext.Database.Migrate(); + await eventStoreDbContext.Database.MigrateAsync(); } if (readStoreDbContext.Database.GetPendingMigrations().Any()) { - readStoreDbContext.Database.Migrate(); + await readStoreDbContext.Database.MigrateAsync(); } if (loginStoreDbContext.Database.GetPendingMigrations().Any()) { - loginStoreDbContext.Database.Migrate(); + await loginStoreDbContext.Database.MigrateAsync(); } } else if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("DockerDevelopment")) diff --git a/ClubService.Infrastructure/ClubService.Infrastructure.csproj b/ClubService.Infrastructure/ClubService.Infrastructure.csproj index 1f3c4f7..d6a186f 100644 --- a/ClubService.Infrastructure/ClubService.Infrastructure.csproj +++ b/ClubService.Infrastructure/ClubService.Infrastructure.csproj @@ -33,7 +33,9 @@ + + diff --git a/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.Designer.cs b/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.Designer.cs deleted file mode 100644 index 0b05bac..0000000 --- a/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.Designer.cs +++ /dev/null @@ -1,63 +0,0 @@ -// - -#nullable disable - -using ClubService.Infrastructure.DbContexts; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace ClubService.Infrastructure.Migrations.EventStore -{ - [DbContext(typeof(EventStoreDbContext))] - [Migration("20240704170701_InitialEventStore")] - partial class InitialEventStore - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ClubService.Domain.Event.DomainEnvelope", b => - { - b.Property("EventId") - .ValueGeneratedOnAdd() - .HasColumnType("uuid") - .HasColumnName("eventId"); - - b.Property("EntityId") - .HasColumnType("uuid") - .HasColumnName("entityId"); - - b.Property("EntityType") - .IsRequired() - .HasColumnType("text") - .HasColumnName("entityType"); - - b.Property("EventData") - .HasColumnType("text") - .HasColumnName("eventData"); - - b.Property("EventType") - .IsRequired() - .HasColumnType("text") - .HasColumnName("eventType"); - - b.Property("Timestamp") - .HasColumnType("timestamp with time zone") - .HasColumnName("timestamp"); - - b.HasKey("EventId") - .HasName("pK_DomainEvent"); - - b.ToTable("DomainEvent", (string)null); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.cs b/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.cs deleted file mode 100644 index e0a16cb..0000000 --- a/ClubService.Infrastructure/Migrations/EventStore/20240704170701_InitialEventStore.cs +++ /dev/null @@ -1,34 +0,0 @@ -#nullable disable - -using Microsoft.EntityFrameworkCore.Migrations; - -namespace ClubService.Infrastructure.Migrations.EventStore -{ - /// - public partial class InitialEventStore : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "DomainEvent", - columns: table => new - { - eventId = table.Column(type: "uuid", nullable: false), - entityId = table.Column(type: "uuid", nullable: false), - eventType = table.Column(type: "text", nullable: false), - entityType = table.Column(type: "text", nullable: false), - timestamp = table.Column(type: "timestamp with time zone", nullable: false), - eventData = table.Column(type: "text", nullable: true) - }, - constraints: table => { table.PrimaryKey("pK_DomainEvent", x => x.eventId); }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "DomainEvent"); - } - } -} \ No newline at end of file diff --git a/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.Designer.cs b/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.Designer.cs new file mode 100644 index 0000000..c75dd9c --- /dev/null +++ b/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.Designer.cs @@ -0,0 +1,112 @@ +// +using System; +using ClubService.Infrastructure.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ClubService.Infrastructure.Migrations.EventStore +{ + [DbContext(typeof(EventStoreDbContext))] + [Migration("20240709210357_InitialEventStore")] + partial class InitialEventStore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ClubService.Domain.Event.DomainEnvelope", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("eventId"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entityId"); + + b.Property("EntityType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("entityType"); + + b.Property("EventData") + .HasColumnType("text") + .HasColumnName("eventData"); + + b.Property("EventType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("eventType"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.HasKey("EventId") + .HasName("pK_DomainEvent"); + + b.ToTable("DomainEvent", (string)null); + + b.HasData( + new + { + EventId = new Guid("ba4eeaa4-9707-4da0-8ee2-3684b4f7804f"), + EntityId = new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), + EntityType = "SYSTEM_OPERATOR", + EventData = "{\"SystemOperatorId\":{\"Id\":\"1588ec27-c932-4dee-a341-d18c8108a711\"},\"Username\":\"systemoperator\"}", + EventType = "SYSTEM_OPERATOR_REGISTERED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7907) + }, + new + { + EventId = new Guid("36db98d7-8fea-4715-923c-74192b147752"), + EntityId = new Guid("4c148d45-ebc8-4bbf-aa9a-d491eb185ad5"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"4c148d45-ebc8-4bbf-aa9a-d491eb185ad5\"},\"Name\":\"Guinea Pig Subscription Tier\",\"MaxMemberCount\":100}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7913) + }, + new + { + EventId = new Guid("3b591696-d9c9-4e30-a6a1-6a1439c5580b"), + EntityId = new Guid("2bebd11c-bf8e-4448-886f-0cb8608af7ca"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"2bebd11c-bf8e-4448-886f-0cb8608af7ca\"},\"Name\":\"Woolf Subscription Tier\",\"MaxMemberCount\":150}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7916) + }, + new + { + EventId = new Guid("8d4d3eff-b77b-4e21-963b-e211366bb94b"), + EntityId = new Guid("38888969-d579-46ec-9cd6-0208569a077e"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"38888969-d579-46ec-9cd6-0208569a077e\"},\"Name\":\"Gorilla Subscription Tier\",\"MaxMemberCount\":200}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7918) + }, + new + { + EventId = new Guid("e335d85a-f844-4c7e-b608-035ef00af733"), + EntityId = new Guid("d19073ba-f760-4a9a-abfa-f8215d96bec7"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"d19073ba-f760-4a9a-abfa-f8215d96bec7\"},\"Name\":\"Bison Subscription Tier\",\"MaxMemberCount\":250}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7921) + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.cs b/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.cs new file mode 100644 index 0000000..df94065 --- /dev/null +++ b/ClubService.Infrastructure/Migrations/EventStore/20240709210357_InitialEventStore.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace ClubService.Infrastructure.Migrations.EventStore +{ + /// + public partial class InitialEventStore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DomainEvent", + columns: table => new + { + eventId = table.Column(type: "uuid", nullable: false), + entityId = table.Column(type: "uuid", nullable: false), + eventType = table.Column(type: "text", nullable: false), + entityType = table.Column(type: "text", nullable: false), + timestamp = table.Column(type: "timestamp with time zone", nullable: false), + eventData = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pK_DomainEvent", x => x.eventId); + }); + + migrationBuilder.InsertData( + table: "DomainEvent", + columns: new[] { "eventId", "entityId", "entityType", "eventData", "eventType", "timestamp" }, + values: new object[,] + { + { new Guid("36db98d7-8fea-4715-923c-74192b147752"), new Guid("4c148d45-ebc8-4bbf-aa9a-d491eb185ad5"), "SUBSCRIPTION_TIER", "{\"Id\":{\"Id\":\"4c148d45-ebc8-4bbf-aa9a-d491eb185ad5\"},\"Name\":\"Guinea Pig Subscription Tier\",\"MaxMemberCount\":100}", "SUBSCRIPTION_TIER_CREATED", new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7913) }, + { new Guid("3b591696-d9c9-4e30-a6a1-6a1439c5580b"), new Guid("2bebd11c-bf8e-4448-886f-0cb8608af7ca"), "SUBSCRIPTION_TIER", "{\"Id\":{\"Id\":\"2bebd11c-bf8e-4448-886f-0cb8608af7ca\"},\"Name\":\"Woolf Subscription Tier\",\"MaxMemberCount\":150}", "SUBSCRIPTION_TIER_CREATED", new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7916) }, + { new Guid("8d4d3eff-b77b-4e21-963b-e211366bb94b"), new Guid("38888969-d579-46ec-9cd6-0208569a077e"), "SUBSCRIPTION_TIER", "{\"Id\":{\"Id\":\"38888969-d579-46ec-9cd6-0208569a077e\"},\"Name\":\"Gorilla Subscription Tier\",\"MaxMemberCount\":200}", "SUBSCRIPTION_TIER_CREATED", new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7918) }, + { new Guid("ba4eeaa4-9707-4da0-8ee2-3684b4f7804f"), new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), "SYSTEM_OPERATOR", "{\"SystemOperatorId\":{\"Id\":\"1588ec27-c932-4dee-a341-d18c8108a711\"},\"Username\":\"systemoperator\"}", "SYSTEM_OPERATOR_REGISTERED", new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7907) }, + { new Guid("e335d85a-f844-4c7e-b608-035ef00af733"), new Guid("d19073ba-f760-4a9a-abfa-f8215d96bec7"), "SUBSCRIPTION_TIER", "{\"Id\":{\"Id\":\"d19073ba-f760-4a9a-abfa-f8215d96bec7\"},\"Name\":\"Bison Subscription Tier\",\"MaxMemberCount\":250}", "SUBSCRIPTION_TIER_CREATED", new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7921) } + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DomainEvent"); + } + } +} diff --git a/ClubService.Infrastructure/Migrations/EventStore/EventStoreDbContextModelSnapshot.cs b/ClubService.Infrastructure/Migrations/EventStore/EventStoreDbContextModelSnapshot.cs index 21f74f0..875ab68 100644 --- a/ClubService.Infrastructure/Migrations/EventStore/EventStoreDbContextModelSnapshot.cs +++ b/ClubService.Infrastructure/Migrations/EventStore/EventStoreDbContextModelSnapshot.cs @@ -55,6 +55,53 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasName("pK_DomainEvent"); b.ToTable("DomainEvent", (string)null); + + b.HasData( + new + { + EventId = new Guid("ba4eeaa4-9707-4da0-8ee2-3684b4f7804f"), + EntityId = new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), + EntityType = "SYSTEM_OPERATOR", + EventData = "{\"SystemOperatorId\":{\"Id\":\"1588ec27-c932-4dee-a341-d18c8108a711\"},\"Username\":\"systemoperator\"}", + EventType = "SYSTEM_OPERATOR_REGISTERED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7907) + }, + new + { + EventId = new Guid("36db98d7-8fea-4715-923c-74192b147752"), + EntityId = new Guid("4c148d45-ebc8-4bbf-aa9a-d491eb185ad5"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"4c148d45-ebc8-4bbf-aa9a-d491eb185ad5\"},\"Name\":\"Guinea Pig Subscription Tier\",\"MaxMemberCount\":100}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7913) + }, + new + { + EventId = new Guid("3b591696-d9c9-4e30-a6a1-6a1439c5580b"), + EntityId = new Guid("2bebd11c-bf8e-4448-886f-0cb8608af7ca"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"2bebd11c-bf8e-4448-886f-0cb8608af7ca\"},\"Name\":\"Woolf Subscription Tier\",\"MaxMemberCount\":150}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7916) + }, + new + { + EventId = new Guid("8d4d3eff-b77b-4e21-963b-e211366bb94b"), + EntityId = new Guid("38888969-d579-46ec-9cd6-0208569a077e"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"38888969-d579-46ec-9cd6-0208569a077e\"},\"Name\":\"Gorilla Subscription Tier\",\"MaxMemberCount\":200}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7918) + }, + new + { + EventId = new Guid("e335d85a-f844-4c7e-b608-035ef00af733"), + EntityId = new Guid("d19073ba-f760-4a9a-abfa-f8215d96bec7"), + EntityType = "SUBSCRIPTION_TIER", + EventData = "{\"Id\":{\"Id\":\"d19073ba-f760-4a9a-abfa-f8215d96bec7\"},\"Name\":\"Bison Subscription Tier\",\"MaxMemberCount\":250}", + EventType = "SUBSCRIPTION_TIER_CREATED", + Timestamp = new DateTime(2024, 7, 9, 21, 3, 56, 835, DateTimeKind.Utc).AddTicks(7921) + }); }); #pragma warning restore 612, 618 } diff --git a/ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.Designer.cs b/ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.Designer.cs deleted file mode 100644 index 8b0e8d7..0000000 --- a/ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.Designer.cs +++ /dev/null @@ -1,47 +0,0 @@ -// -using System; -using ClubService.Infrastructure.DbContexts; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace ClubService.Infrastructure.Migrations.LoginStore -{ - [DbContext(typeof(LoginStoreDbContext))] - [Migration("20240704171249_InitialLoginStore")] - partial class InitialLoginStore - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.2") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("ClubService.Domain.Model.Entity.UserPassword", b => - { - b.Property("UserId") - .HasColumnType("uuid") - .HasColumnName("userId"); - - b.Property("HashedPassword") - .IsRequired() - .HasColumnType("text") - .HasColumnName("hashedPassword"); - - b.HasKey("UserId") - .HasName("pK_UserPassword"); - - b.ToTable("UserPassword", (string)null); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.Designer.cs b/ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.Designer.cs new file mode 100644 index 0000000..d36a487 --- /dev/null +++ b/ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.Designer.cs @@ -0,0 +1,84 @@ +// +using System; +using ClubService.Infrastructure.DbContexts; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ClubService.Infrastructure.Migrations.LoginStore +{ + [DbContext(typeof(LoginStoreDbContext))] + [Migration("20240709210524_InitialLoginStore")] + partial class InitialLoginStore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ClubService.Domain.Model.Entity.UserPassword", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("userId"); + + b.Property("HashedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hashedPassword"); + + b.HasKey("UserId") + .HasName("pK_UserPassword"); + + b.ToTable("UserPassword", (string)null); + + b.HasData( + new + { + UserId = new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), + HashedPassword = "AQAAAAIAAYagAAAAEDUKJ7ReV2uEyekYGEB91VvXGmWH+HwRO9wfUHnqgb5QHs0NJTac+/OUJbiInt64zg==" + }, + new + { + UserId = new Guid("1dd88382-f781-4bf8-94e3-05e99d1434fe"), + HashedPassword = "AQAAAAIAAYagAAAAEINUWsPuMng880Mae+aQWYsWtBOozyrGRXjlwTzpZhgI5IAbm0g+JoI0kDdPkrKBfA==" + }, + new + { + UserId = new Guid("4a2eb3dc-7f1e-4dac-851a-667594ca31ff"), + HashedPassword = "AQAAAAIAAYagAAAAEBKu/YIpt3bJSChT1BW8kziF2PcNfbYrn/ar4ApIvo9CCoTypAWnJaWV4SsNTfTaYg==" + }, + new + { + UserId = new Guid("5d2f1aec-1cc6-440a-b04f-ba8b3085a35a"), + HashedPassword = "AQAAAAIAAYagAAAAEGT6tP27VLg6Fj8DqqMkzoXM1u5ZPMFlx9QJY4c/aBb3jGng1ySWdmC7l3w6hknQCw==" + }, + new + { + UserId = new Guid("60831440-06d2-4017-9a7b-016e9cd0b2dc"), + HashedPassword = "AQAAAAIAAYagAAAAEOHyHLp7O2x6x+SSomU58BNU/B3SwbkVe1iW8mI3kJCbx/3ltTcee3VNfC6O8xpOrQ==" + }, + new + { + UserId = new Guid("51ae7aca-2bb8-421a-a923-2ba2eb94bb3a"), + HashedPassword = "AQAAAAIAAYagAAAAEINyI26mDpfR+H5XGY+1zwGWGqT5+1jkHC9rxFDUNC3xcTlFyf9en7A64cfrLmhQ7Q==" + }, + new + { + UserId = new Guid("e8a2cd4c-69ad-4cf2-bca6-a60d88be6649"), + HashedPassword = "AQAAAAIAAYagAAAAEAWIvUbmCZPblT7HngkrZ1f4zfBtO8WGDd4SScg1ZZPE7SlytGjjxbLI1hPm0DePjw==" + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.cs b/ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.cs similarity index 56% rename from ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.cs rename to ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.cs index 052624c..1677e94 100644 --- a/ClubService.Infrastructure/Migrations/LoginStore/20240704171249_InitialLoginStore.cs +++ b/ClubService.Infrastructure/Migrations/LoginStore/20240709210524_InitialLoginStore.cs @@ -1,7 +1,10 @@ -#nullable disable - +using System; using Microsoft.EntityFrameworkCore.Migrations; +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + namespace ClubService.Infrastructure.Migrations.LoginStore { /// @@ -17,7 +20,18 @@ protected override void Up(MigrationBuilder migrationBuilder) userId = table.Column(type: "uuid", nullable: false), hashedPassword = table.Column(type: "text", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_UserPassword", x => x.userId); }); + constraints: table => + { + table.PrimaryKey("pK_UserPassword", x => x.userId); + }); + + migrationBuilder.InsertData( + table: "UserPassword", + columns: new[] { "userId", "hashedPassword" }, + values: new object[,] + { + { new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), "AQAAAAIAAYagAAAAEDUKJ7ReV2uEyekYGEB91VvXGmWH+HwRO9wfUHnqgb5QHs0NJTac+/OUJbiInt64zg==" } + }); } /// @@ -27,4 +41,4 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "UserPassword"); } } -} \ No newline at end of file +} diff --git a/ClubService.Infrastructure/Migrations/LoginStore/LoginStoreDbContextModelSnapshot.cs b/ClubService.Infrastructure/Migrations/LoginStore/LoginStoreDbContextModelSnapshot.cs index 80d64e5..8f4e41d 100644 --- a/ClubService.Infrastructure/Migrations/LoginStore/LoginStoreDbContextModelSnapshot.cs +++ b/ClubService.Infrastructure/Migrations/LoginStore/LoginStoreDbContextModelSnapshot.cs @@ -37,6 +37,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasName("pK_UserPassword"); b.ToTable("UserPassword", (string)null); + + b.HasData( + new + { + UserId = new Guid("1588ec27-c932-4dee-a341-d18c8108a711"), + HashedPassword = "AQAAAAIAAYagAAAAEDUKJ7ReV2uEyekYGEB91VvXGmWH+HwRO9wfUHnqgb5QHs0NJTac+/OUJbiInt64zg==" + }); }); #pragma warning restore 612, 618 } diff --git a/ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.Designer.cs b/ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.Designer.cs similarity index 92% rename from ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.Designer.cs rename to ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.Designer.cs index f336473..cb08360 100644 --- a/ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.Designer.cs +++ b/ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.Designer.cs @@ -1,16 +1,18 @@ // - -#nullable disable - +using System; using ClubService.Infrastructure.DbContexts; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable namespace ClubService.Infrastructure.Migrations.ReadStore { [DbContext(typeof(ReadStoreDbContext))] - [Migration("20240704170935_InitialReadStore")] + [Migration("20240709210752_InitialReadStore")] partial class InitialReadStore { /// @@ -142,6 +144,23 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("SubscriptionTier", (string)null); }); + modelBuilder.Entity("ClubService.Domain.ReadModel.SystemOperatorReadModel", b => + { + b.Property("SystemOperatorId") + .HasColumnType("uuid") + .HasColumnName("systemOperatorId"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("SystemOperatorId") + .HasName("pK_SystemOperator"); + + b.ToTable("SystemOperator", (string)null); + }); + modelBuilder.Entity("ClubService.Domain.ReadModel.TennisClubReadModel", b => { b.Property("TennisClubId") diff --git a/ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.cs b/ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.cs similarity index 74% rename from ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.cs rename to ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.cs index 7109f44..13e91a2 100644 --- a/ClubService.Infrastructure/Migrations/ReadStore/20240704170935_InitialReadStore.cs +++ b/ClubService.Infrastructure/Migrations/ReadStore/20240709210752_InitialReadStore.cs @@ -1,7 +1,8 @@ -#nullable disable - +using System; using Microsoft.EntityFrameworkCore.Migrations; +#nullable disable + namespace ClubService.Infrastructure.Migrations.ReadStore { /// @@ -21,7 +22,10 @@ protected override void Up(MigrationBuilder migrationBuilder) tennisClubId = table.Column(type: "uuid", nullable: false), status = table.Column(type: "text", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_Admin", x => x.adminId); }); + constraints: table => + { + table.PrimaryKey("pK_Admin", x => x.adminId); + }); migrationBuilder.CreateTable( name: "EmailOutbox", @@ -33,7 +37,10 @@ protected override void Up(MigrationBuilder migrationBuilder) body = table.Column(type: "text", nullable: false), timestamp = table.Column(type: "timestamp with time zone", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_EmailOutbox", x => x.id); }); + constraints: table => + { + table.PrimaryKey("pK_EmailOutbox", x => x.id); + }); migrationBuilder.CreateTable( name: "Member", @@ -46,7 +53,10 @@ protected override void Up(MigrationBuilder migrationBuilder) tennisClubId = table.Column(type: "uuid", nullable: false), status = table.Column(type: "text", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_Member", x => x.memberId); }); + constraints: table => + { + table.PrimaryKey("pK_Member", x => x.memberId); + }); migrationBuilder.CreateTable( name: "ProcessedEvent", @@ -54,7 +64,10 @@ protected override void Up(MigrationBuilder migrationBuilder) { eventId = table.Column(type: "uuid", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_ProcessedEvent", x => x.eventId); }); + constraints: table => + { + table.PrimaryKey("pK_ProcessedEvent", x => x.eventId); + }); migrationBuilder.CreateTable( name: "SubscriptionTier", @@ -64,7 +77,22 @@ protected override void Up(MigrationBuilder migrationBuilder) name = table.Column(type: "text", nullable: false), maxMemberCount = table.Column(type: "integer", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_SubscriptionTier", x => x.subscriptionTierId); }); + constraints: table => + { + table.PrimaryKey("pK_SubscriptionTier", x => x.subscriptionTierId); + }); + + migrationBuilder.CreateTable( + name: "SystemOperator", + columns: table => new + { + systemOperatorId = table.Column(type: "uuid", nullable: false), + username = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pK_SystemOperator", x => x.systemOperatorId); + }); migrationBuilder.CreateTable( name: "TennisClub", @@ -76,7 +104,10 @@ protected override void Up(MigrationBuilder migrationBuilder) status = table.Column(type: "text", nullable: false), memberCount = table.Column(type: "integer", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_TennisClub", x => x.tennisClubId); }); + constraints: table => + { + table.PrimaryKey("pK_TennisClub", x => x.tennisClubId); + }); migrationBuilder.CreateTable( name: "Tournament", @@ -88,7 +119,10 @@ protected override void Up(MigrationBuilder migrationBuilder) startDate = table.Column(type: "date", nullable: false), endDate = table.Column(type: "date", nullable: false) }, - constraints: table => { table.PrimaryKey("pK_Tournament", x => x.tournamentId); }); + constraints: table => + { + table.PrimaryKey("pK_Tournament", x => x.tournamentId); + }); } /// @@ -109,6 +143,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "SubscriptionTier"); + migrationBuilder.DropTable( + name: "SystemOperator"); + migrationBuilder.DropTable( name: "TennisClub"); @@ -116,4 +153,4 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "Tournament"); } } -} \ No newline at end of file +} diff --git a/ClubService.Infrastructure/Migrations/ReadStore/ReadStoreDbContextModelSnapshot.cs b/ClubService.Infrastructure/Migrations/ReadStore/ReadStoreDbContextModelSnapshot.cs index 5492e2d..583c685 100644 --- a/ClubService.Infrastructure/Migrations/ReadStore/ReadStoreDbContextModelSnapshot.cs +++ b/ClubService.Infrastructure/Migrations/ReadStore/ReadStoreDbContextModelSnapshot.cs @@ -141,6 +141,23 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("SubscriptionTier", (string)null); }); + modelBuilder.Entity("ClubService.Domain.ReadModel.SystemOperatorReadModel", b => + { + b.Property("SystemOperatorId") + .HasColumnType("uuid") + .HasColumnName("systemOperatorId"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("SystemOperatorId") + .HasName("pK_SystemOperator"); + + b.ToTable("SystemOperator", (string)null); + }); + modelBuilder.Entity("ClubService.Domain.ReadModel.TennisClubReadModel", b => { b.Property("TennisClubId") From 67d146dc35da7975e4d3e45b0b83e6f49031e582 Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Wed, 10 Jul 2024 00:59:36 +0200 Subject: [PATCH 03/10] Added consume types to apis --- ClubService.API/Controller/AdminController.cs | 4 ++++ ClubService.API/Controller/MemberController.cs | 8 +++++++- ClubService.API/Controller/SubscriptionTierController.cs | 1 + ClubService.API/Controller/TennisClubController.cs | 8 ++++++++ ClubService.API/Controller/UserController.cs | 2 ++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ClubService.API/Controller/AdminController.cs b/ClubService.API/Controller/AdminController.cs index 8a46773..f92162d 100644 --- a/ClubService.API/Controller/AdminController.cs +++ b/ClubService.API/Controller/AdminController.cs @@ -25,6 +25,7 @@ public class AdminController( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "ADMIN")] public async Task> RegisterAdmin( [FromBody] AdminRegisterCommand adminRegisterCommand) @@ -41,6 +42,7 @@ public async Task> RegisterAdmin( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> DeleteAdmin(Guid id) { @@ -57,6 +59,7 @@ public async Task> DeleteAdmin(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "ADMIN")] public async Task> UpdateAdmin(Guid id, [FromBody] AdminUpdateCommand adminUpdateCommand) { @@ -76,6 +79,7 @@ public async Task> UpdateAdmin(Guid id, [FromBody] AdminUpdat [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task> GetAdminById(Guid id) { diff --git a/ClubService.API/Controller/MemberController.cs b/ClubService.API/Controller/MemberController.cs index bb80971..2bc3149 100644 --- a/ClubService.API/Controller/MemberController.cs +++ b/ClubService.API/Controller/MemberController.cs @@ -25,6 +25,7 @@ public class MemberController( [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN,MEMBER")] public async Task> GetMemberById(Guid id) { @@ -55,6 +56,7 @@ public async Task> GetMemberById(Guid id) [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "ADMIN")] public async Task> RegisterMember([FromBody] MemberRegisterCommand memberRegisterCommand) { @@ -73,8 +75,9 @@ public async Task> RegisterMember([FromBody] MemberRegisterCo [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "MEMBER")] - public async Task> UpdateMember(Guid id, MemberUpdateCommand memberUpdateCommand) + public async Task> UpdateMember(Guid id, [FromBody] MemberUpdateCommand memberUpdateCommand) { var jwtUserId = User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value; @@ -91,6 +94,7 @@ public async Task> UpdateMember(Guid id, MemberUpdateCommand [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> DeleteMember(Guid id) { @@ -107,6 +111,7 @@ public async Task> DeleteMember(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> LockMember(Guid id) { @@ -123,6 +128,7 @@ public async Task> LockMember(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> UnlockMember(Guid id) { diff --git a/ClubService.API/Controller/SubscriptionTierController.cs b/ClubService.API/Controller/SubscriptionTierController.cs index cd09cca..18cb95d 100644 --- a/ClubService.API/Controller/SubscriptionTierController.cs +++ b/ClubService.API/Controller/SubscriptionTierController.cs @@ -24,6 +24,7 @@ public async Task>> GetAllSubscript [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] public async Task> GetSubscriptionTierById(Guid id) { var subscriptionTier = await subscriptionTierReadModelRepository.GetSubscriptionTierById(id); diff --git a/ClubService.API/Controller/TennisClubController.cs b/ClubService.API/Controller/TennisClubController.cs index 55aa6c0..837b3a8 100644 --- a/ClubService.API/Controller/TennisClubController.cs +++ b/ClubService.API/Controller/TennisClubController.cs @@ -49,6 +49,7 @@ public async Task>> GetAllTennisClubs( [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN,MEMBER")] public async Task> GetTennisClubById(Guid id) { @@ -75,6 +76,7 @@ public async Task> GetTennisClubById(Guid id) [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task>> GetAdminsByTennisClubId(Guid id) { @@ -95,6 +97,7 @@ public async Task>> GetAdminsByTennisClubId(Gu [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task>> GetMembersByTennisClubId(Guid id) { @@ -113,6 +116,7 @@ public async Task>> GetMembersByTennisClubId( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] public async Task> RegisterTennisClub( [FromBody] TennisClubRegisterCommand tennisClubRegisterCommand) { @@ -127,6 +131,7 @@ public async Task> RegisterTennisClub( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "ADMIN")] public async Task> UpdateTennisClub( Guid id, @@ -145,6 +150,7 @@ public async Task> UpdateTennisClub( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task> DeleteTennisClub(Guid id) { @@ -161,6 +167,7 @@ public async Task> DeleteTennisClub(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR")] public async Task> LockTennisClub(Guid id) { @@ -175,6 +182,7 @@ public async Task> LockTennisClub(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR")] public async Task> UnlockTennisClub(Guid id) { diff --git a/ClubService.API/Controller/UserController.cs b/ClubService.API/Controller/UserController.cs index d70462e..6adee8f 100644 --- a/ClubService.API/Controller/UserController.cs +++ b/ClubService.API/Controller/UserController.cs @@ -18,6 +18,7 @@ public class UserController(ILoginService loginService, IUserService userService [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] public async Task> Login( [FromBody] LoginCommand loginCommand) { @@ -32,6 +33,7 @@ public async Task> Login( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] + [Consumes("application/json")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN,MEMBER")] public async Task ChangePassword([FromBody] ChangePasswordCommand changePasswordCommand) { From 54d5deb2a3a6dc9dfcb3ebbd32429562cdb90c25 Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Wed, 10 Jul 2024 01:09:35 +0200 Subject: [PATCH 04/10] Removed default consume type annotations --- ClubService.API/Controller/AdminController.cs | 2 -- ClubService.API/Controller/MemberController.cs | 4 ---- ClubService.API/Controller/SubscriptionTierController.cs | 1 - ClubService.API/Controller/TennisClubController.cs | 6 ------ 4 files changed, 13 deletions(-) diff --git a/ClubService.API/Controller/AdminController.cs b/ClubService.API/Controller/AdminController.cs index f92162d..13b16e2 100644 --- a/ClubService.API/Controller/AdminController.cs +++ b/ClubService.API/Controller/AdminController.cs @@ -42,7 +42,6 @@ public async Task> RegisterAdmin( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> DeleteAdmin(Guid id) { @@ -79,7 +78,6 @@ public async Task> UpdateAdmin(Guid id, [FromBody] AdminUpdat [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task> GetAdminById(Guid id) { diff --git a/ClubService.API/Controller/MemberController.cs b/ClubService.API/Controller/MemberController.cs index 2bc3149..42ec15b 100644 --- a/ClubService.API/Controller/MemberController.cs +++ b/ClubService.API/Controller/MemberController.cs @@ -25,7 +25,6 @@ public class MemberController( [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN,MEMBER")] public async Task> GetMemberById(Guid id) { @@ -94,7 +93,6 @@ public async Task> UpdateMember(Guid id, [FromBody] MemberUpd [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> DeleteMember(Guid id) { @@ -111,7 +109,6 @@ public async Task> DeleteMember(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> LockMember(Guid id) { @@ -128,7 +125,6 @@ public async Task> LockMember(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task> UnlockMember(Guid id) { diff --git a/ClubService.API/Controller/SubscriptionTierController.cs b/ClubService.API/Controller/SubscriptionTierController.cs index 18cb95d..cd09cca 100644 --- a/ClubService.API/Controller/SubscriptionTierController.cs +++ b/ClubService.API/Controller/SubscriptionTierController.cs @@ -24,7 +24,6 @@ public async Task>> GetAllSubscript [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] public async Task> GetSubscriptionTierById(Guid id) { var subscriptionTier = await subscriptionTierReadModelRepository.GetSubscriptionTierById(id); diff --git a/ClubService.API/Controller/TennisClubController.cs b/ClubService.API/Controller/TennisClubController.cs index 837b3a8..3b640e4 100644 --- a/ClubService.API/Controller/TennisClubController.cs +++ b/ClubService.API/Controller/TennisClubController.cs @@ -49,7 +49,6 @@ public async Task>> GetAllTennisClubs( [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN,MEMBER")] public async Task> GetTennisClubById(Guid id) { @@ -76,7 +75,6 @@ public async Task> GetTennisClubById(Guid id) [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task>> GetAdminsByTennisClubId(Guid id) { @@ -97,7 +95,6 @@ public async Task>> GetAdminsByTennisClubId(Gu [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "ADMIN")] public async Task>> GetMembersByTennisClubId(Guid id) { @@ -150,7 +147,6 @@ public async Task> UpdateTennisClub( [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR,ADMIN")] public async Task> DeleteTennisClub(Guid id) { @@ -167,7 +163,6 @@ public async Task> DeleteTennisClub(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR")] public async Task> LockTennisClub(Guid id) { @@ -182,7 +177,6 @@ public async Task> LockTennisClub(Guid id) [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - [Consumes("text/plain")] [Authorize(Roles = "SYSTEM_OPERATOR")] public async Task> UnlockTennisClub(Guid id) { From f0feb4bed8cd77022690a75c2b9c13a8364431f4 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 10 Jul 2024 09:48:37 +0200 Subject: [PATCH 05/10] Added more Documentation --- README.md | 115 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 2d311b9..ba2a5be 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,10 @@ In our application we distinguish between `Members`, `Admins`, and `Supervisors` Members use their email address as login while Admins have a username. Since it's theoretically possible that a user would be member or admin of multiple tennis clubs, the login details (i.e. emails and usernames) only have to be unique within one tennis club. This means for a login we have to provide a username (which can be an actual username or an email) and the associated tennis club. -In a real world the user wouldn't need to select the tennis club, as we imagined each tennis club to have it's own page, which -would also allow for individual branding. +In a real world the user wouldn't need to select the tennis club, as we imagined each tennis club to have it's own page, which +would also allow for individual branding. -Once the credentials are sent to the backend, we check if it is an Admin or a Member and load the correct user form the database by +Once the credentials are sent to the backend, we check if it is an Admin or a Member and load the correct user form the database by using the unique combination of username and tennis club Id. Once we loaded the correct user, we can verify the password in our login db. Supervisors also have a username as login, they do not belong to any club as they are used to supervise all Tennisclubs. @@ -66,9 +66,12 @@ DDD was used in this project. The following aggregates exist: - TennisClub - UserPassword -Additionally, Event Sourcing was used, therefore those Entities all provide a `process` and a `apply` method. +Additionally, Event Sourcing was used, therefore those Entities all provide a `process` and a `apply` method. Process is used to create an Event from a Command, which can then be applied on this Entity. It's important that `apply` always -works if `process` works. +works if `process` works. + +Files: +DDD was used throughout the whole project. ## Event Sourcing @@ -78,6 +81,11 @@ which in turn implements the `IDomainEvent` interface, which is implemented by a Furthermore, we created a `DomainEnvelope`, which adds metadata to the Events such as a `Timestamp`, `EventType`, and `EntityType`. This is also what gets persisted in the database. Since event sourcing is used, the database is append only, events cannot be deleted. +All Events can be found in: ClubService.Domain/Event\ +The Domain Envelope can be found here: ClubService.Domain/Event/DomainEnvelope.cs +However, Event Sourcing encompasses much more and the whole project is structured to accommodate this approach, like creating +Domain Entitites with apply and process methods,... + ### Optimistic Locking We implemented optimistic locking directly in the insert sql query. @@ -91,6 +99,9 @@ We implemented optimistic locking directly in the insert sql query. This is done by counting the number of events before the insert, if the `expectedEventCount` is not correct the whole query fails. Therefore guaranteeing a consistent state. +Files:\ +ClubService.Infrastructure/Repositories/PostgresEventRepository.cs (Line 21 - 25) + ### Debezium Once an event gets persisted in the database Debezium publishes it to the redis stream. @@ -101,30 +112,30 @@ messages is correct by using transaction log tailing. #### Redis Event Reader -The `RedisEventReader` is registered as background service, messages are polled in a 1s intervall. +The `RedisEventReader` is registered as background service, messages are polled in a 1s intervall. We define which streams and which messages of which streams are relevant for us in the appsettings.json ```json -"RedisConfiguration": { - "Host": "localhost:6379", - "PollingInterval": "1", - "Streams": [ - { - "StreamName": "club_service_events.public.DomainEvent", - "ConsumerGroup": "club_service_events.domain.events.group", - "DesiredEventTypes": [ - "*" - ] - }, - { - "StreamName": "tournament_events.public.technicaleventenvelope", - "ConsumerGroup": "tournament_service_events.domain.events.group", - "DesiredEventTypes": [ - "TOURNAMENT_CONFIRMED", - "TOURNAMENT_CANCELED" - ] - } - ] - } + "RedisConfiguration": { +"Host": "localhost:6379", +"PollingInterval": "1", +"Streams": [ +{ +"StreamName": "club_service_events.public.DomainEvent", +"ConsumerGroup": "club_service_events.domain.events.group", +"DesiredEventTypes": [ +"*" +] +}, +{ +"StreamName": "tournament_events.public.technicaleventenvelope", +"ConsumerGroup": "tournament_service_events.domain.events.group", +"DesiredEventTypes": [ +"TOURNAMENT_CONFIRMED", +"TOURNAMENT_CANCELED" +] +} +] +}, ``` In our case we listen to all of our own events and additionally to the `TOURNAMENT_CONFIRMED` and `TOURNAMENT_CANCELLED` events of the tournament service. If the event that the stream provides us with is in the `DesiredEventTypes`, we hand @@ -155,23 +166,30 @@ processed event id happen in the same transaction therefore we can guarantee tha }); ``` +ClubService.Application/EventHandlers/ChainEventHandler.cs (Line 19 - 28)\ +ClubService.Domain/ReadModel/ProcessedEvent.cs + ## Sagas -we are just part of a Saga but did not have to implement one ourselves -//TODO more on saga +We did not have to implement any sagas. ## Authorization The authentication is done in the API gateway, in the tennisclub service we only verify that the user is allowed to perform the action based on the tennis club they are part of and/or the account they have. Example: Member can only change details for his own account, Admin can only lock members that are part of same tennisclub and so on. +Since C# wants to always verify the JWT, e.g. expiration date, and the API Gateway already verifies that, we implemented our own +middleware (ClubService.API/JwtClaimsMiddleware.cs), this allows us to change the behaviour of how the JWT is handled. ## CI/CD + Development For the whole project we used Sonarcloud with a github runner pipeline that automatically runs sonarcloud + all of the tests. -The main branch was locked and pull requests + code reviews were done for each merge to main. Furthermore, the pipeline had to -run and pass the quality gates set in sonarcloud. +The main branch was locked and pull requests + code reviews were done for each merge to main. Furthermore, the pipeline had to +run and pass the quality gates set in sonarcloud. +Files:\ +.github/workflows/ci.yml\ +.github/workflows/cd.yml ## Infrastructure @@ -231,7 +249,7 @@ ENV SMTP_SENDER_EMAIL_ADDRESS=admin@thcdornbirn.at ENTRYPOINT ["dotnet", "ClubService.API.dll"] ``` A build container is used to build the software which is then deployed in the base container. -This is then used in the `docker-compose.yml` file to start the tennis club service along with all other containers, except for Redis and Debezium-Postgres. +This is then used in the `docker-compose.yml` file to start the tennis club service along with all other containers, except for Redis and Debezium-Postgres. These two services are defined in the `docker-compose.override.yml` file. The idea behind this is that any other service can use our `docker-compose.yml` file without removing the redis and debezium-postgres services. @@ -319,4 +337,35 @@ services: ### Kubernetes -## Sending Emails \ No newline at end of file +We split all of the Kubernetes files by kind (config, secrets, deployment, and services). +The secrets contain for mailhog the sender email encoded in Base64 and for postgres the username and password also encoded in Base64. +All of the configuration files can be applied using `kubectl apply -R -f deployments` + +All of the files used to configure Kubernetes are located here:\ +deployment + +### Environment Variables + +We don't use hardcoded values for deployment, instead we configured the dockerfile to accept custom environment variables. +The used deployment solution in our case Kubernetes, sets the environment variables accordingly. In the program.cs (lines 9 - 14) we have some code +that substitutes the placeholders in the appsettings.Production.json with the values of the environment variables passed by kubernetes. +Therefore all of our environment variables are configured in the Kubernetes config files. + +## Sending Emails + +When the Tournament service publishes a TournamentConfirmed or TournamentCanceled event we send an email to each member of the Tennisclub. + +### Outbox pattern + +The TournamentCanceledEventHandler & TournamentConfirmedEventHandler write the email with the memberId and MemberEmail into the outbox table of our readstore. +Afterwards the EmailMessageRelay checks the table in a fixed interval and processes all entries of this table. +Once they are processed it removes them. All of this happens one transaction, guaranteeing a atleast once delivery. + +ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentCanceledEventHandler.cs (Line 54 - 59)\ +ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentConfirmedEventHandler.cs (Line 42 - 48)\ +ClubService.Infrastructure/Mail/EmailMessageRelay.cs (Line 32 - 55) + +### Mailhog + +We use Mailhog for testing the sending of the emails. +SMTP Config for mailhog: ClubService.API/appsettings.DockerDevelopment.json (Line 28 - 32) \ No newline at end of file From cba022d727653db3255441f25bf6cc25d6f3ee95 Mon Sep 17 00:00:00 2001 From: Michael Spiegel Date: Wed, 10 Jul 2024 10:19:07 +0200 Subject: [PATCH 06/10] Updated domain model and added diagram for event handlers to README.md. Co-authored-by: Marco Prescher Co-authored-by: Adrian Essig --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ba2a5be..bcf5ddc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # ClubService +[![CI](https://github.com/THC-Software/ClubService/actions/workflows/ci.yml/badge.svg)](https://github.com/THC-Software/ClubService/actions/workflows/ci.yml/badge.svg) +[![CD](https://github.com/THC-Software/ClubService/actions/workflows/cd.yml/badge.svg)](https://github.com/THC-Software/ClubService/actions/workflows/cd.yml/badge.svg) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=THC-Software_ClubService&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=THC-Software_ClubService) + [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=THC-Software_ClubService&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=THC-Software_ClubService) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=THC-Software_ClubService&metric=bugs)](https://sonarcloud.io/summary/new_code?id=THC-Software_ClubService) [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=THC-Software_ClubService&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=THC-Software_ClubService) @@ -11,30 +14,36 @@ Microservice to manage Tennis Clubs and their members. ```mermaid classDiagram class TennisClub { - id + tennisClubId name - isLocked + status } class Member { - id + memberId name email - isLocked + status } class Admin { - id + adminId username name + status } class SubscriptionTier { - id + subscriptionTierId name maxMemberCount } +class SystemOperator { + systemOperatorId + username +} + TennisClub "*" --> "1" SubscriptionTier TennisClub "1" --> "*" Member Admin "*" --> "1" TennisClub @@ -147,6 +156,32 @@ We've implemented a chain event handler that has a list of event handlers. Each All events are passed to the chain event handler and it in turn passes them further to all of the event handlers that are registered with it. If the event is supported in the event handler that it is passed to, it will process that event (e.g. construct the readside or send an email) +The following diagram shows the structure of the event handlers. In our case we split them up so that we have an +event handler for each event instead of for each aggregate but for the diagram we simplified it because otherwise it +would be too big. +```mermaid +classDiagram + class IEventHandler + class ChainEventHandler { + List~IEventHandler~ EventHandlers + } + class TennisClubEventHandler + class AdminEventHandler + class MemberEventHandler + class SubscriptionTierEventHandler + class SystemOperatorEventHandler + class TournamentEventHandler + + IEventHandler "*" -- ChainEventHandler + IEventHandler <|.. ChainEventHandler + IEventHandler <|.. TennisClubEventHandler + IEventHandler <|.. AdminEventHandler + IEventHandler <|.. MemberEventHandler + IEventHandler <|.. SubscriptionTierEventHandler + IEventHandler <|.. SystemOperatorEventHandler + IEventHandler <|.. TournamentEventHandler +``` + #### Duplicate Messages To guarantee idempotency, we track which events have already been handled. This is also done in the chain event handler. From 2192517ad9974488e1fe2b1e3d61908bf4b7c980 Mon Sep 17 00:00:00 2001 From: Michael Spiegel Date: Wed, 10 Jul 2024 10:51:38 +0200 Subject: [PATCH 07/10] Moved transaction to be for each email instead for all emails in EmailMessageRelay.cs. Added delete method to IEmailOutboxRepository.cs and implemented it in EmailOutboxRepository.cs. Updated line numbers in README.md. Co-authored-by: Marco Prescher Co-authored-by: Adrian Essig --- .../Repository/IEmailOutboxRepository.cs | 4 ++-- .../Mail/EmailMessageRelay.cs | 21 +++++++++---------- .../Repositories/EmailOutboxRepository.cs | 6 +++--- README.md | 2 +- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/ClubService.Domain/Repository/IEmailOutboxRepository.cs b/ClubService.Domain/Repository/IEmailOutboxRepository.cs index e20f690..88f7f28 100644 --- a/ClubService.Domain/Repository/IEmailOutboxRepository.cs +++ b/ClubService.Domain/Repository/IEmailOutboxRepository.cs @@ -5,6 +5,6 @@ namespace ClubService.Domain.Repository; public interface IEmailOutboxRepository { Task Add(EmailMessage emailMessage); - Task> GetAllEmails(); - Task RemoveEmails(List emails); + Task> GetAllEmailMessages(); + Task Delete(EmailMessage emailMessage); } \ No newline at end of file diff --git a/ClubService.Infrastructure/Mail/EmailMessageRelay.cs b/ClubService.Infrastructure/Mail/EmailMessageRelay.cs index 5de5d8f..426b22c 100644 --- a/ClubService.Infrastructure/Mail/EmailMessageRelay.cs +++ b/ClubService.Infrastructure/Mail/EmailMessageRelay.cs @@ -35,23 +35,22 @@ private async Task SendEmails() var emailOutboxRepository = scope.ServiceProvider.GetRequiredService(); var transactionManager = scope.ServiceProvider.GetRequiredService(); - await transactionManager.TransactionScope(async () => - { - var emails = await emailOutboxRepository.GetAllEmails(); + var emailMessages = await emailOutboxRepository.GetAllEmailMessages(); - foreach (var email in emails) + foreach (var emailMessage in emailMessages) + { + await transactionManager.TransactionScope(async () => { - var mailMessage = new MailMessage(_senderEmailAddress, email.RecipientEMailAddress) + var mailMessage = new MailMessage(_senderEmailAddress, emailMessage.RecipientEMailAddress) { - Subject = email.Subject, - Body = email.Body + Subject = emailMessage.Subject, + Body = emailMessage.Body }; await _smtpClient.SendMailAsync(mailMessage); - } - - await emailOutboxRepository.RemoveEmails(emails); - }); + await emailOutboxRepository.Delete(emailMessage); + }); + } } protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/ClubService.Infrastructure/Repositories/EmailOutboxRepository.cs b/ClubService.Infrastructure/Repositories/EmailOutboxRepository.cs index 1bc3a2f..364ca5b 100644 --- a/ClubService.Infrastructure/Repositories/EmailOutboxRepository.cs +++ b/ClubService.Infrastructure/Repositories/EmailOutboxRepository.cs @@ -13,14 +13,14 @@ public async Task Add(EmailMessage emailMessage) await readStoreDbContext.SaveChangesAsync(); } - public async Task> GetAllEmails() + public async Task> GetAllEmailMessages() { return await readStoreDbContext.EmailOutbox.OrderBy(x => x.Timestamp).ToListAsync(); } - public async Task RemoveEmails(List emails) + public async Task Delete(EmailMessage emailMessage) { - readStoreDbContext.EmailOutbox.RemoveRange(emails); + readStoreDbContext.EmailOutbox.Remove(emailMessage); await readStoreDbContext.SaveChangesAsync(); } } \ No newline at end of file diff --git a/README.md b/README.md index bcf5ddc..772146d 100644 --- a/README.md +++ b/README.md @@ -398,7 +398,7 @@ Once they are processed it removes them. All of this happens one transaction, gu ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentCanceledEventHandler.cs (Line 54 - 59)\ ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentConfirmedEventHandler.cs (Line 42 - 48)\ -ClubService.Infrastructure/Mail/EmailMessageRelay.cs (Line 32 - 55) +ClubService.Infrastructure/Mail/EmailMessageRelay.cs (Line 32 - 54) ### Mailhog From 2a804f75b2044b15b3a0c5e799de8d2ae0a2743f Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Wed, 10 Jul 2024 12:53:13 +0200 Subject: [PATCH 08/10] Fixed json field --- README.md | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 772146d..50ccebe 100644 --- a/README.md +++ b/README.md @@ -124,27 +124,29 @@ messages is correct by using transaction log tailing. The `RedisEventReader` is registered as background service, messages are polled in a 1s intervall. We define which streams and which messages of which streams are relevant for us in the appsettings.json ```json - "RedisConfiguration": { -"Host": "localhost:6379", -"PollingInterval": "1", -"Streams": [ { -"StreamName": "club_service_events.public.DomainEvent", -"ConsumerGroup": "club_service_events.domain.events.group", -"DesiredEventTypes": [ -"*" -] -}, -{ -"StreamName": "tournament_events.public.technicaleventenvelope", -"ConsumerGroup": "tournament_service_events.domain.events.group", -"DesiredEventTypes": [ -"TOURNAMENT_CONFIRMED", -"TOURNAMENT_CANCELED" -] + "RedisConfiguration": { + "Host": "localhost:6379", + "PollingInterval": "1", + "Streams": [ + { + "StreamName": "club_service_events.public.DomainEvent", + "ConsumerGroup": "club_service_events.domain.events.group", + "DesiredEventTypes": [ + "*" + ] + }, + { + "StreamName": "tournament_events.public.technicaleventenvelope", + "ConsumerGroup": "tournament_service_events.domain.events.group", + "DesiredEventTypes": [ + "TOURNAMENT_CONFIRMED", + "TOURNAMENT_CANCELED" + ] + } + ] + } } -] -}, ``` In our case we listen to all of our own events and additionally to the `TOURNAMENT_CONFIRMED` and `TOURNAMENT_CANCELLED` events of the tournament service. If the event that the stream provides us with is in the `DesiredEventTypes`, we hand From 000e52f778f088409002f7d3e5d5fa94473705a9 Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Wed, 10 Jul 2024 13:12:49 +0200 Subject: [PATCH 09/10] Minor documentation improvements Co-authored-by: Adrian <50778781+essiga@users.noreply.github.com> --- README.md | 69 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 50ccebe..afa987d 100644 --- a/README.md +++ b/README.md @@ -55,15 +55,14 @@ In our application we distinguish between `Members`, `Admins`, and `Supervisors` Members use their email address as login while Admins have a username. Since it's theoretically possible that a user would be member or admin of multiple tennis clubs, the login details (i.e. emails and usernames) only have to be unique within one tennis club. This means for a login we have to provide a username (which can be an actual username or an email) and the associated tennis club. -In a real world the user wouldn't need to select the tennis club, as we imagined each tennis club to have it's own page, which +In a real world the user wouldn't need to select the tennis club, as we imagined each tennis club to have its own page, which would also allow for individual branding. Once the credentials are sent to the backend, we check if it is an Admin or a Member and load the correct user form the database by -using the unique combination of username and tennis club Id. Once we loaded the correct user, we can verify the password in our login db. - -Supervisors also have a username as login, they do not belong to any club as they are used to supervise all Tennisclubs. -Therefore, if no TennisclubId is provided when logging in, we assume the user to be a Supervisor. +using the unique combination of username and tennisClubId. Once we loaded the correct user, we can verify the password in our login db. +Supervisors also have a username as login, they do not belong to any club as they are used to supervise all Tennis clubs. +Therefore, if no TennisClubId is provided when logging in, we assume the user to be a Supervisor. ## Domain Driven Design @@ -79,7 +78,7 @@ Additionally, Event Sourcing was used, therefore those Entities all provide a `p Process is used to create an Event from a Command, which can then be applied on this Entity. It's important that `apply` always works if `process` works. -Files: +Files:\ DDD was used throughout the whole project. ## Event Sourcing @@ -93,7 +92,7 @@ This is also what gets persisted in the database. Since event sourcing is used, All Events can be found in: ClubService.Domain/Event\ The Domain Envelope can be found here: ClubService.Domain/Event/DomainEnvelope.cs However, Event Sourcing encompasses much more and the whole project is structured to accommodate this approach, like creating -Domain Entitites with apply and process methods,... +Domain Entities with apply and process methods,... ### Optimistic Locking @@ -106,10 +105,10 @@ We implemented optimistic locking directly in the insert sql query. "; ``` This is done by counting the number of events before the insert, if the `expectedEventCount` is not correct the whole query fails. -Therefore guaranteeing a consistent state. +Therefore, guaranteeing a consistent state. Files:\ -ClubService.Infrastructure/Repositories/PostgresEventRepository.cs (Line 21 - 25) +`ClubService.Infrastructure/Repositories/PostgresEventRepository.cs (Line 21 - 25)` ### Debezium @@ -121,8 +120,8 @@ messages is correct by using transaction log tailing. #### Redis Event Reader -The `RedisEventReader` is registered as background service, messages are polled in a 1s intervall. -We define which streams and which messages of which streams are relevant for us in the appsettings.json +The `RedisEventReader` is registered as background service, messages are polled in a configurable interval. +We define which streams and which messages of which streams are relevant for us in the `appsettings.json` ```json { "RedisConfiguration": { @@ -155,8 +154,8 @@ it to the `ChainEventHandler` #### Chain Event Handler We've implemented a chain event handler that has a list of event handlers. Each event handler corresponds to one event type. -All events are passed to the chain event handler and it in turn passes them further to all of the event handlers that are registered with it. -If the event is supported in the event handler that it is passed to, it will process that event (e.g. construct the readside or send an email) +All events are passed to the chain event handler and it in turn passes them further to all the event handlers that are registered with it. +If the event is supported in the event handler that it is passed to, it will process that event (e.g. construct the read side or send an email). The following diagram shows the structure of the event handlers. In our case we split them up so that we have an event handler for each event instead of for each aggregate but for the diagram we simplified it because otherwise it @@ -203,8 +202,9 @@ processed event id happen in the same transaction therefore we can guarantee tha }); ``` -ClubService.Application/EventHandlers/ChainEventHandler.cs (Line 19 - 28)\ -ClubService.Domain/ReadModel/ProcessedEvent.cs +Files:\ +`ClubService.Application/EventHandlers/ChainEventHandler.cs (Line 19 - 28)`\ +`ClubService.Domain/ReadModel/ProcessedEvent.cs` ## Sagas @@ -212,21 +212,21 @@ We did not have to implement any sagas. ## Authorization -The authentication is done in the API gateway, in the tennisclub service we only verify that the user is allowed to perform the action +The authentication is done in the API gateway, in the tennis club service we only verify that the user is allowed to perform the action based on the tennis club they are part of and/or the account they have. -Example: Member can only change details for his own account, Admin can only lock members that are part of same tennisclub and so on. +Example: Member can only change details for his own account, Admin can only lock members that are part of same tennis club and so on. Since C# wants to always verify the JWT, e.g. expiration date, and the API Gateway already verifies that, we implemented our own middleware (ClubService.API/JwtClaimsMiddleware.cs), this allows us to change the behaviour of how the JWT is handled. ## CI/CD + Development -For the whole project we used Sonarcloud with a github runner pipeline that automatically runs sonarcloud + all of the tests. +For the whole project we used Sonarcloud with a github runner pipeline that automatically runs sonarcloud + all the tests. The main branch was locked and pull requests + code reviews were done for each merge to main. Furthermore, the pipeline had to run and pass the quality gates set in sonarcloud. Files:\ -.github/workflows/ci.yml\ -.github/workflows/cd.yml +`.github/workflows/ci.yml`\ +`.github/workflows/cd.yml` ## Infrastructure @@ -374,35 +374,36 @@ services: ### Kubernetes -We split all of the Kubernetes files by kind (config, secrets, deployment, and services). +We split all the Kubernetes files by kind (config, secrets, deployment, and services). The secrets contain for mailhog the sender email encoded in Base64 and for postgres the username and password also encoded in Base64. -All of the configuration files can be applied using `kubectl apply -R -f deployments` +All the configuration files can be applied using `kubectl apply -R -f deployments`. -All of the files used to configure Kubernetes are located here:\ -deployment +All the files used to configure Kubernetes are located here:\ +`deployment/` ### Environment Variables We don't use hardcoded values for deployment, instead we configured the dockerfile to accept custom environment variables. The used deployment solution in our case Kubernetes, sets the environment variables accordingly. In the program.cs (lines 9 - 14) we have some code -that substitutes the placeholders in the appsettings.Production.json with the values of the environment variables passed by kubernetes. -Therefore all of our environment variables are configured in the Kubernetes config files. +that substitutes the placeholders in the `appsettings.Production.json` with the values of the environment variables passed by kubernetes. +Therefore, all of our environment variables are configured in the Kubernetes config files. ## Sending Emails -When the Tournament service publishes a TournamentConfirmed or TournamentCanceled event we send an email to each member of the Tennisclub. +When the Tournament service publishes a TournamentConfirmed or TournamentCanceled event we email each member of the Tennis club. ### Outbox pattern -The TournamentCanceledEventHandler & TournamentConfirmedEventHandler write the email with the memberId and MemberEmail into the outbox table of our readstore. +The TournamentCanceledEventHandler & TournamentConfirmedEventHandler write the email with the memberId and MemberEmail into the outbox table of our read store. Afterwards the EmailMessageRelay checks the table in a fixed interval and processes all entries of this table. -Once they are processed it removes them. All of this happens one transaction, guaranteeing a atleast once delivery. +Once they are processed it removes them. All of this happens one transaction, guaranteeing the at least once delivery. -ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentCanceledEventHandler.cs (Line 54 - 59)\ -ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentConfirmedEventHandler.cs (Line 42 - 48)\ -ClubService.Infrastructure/Mail/EmailMessageRelay.cs (Line 32 - 54) +`ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentCanceledEventHandler.cs (Line 54 - 59)`\ +`ClubService.Application/EventHandlers/TournamentEventHandlers/TournamentConfirmedEventHandler.cs (Line 42 - 48)`\ +`ClubService.Infrastructure/Mail/EmailMessageRelay.cs (Line 32 - 54)` ### Mailhog -We use Mailhog for testing the sending of the emails. -SMTP Config for mailhog: ClubService.API/appsettings.DockerDevelopment.json (Line 28 - 32) \ No newline at end of file +We use mailhog for testing the sending of the emails. +SMTP Config for mailhog:\ +`ClubService.API/appsettings.DockerDevelopment.json (Line 28 - 32)` \ No newline at end of file From 5364ec532f5d290b105ce8868f085b4d7077d056 Mon Sep 17 00:00:00 2001 From: Marco Prescher Date: Wed, 10 Jul 2024 13:24:10 +0200 Subject: [PATCH 10/10] Fixed parsing of non-existing events Co-authored-by: Adrian <50778781+essiga@users.noreply.github.com> Co-authored-by: Michael Spiegel <70846025+smightym8@users.noreply.github.com> --- .../EventHandling/RedisEventReader.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ClubService.Infrastructure/EventHandling/RedisEventReader.cs b/ClubService.Infrastructure/EventHandling/RedisEventReader.cs index 75c89e4..7278fb0 100644 --- a/ClubService.Infrastructure/EventHandling/RedisEventReader.cs +++ b/ClubService.Infrastructure/EventHandling/RedisEventReader.cs @@ -73,19 +73,19 @@ private async Task ConsumeMessages() await db.StreamAcknowledgeAsync(stream.StreamName, stream.ConsumerGroup, entry.Id); continue; } - - var parsedEvent = EventParser.ParseEvent(after); - + // Here we filter out the events we don't want. // If the EventTypes list contains an asterisk we want all the events of that stream. // Otherwise, we check if the event type of the parsedEvent is in the list of desired events. - var parsedEventType = parsedEvent.EventType.ToString(); + var parsedEventType = after.GetProperty("eventType").GetString(); if (!stream.DesiredEventTypes.Contains(AllEvents) && !stream.DesiredEventTypes.Contains(parsedEventType, StringComparer.OrdinalIgnoreCase)) { await db.StreamAcknowledgeAsync(stream.StreamName, stream.ConsumerGroup, entry.Id); continue; } + + var parsedEvent = EventParser.ParseEvent(after); using var scope = _services.CreateScope(); var chainEventHandler = scope.ServiceProvider.GetRequiredService();