From 474426dbe9269ceaaffc242cfe4cf0a6a4bc30bc Mon Sep 17 00:00:00 2001 From: Artyom Barkovsky Date: Mon, 6 Oct 2025 20:47:54 +0500 Subject: [PATCH 1/5] .gitignore (stop tracking bin/obj/ .vs) --- .gitignore | 24 +++ ...CoworkingBooking.BookingService.Api.csproj | 24 +++ .../Program.cs | 83 ++++++++ .../Properties/launchSettings.json | 38 ++++ .../appsettings.json | 11 + .../Bookings/BookingDtos.cs | 19 ++ .../Class1.cs | 6 + ...gBooking.BookingService.Application.csproj | 18 ++ .../Rooms/RoomDtos.cs | 5 + .../Rules/RuleDtos.cs | 5 + .../Class1.cs | 6 + ...oking.BookingService.Infrastructure.csproj | 24 +++ .../20251006151430_Initial.Designer.cs | 198 ++++++++++++++++++ .../Migrations/20251006151430_Initial.cs | 135 ++++++++++++ .../BookingDbContextModelSnapshot.cs | 195 +++++++++++++++++ .../Persistence/Entities.cs | 74 +++++++ .../Services/BookingServiceImpl.cs | 79 +++++++ .../Services/RoomService.cs | 64 ++++++ .../Services/RuleService.cs | 60 ++++++ CoworkingBooking.CoreLib/Class1.cs | 6 + .../Contracts/PagedResponse.cs | 3 + .../CoworkingBooking.CoreLib.csproj | 9 + CoworkingBooking.CoreLib/Results/Result.cs | 20 ++ CoworkingBooking.sln | 40 ++++ README.md | 13 +- docker-compose.yml | 14 ++ 26 files changed, 1166 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj create mode 100644 CoworkingBooking.BookingService.Api/Program.cs create mode 100644 CoworkingBooking.BookingService.Api/Properties/launchSettings.json create mode 100644 CoworkingBooking.BookingService.Api/appsettings.json create mode 100644 CoworkingBooking.BookingService.Application/Bookings/BookingDtos.cs create mode 100644 CoworkingBooking.BookingService.Application/Class1.cs create mode 100644 CoworkingBooking.BookingService.Application/CoworkingBooking.BookingService.Application.csproj create mode 100644 CoworkingBooking.BookingService.Application/Rooms/RoomDtos.cs create mode 100644 CoworkingBooking.BookingService.Application/Rules/RuleDtos.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Class1.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/CoworkingBooking.BookingService.Infrastructure.csproj create mode 100644 CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.Designer.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Migrations/BookingDbContextModelSnapshot.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Persistence/Entities.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Services/BookingServiceImpl.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Services/RoomService.cs create mode 100644 CoworkingBooking.BookingService.Infrastructure/Services/RuleService.cs create mode 100644 CoworkingBooking.CoreLib/Class1.cs create mode 100644 CoworkingBooking.CoreLib/Contracts/PagedResponse.cs create mode 100644 CoworkingBooking.CoreLib/CoworkingBooking.CoreLib.csproj create mode 100644 CoworkingBooking.CoreLib/Results/Result.cs create mode 100644 CoworkingBooking.sln create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3b7d80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# ===== .NET / IDE ===== +bin/ +obj/ +.vs/ +*.user +*.suo +*.userprefs +.idea/ +*.DotSettings.user + +# тесты / артефакты +TestResults/ +*.log + +# ОС +.DS_Store +Thumbs.db + +# секреты/локальные конфиги +appsettings.Development.json +appsettings.*.local.json + +# docker локальные переопределения +docker-compose.override.yml diff --git a/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj b/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj new file mode 100644 index 0000000..dcc2904 --- /dev/null +++ b/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + net8.0 + enable + enable + + + diff --git a/CoworkingBooking.BookingService.Api/Program.cs b/CoworkingBooking.BookingService.Api/Program.cs new file mode 100644 index 0000000..5e5f4ea --- /dev/null +++ b/CoworkingBooking.BookingService.Api/Program.cs @@ -0,0 +1,83 @@ +using BookingService.Application.Bookings; +using BookingService.Application.Rooms; +using BookingService.Application.Rules; +using BookingService.Infrastructure.Persistence; +using BookingService.Infrastructure.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddDbContext(opt => + opt.UseNpgsql(builder.Configuration.GetConnectionString("Postgres"))); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); + +// Rooms +app.MapGet("/api/booking/v1/rooms", async (int page, int pageSize, IRoomService svc, CancellationToken ct) => + Results.Ok(await svc.GetAsync(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, ct))); +app.MapGet("/api/booking/v1/rooms/{id:guid}", async (Guid id, IRoomService svc, CancellationToken ct) => +{ + var r = await svc.GetByIdAsync(id, ct); + return r is null ? Results.NotFound() : Results.Ok(r); +}); +app.MapPost("/api/booking/v1/rooms", async (RoomCreateDto dto, IRoomService svc, CancellationToken ct) => + Results.Created($"/api/booking/v1/rooms", await svc.CreateAsync(dto, ct))); +app.MapPut("/api/booking/v1/rooms/{id:guid}", async (Guid id, RoomUpdateDto dto, IRoomService svc, CancellationToken ct) => +{ + var res = await svc.UpdateAsync(id, dto, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); +app.MapDelete("/api/booking/v1/rooms/{id:guid}", async (Guid id, IRoomService svc, CancellationToken ct) => +{ + var res = await svc.DeleteAsync(id, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); + +// Rules +app.MapGet("/api/booking/v1/rooms/{roomId:guid}/rules", async (Guid roomId, IRuleService svc, CancellationToken ct) => + Results.Ok(await svc.GetByRoomAsync(roomId, ct))); +app.MapPost("/api/booking/v1/rules", async (TimeSlotRuleCreateDto dto, IRuleService svc, CancellationToken ct) => + Results.Created($"/api/booking/v1/rules", await svc.CreateAsync(dto, ct))); +app.MapPut("/api/booking/v1/rules/{id:guid}", async (Guid id, TimeSlotRuleUpdateDto dto, IRuleService svc, CancellationToken ct) => +{ + var res = await svc.UpdateAsync(id, dto, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); +app.MapDelete("/api/booking/v1/rules/{id:guid}", async (Guid id, IRuleService svc, CancellationToken ct) => +{ + var res = await svc.DeleteAsync(id, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); + +// Bookings +app.MapPost("/api/booking/v1/bookings", async (BookingCreateDto dto, IBookingService svc, CancellationToken ct) => +{ + var created = await svc.CreateAsync(dto, ct); + return Results.Created($"/api/booking/v1/bookings/{created.Id}", created); +}); +app.MapGet("/api/booking/v1/bookings/{id:guid}", async (Guid id, IBookingService svc, CancellationToken ct) => +{ + var b = await svc.GetAsync(id, ct); + return b is null ? Results.NotFound() : Results.Ok(b); +}); +app.MapPatch("/api/booking/v1/bookings/{id:guid}", async (Guid id, BookingUpdateDto dto, IBookingService svc, CancellationToken ct) => +{ + var res = await svc.UpdateAsync(id, dto, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); +app.MapPost("/api/booking/v1/bookings/{id:guid}/cancel", async (Guid id, IBookingService svc, CancellationToken ct) => +{ + var res = await svc.CancelAsync(id, ct); + return res.IsSuccess ? Results.NoContent() : Results.NotFound(); +}); + +app.Run(); diff --git a/CoworkingBooking.BookingService.Api/Properties/launchSettings.json b/CoworkingBooking.BookingService.Api/Properties/launchSettings.json new file mode 100644 index 0000000..02830e2 --- /dev/null +++ b/CoworkingBooking.BookingService.Api/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:30606", + "sslPort": 44341 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5058", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7288;http://localhost:5058", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/CoworkingBooking.BookingService.Api/appsettings.json b/CoworkingBooking.BookingService.Api/appsettings.json new file mode 100644 index 0000000..29d55ac --- /dev/null +++ b/CoworkingBooking.BookingService.Api/appsettings.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=bookingdb;Username=booking;Password=bookingpwd" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/CoworkingBooking.BookingService.Application/Bookings/BookingDtos.cs b/CoworkingBooking.BookingService.Application/Bookings/BookingDtos.cs new file mode 100644 index 0000000..258c230 --- /dev/null +++ b/CoworkingBooking.BookingService.Application/Bookings/BookingDtos.cs @@ -0,0 +1,19 @@ +namespace BookingService.Application.Bookings; + +public enum BookingStatus { Pending = 0, Confirmed = 1, Cancelled = 2 } + +public record BookingParticipantDto(Guid Id, string Name, string Email); +public record BookingCreateParticipantDto(string Name, string Email); + +public record BookingDto( + Guid Id, + Guid RoomId, + Guid UserId, + DateTime StartUtc, + DateTime EndUtc, + decimal PriceTotal, + BookingStatus Status, + IReadOnlyList Participants); + +public record BookingCreateDto(Guid RoomId, Guid UserId, DateTime StartUtc, DateTime EndUtc, decimal? PriceHint, List? Participants); +public record BookingUpdateDto(DateTime? StartUtc, DateTime? EndUtc); diff --git a/CoworkingBooking.BookingService.Application/Class1.cs b/CoworkingBooking.BookingService.Application/Class1.cs new file mode 100644 index 0000000..325d915 --- /dev/null +++ b/CoworkingBooking.BookingService.Application/Class1.cs @@ -0,0 +1,6 @@ +namespace CoworkingBooking.BookingService.Application; + +public class Class1 +{ + +} diff --git a/CoworkingBooking.BookingService.Application/CoworkingBooking.BookingService.Application.csproj b/CoworkingBooking.BookingService.Application/CoworkingBooking.BookingService.Application.csproj new file mode 100644 index 0000000..5e37cae --- /dev/null +++ b/CoworkingBooking.BookingService.Application/CoworkingBooking.BookingService.Application.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/CoworkingBooking.BookingService.Application/Rooms/RoomDtos.cs b/CoworkingBooking.BookingService.Application/Rooms/RoomDtos.cs new file mode 100644 index 0000000..8443a19 --- /dev/null +++ b/CoworkingBooking.BookingService.Application/Rooms/RoomDtos.cs @@ -0,0 +1,5 @@ +namespace BookingService.Application.Rooms; + +public record RoomDto(Guid Id, string Name, int Capacity, decimal BasePricePerHour, bool IsActive); +public record RoomCreateDto(string Name, int Capacity, decimal BasePricePerHour, bool IsActive); +public record RoomUpdateDto(string Name, int Capacity, decimal BasePricePerHour, bool IsActive); diff --git a/CoworkingBooking.BookingService.Application/Rules/RuleDtos.cs b/CoworkingBooking.BookingService.Application/Rules/RuleDtos.cs new file mode 100644 index 0000000..de3e530 --- /dev/null +++ b/CoworkingBooking.BookingService.Application/Rules/RuleDtos.cs @@ -0,0 +1,5 @@ +namespace BookingService.Application.Rules; + +public record TimeSlotRuleDto(Guid Id, Guid RoomId, int Weekday, string StartTime, string EndTime, int MinDurationMin, int MaxDurationMin); +public record TimeSlotRuleCreateDto(Guid RoomId, int Weekday, string StartTime, string EndTime, int MinDurationMin, int MaxDurationMin); +public record TimeSlotRuleUpdateDto(int Weekday, string StartTime, string EndTime, int MinDurationMin, int MaxDurationMin); diff --git a/CoworkingBooking.BookingService.Infrastructure/Class1.cs b/CoworkingBooking.BookingService.Infrastructure/Class1.cs new file mode 100644 index 0000000..93a6c70 --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Class1.cs @@ -0,0 +1,6 @@ +namespace CoworkingBooking.BookingService.Infrastructure; + +public class Class1 +{ + +} diff --git a/CoworkingBooking.BookingService.Infrastructure/CoworkingBooking.BookingService.Infrastructure.csproj b/CoworkingBooking.BookingService.Infrastructure/CoworkingBooking.BookingService.Infrastructure.csproj new file mode 100644 index 0000000..3bb1bc3 --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/CoworkingBooking.BookingService.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + net8.0 + enable + enable + + + diff --git a/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.Designer.cs b/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.Designer.cs new file mode 100644 index 0000000..072e533 --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.Designer.cs @@ -0,0 +1,198 @@ +// +using System; +using BookingService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CoworkingBooking.BookingService.Infrastructure.Migrations +{ + [DbContext(typeof(BookingDbContext))] + [Migration("20251006151430_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PriceTotal") + .HasColumnType("numeric(12,2)"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "StartUtc", "EndUtc"); + + b.ToTable("Bookings"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.BookingParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookingId") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.ToTable("BookingParticipants"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BasePricePerHour") + .HasColumnType("numeric"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.TimeSlotRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("MaxDurationMin") + .HasColumnType("integer"); + + b.Property("MinDurationMin") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Weekday"); + + b.ToTable("TimeSlotRules"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Room", "Room") + .WithMany("Bookings") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.BookingParticipant", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Booking", "Booking") + .WithMany("Participants") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.TimeSlotRule", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Room", "Room") + .WithMany("Rules") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Room", b => + { + b.Navigation("Bookings"); + + b.Navigation("Rules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.cs b/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.cs new file mode 100644 index 0000000..adf291a --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Migrations/20251006151430_Initial.cs @@ -0,0 +1,135 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CoworkingBooking.BookingService.Infrastructure.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Rooms", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Capacity = table.Column(type: "integer", nullable: false), + BasePricePerHour = table.Column(type: "numeric", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Rooms", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Bookings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + StartUtc = table.Column(type: "timestamp with time zone", nullable: false), + EndUtc = table.Column(type: "timestamp with time zone", nullable: false), + PriceTotal = table.Column(type: "numeric(12,2)", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RowVersion = table.Column(type: "bytea", rowVersion: true, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Bookings", x => x.Id); + table.ForeignKey( + name: "FK_Bookings_Rooms_RoomId", + column: x => x.RoomId, + principalTable: "Rooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TimeSlotRules", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RoomId = table.Column(type: "uuid", nullable: false), + Weekday = table.Column(type: "integer", nullable: false), + StartTime = table.Column(type: "time without time zone", nullable: false), + EndTime = table.Column(type: "time without time zone", nullable: false), + MinDurationMin = table.Column(type: "integer", nullable: false), + MaxDurationMin = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TimeSlotRules", x => x.Id); + table.ForeignKey( + name: "FK_TimeSlotRules_Rooms_RoomId", + column: x => x.RoomId, + principalTable: "Rooms", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BookingParticipants", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + BookingId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BookingParticipants", x => x.Id); + table.ForeignKey( + name: "FK_BookingParticipants_Bookings_BookingId", + column: x => x.BookingId, + principalTable: "Bookings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BookingParticipants_BookingId", + table: "BookingParticipants", + column: "BookingId"); + + migrationBuilder.CreateIndex( + name: "IX_Bookings_RoomId_StartUtc_EndUtc", + table: "Bookings", + columns: new[] { "RoomId", "StartUtc", "EndUtc" }); + + migrationBuilder.CreateIndex( + name: "IX_Rooms_IsActive", + table: "Rooms", + column: "IsActive"); + + migrationBuilder.CreateIndex( + name: "IX_TimeSlotRules_RoomId_Weekday", + table: "TimeSlotRules", + columns: new[] { "RoomId", "Weekday" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BookingParticipants"); + + migrationBuilder.DropTable( + name: "TimeSlotRules"); + + migrationBuilder.DropTable( + name: "Bookings"); + + migrationBuilder.DropTable( + name: "Rooms"); + } + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Migrations/BookingDbContextModelSnapshot.cs b/CoworkingBooking.BookingService.Infrastructure/Migrations/BookingDbContextModelSnapshot.cs new file mode 100644 index 0000000..dda3fdd --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Migrations/BookingDbContextModelSnapshot.cs @@ -0,0 +1,195 @@ +// +using System; +using BookingService.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CoworkingBooking.BookingService.Infrastructure.Migrations +{ + [DbContext(typeof(BookingDbContext))] + partial class BookingDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PriceTotal") + .HasColumnType("numeric(12,2)"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.Property("StartUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "StartUtc", "EndUtc"); + + b.ToTable("Bookings"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.BookingParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BookingId") + .HasColumnType("uuid"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("BookingId"); + + b.ToTable("BookingParticipants"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Room", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BasePricePerHour") + .HasColumnType("numeric"); + + b.Property("Capacity") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("Rooms"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.TimeSlotRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("MaxDurationMin") + .HasColumnType("integer"); + + b.Property("MinDurationMin") + .HasColumnType("integer"); + + b.Property("RoomId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoomId", "Weekday"); + + b.ToTable("TimeSlotRules"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Room", "Room") + .WithMany("Bookings") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.BookingParticipant", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Booking", "Booking") + .WithMany("Participants") + .HasForeignKey("BookingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Booking"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.TimeSlotRule", b => + { + b.HasOne("BookingService.Infrastructure.Persistence.Room", "Room") + .WithMany("Rules") + .HasForeignKey("RoomId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Room"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Booking", b => + { + b.Navigation("Participants"); + }); + + modelBuilder.Entity("BookingService.Infrastructure.Persistence.Room", b => + { + b.Navigation("Bookings"); + + b.Navigation("Rules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Persistence/Entities.cs b/CoworkingBooking.BookingService.Infrastructure/Persistence/Entities.cs new file mode 100644 index 0000000..7a463cb --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Persistence/Entities.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace BookingService.Infrastructure.Persistence; + +public class Room +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public int Capacity { get; set; } + public decimal BasePricePerHour { get; set; } + public bool IsActive { get; set; } = true; + + public ICollection Rules { get; set; } = new List(); + public ICollection Bookings { get; set; } = new List(); +} + +public class TimeSlotRule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid RoomId { get; set; } + public Room Room { get; set; } = null!; + public int Weekday { get; set; } // 0..6 + public TimeOnly StartTime { get; set; } + public TimeOnly EndTime { get; set; } + public int MinDurationMin { get; set; } = 30; + public int MaxDurationMin { get; set; } = 240; +} + +public enum BookingStatus { Pending = 0, Confirmed = 1, Cancelled = 2 } + +public class Booking +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid RoomId { get; set; } + public Room Room { get; set; } = null!; + public Guid UserId { get; set; } + public DateTime StartUtc { get; set; } + public DateTime EndUtc { get; set; } + public decimal PriceTotal { get; set; } + public BookingStatus Status { get; set; } = BookingStatus.Pending; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Timestamp] public byte[]? RowVersion { get; set; } + + public ICollection Participants { get; set; } = new List(); +} + +public class BookingParticipant +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid BookingId { get; set; } + public Booking Booking { get; set; } = null!; + public string Name { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} + +public class BookingDbContext : DbContext +{ + public BookingDbContext(DbContextOptions options) : base(options) { } + + public DbSet Rooms => Set(); + public DbSet TimeSlotRules => Set(); + public DbSet Bookings => Set(); + public DbSet BookingParticipants => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(r => new { r.IsActive }); + modelBuilder.Entity().HasIndex(r => new { r.RoomId, r.Weekday }); + modelBuilder.Entity().HasIndex(b => new { b.RoomId, b.StartUtc, b.EndUtc }); + modelBuilder.Entity().Property(b => b.PriceTotal).HasColumnType("numeric(12,2)"); + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Services/BookingServiceImpl.cs b/CoworkingBooking.BookingService.Infrastructure/Services/BookingServiceImpl.cs new file mode 100644 index 0000000..de7710e --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Services/BookingServiceImpl.cs @@ -0,0 +1,79 @@ +using BookingService.Application.Bookings; +using BookingService.Infrastructure.Persistence; +using CoreLib.Results; +using Microsoft.EntityFrameworkCore; +using AppBookingStatus = BookingService.Application.Bookings.BookingStatus; +using DbBookingStatus = BookingService.Infrastructure.Persistence.BookingStatus; + +namespace BookingService.Infrastructure.Services; + +public interface IBookingService +{ + Task GetAsync(Guid id, CancellationToken ct); + Task CreateAsync(BookingCreateDto dto, CancellationToken ct); + Task UpdateAsync(Guid id, BookingUpdateDto dto, CancellationToken ct); + Task CancelAsync(Guid id, CancellationToken ct); +} + +public class BookingServiceImpl : IBookingService +{ + private readonly BookingDbContext _db; + public BookingServiceImpl(BookingDbContext db) { _db = db; } + + private static BookingDto Map(Booking b) => + new BookingDto( + b.Id, b.RoomId, b.UserId, b.StartUtc, b.EndUtc, b.PriceTotal, + (AppBookingStatus)b.Status, + b.Participants.Select(p => new BookingParticipantDto(p.Id, p.Name, p.Email)).ToList()); + + public async Task GetAsync(Guid id, CancellationToken ct) + { + var b = await _db.Bookings.AsNoTracking() + .Include(x => x.Participants) + .FirstOrDefaultAsync(x => x.Id == id, ct); + return b is null ? null : Map(b); + } + + public async Task CreateAsync(BookingCreateDto dto, CancellationToken ct) + { + bool conflict = await _db.Bookings.AnyAsync(b => b.RoomId == dto.RoomId && + dto.StartUtc < b.EndUtc && dto.EndUtc > b.StartUtc && b.Status != DbBookingStatus.Cancelled, ct); + if (conflict) throw new InvalidOperationException("Time slot is busy"); + + var hours = (decimal)(dto.EndUtc - dto.StartUtc).TotalHours; + var room = await _db.Rooms.AsNoTracking().FirstAsync(r => r.Id == dto.RoomId, ct); + var price = Math.Round(hours * room.BasePricePerHour, 2); + + var b = new Booking { + RoomId = dto.RoomId, UserId = dto.UserId, StartUtc = dto.StartUtc, EndUtc = dto.EndUtc, + PriceTotal = price, Status = Persistence.BookingStatus.Pending + }; + if (dto.Participants is not null) + foreach (var p in dto.Participants) + b.Participants.Add(new BookingParticipant { Name = p.Name, Email = p.Email }); + + _db.Bookings.Add(b); + await _db.SaveChangesAsync(ct); + return Map(b); + } + + public async Task UpdateAsync(Guid id, BookingUpdateDto dto, CancellationToken ct) + { + var b = await _db.Bookings.FirstOrDefaultAsync(x => x.Id == id, ct); + if (b is null) return Result.Fail("not_found", "Booking not found"); + if (dto.StartUtc.HasValue) b.StartUtc = dto.StartUtc.Value; + if (dto.EndUtc.HasValue) b.EndUtc = dto.EndUtc.Value; + b.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + return Result.Success(); + } + + public async Task CancelAsync(Guid id, CancellationToken ct) + { + var b = await _db.Bookings.FirstOrDefaultAsync(x => x.Id == id, ct); + if (b is null) return Result.Fail("not_found", "Booking not found"); + b.Status = Persistence.BookingStatus.Cancelled; + await _db.SaveChangesAsync(ct); + return Result.Success(); + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Services/RoomService.cs b/CoworkingBooking.BookingService.Infrastructure/Services/RoomService.cs new file mode 100644 index 0000000..2612b8a --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Services/RoomService.cs @@ -0,0 +1,64 @@ +using BookingService.Application.Rooms; +using BookingService.Infrastructure.Persistence; +using CoreLib.Contracts; +using CoreLib.Results; +using Microsoft.EntityFrameworkCore; + +namespace BookingService.Infrastructure.Services; + +public interface IRoomService +{ + Task> GetAsync(int page, int pageSize, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task CreateAsync(RoomCreateDto dto, CancellationToken ct); + Task UpdateAsync(Guid id, RoomUpdateDto dto, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +public class RoomService : IRoomService +{ + private readonly BookingDbContext _db; + public RoomService(BookingDbContext db) => _db = db; + + public async Task> GetAsync(int page, int pageSize, CancellationToken ct) + { + var q = _db.Rooms.AsNoTracking().OrderBy(r => r.Name); + var total = await q.CountAsync(ct); + var items = await q.Skip((page-1)*pageSize).Take(pageSize) + .Select(r => new RoomDto(r.Id, r.Name, r.Capacity, r.BasePricePerHour, r.IsActive)) + .ToListAsync(ct); + return new PagedResponse(items, total, page, pageSize); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct) + { + var r = await _db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); + return r is null ? null : new RoomDto(r.Id, r.Name, r.Capacity, r.BasePricePerHour, r.IsActive); + } + + public async Task CreateAsync(RoomCreateDto dto, CancellationToken ct) + { + var e = new Room { Name = dto.Name, Capacity = dto.Capacity, BasePricePerHour = dto.BasePricePerHour, IsActive = dto.IsActive }; + _db.Rooms.Add(e); + await _db.SaveChangesAsync(ct); + return new RoomDto(e.Id, e.Name, e.Capacity, e.BasePricePerHour, e.IsActive); + } + + public async Task UpdateAsync(Guid id, RoomUpdateDto dto, CancellationToken ct) + { + var e = await _db.Rooms.FindAsync([id], ct); + if (e is null) return Result.Fail("not_found", "Room not found"); + e.Name = dto.Name; e.Capacity = dto.Capacity; e.BasePricePerHour = dto.BasePricePerHour; e.IsActive = dto.IsActive; + await _db.SaveChangesAsync(ct); + return Result.Success(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + var e = await _db.Rooms.FindAsync([id], ct); + if (e is null) return Result.Fail("not_found", "Room not found"); + _db.Rooms.Remove(e); + await _db.SaveChangesAsync(ct); + return Result.Success(); + } +} diff --git a/CoworkingBooking.BookingService.Infrastructure/Services/RuleService.cs b/CoworkingBooking.BookingService.Infrastructure/Services/RuleService.cs new file mode 100644 index 0000000..032f4ae --- /dev/null +++ b/CoworkingBooking.BookingService.Infrastructure/Services/RuleService.cs @@ -0,0 +1,60 @@ +using BookingService.Application.Rules; +using BookingService.Infrastructure.Persistence; +using CoreLib.Results; +using Microsoft.EntityFrameworkCore; + +namespace BookingService.Infrastructure.Services; // ← ВАЖНО: этот namespace + +public interface IRuleService +{ + Task> GetByRoomAsync(Guid roomId, CancellationToken ct); + Task CreateAsync(TimeSlotRuleCreateDto dto, CancellationToken ct); + Task UpdateAsync(Guid id, TimeSlotRuleUpdateDto dto, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +public class RuleService : IRuleService +{ + private readonly BookingDbContext _db; + public RuleService(BookingDbContext db) => _db = db; + + public async Task> GetByRoomAsync(Guid roomId, CancellationToken ct) + => await _db.TimeSlotRules.AsNoTracking().Where(x => x.RoomId == roomId) + .Select(x => new TimeSlotRuleDto(x.Id, x.RoomId, x.Weekday, x.StartTime.ToString(), x.EndTime.ToString(), x.MinDurationMin, x.MaxDurationMin)) + .ToListAsync(ct); + + public async Task CreateAsync(TimeSlotRuleCreateDto dto, CancellationToken ct) + { + var e = new TimeSlotRule { + RoomId = dto.RoomId, Weekday = dto.Weekday, + StartTime = TimeOnly.Parse(dto.StartTime), + EndTime = TimeOnly.Parse(dto.EndTime), + MinDurationMin = dto.MinDurationMin, MaxDurationMin = dto.MaxDurationMin + }; + _db.TimeSlotRules.Add(e); + await _db.SaveChangesAsync(ct); + return new TimeSlotRuleDto(e.Id, e.RoomId, e.Weekday, e.StartTime.ToString(), e.EndTime.ToString(), e.MinDurationMin, e.MaxDurationMin); + } + + public async Task UpdateAsync(Guid id, TimeSlotRuleUpdateDto dto, CancellationToken ct) + { + var e = await _db.TimeSlotRules.FindAsync([id], ct); + if (e is null) return Result.Fail("not_found", "Rule not found"); + e.Weekday = dto.Weekday; + e.StartTime = TimeOnly.Parse(dto.StartTime); + e.EndTime = TimeOnly.Parse(dto.EndTime); + e.MinDurationMin = dto.MinDurationMin; + e.MaxDurationMin = dto.MaxDurationMin; + await _db.SaveChangesAsync(ct); + return Result.Success(); + } + + public async Task DeleteAsync(Guid id, CancellationToken ct) + { + var e = await _db.TimeSlotRules.FindAsync([id], ct); + if (e is null) return Result.Fail("not_found", "Rule not found"); + _db.TimeSlotRules.Remove(e); + await _db.SaveChangesAsync(ct); + return Result.Success(); + } +} diff --git a/CoworkingBooking.CoreLib/Class1.cs b/CoworkingBooking.CoreLib/Class1.cs new file mode 100644 index 0000000..034beb6 --- /dev/null +++ b/CoworkingBooking.CoreLib/Class1.cs @@ -0,0 +1,6 @@ +namespace CoworkingBooking.CoreLib; + +public class Class1 +{ + +} diff --git a/CoworkingBooking.CoreLib/Contracts/PagedResponse.cs b/CoworkingBooking.CoreLib/Contracts/PagedResponse.cs new file mode 100644 index 0000000..e655030 --- /dev/null +++ b/CoworkingBooking.CoreLib/Contracts/PagedResponse.cs @@ -0,0 +1,3 @@ +namespace CoreLib.Contracts; + +public record PagedResponse(IReadOnlyList Items, int Total, int Page, int PageSize); diff --git a/CoworkingBooking.CoreLib/CoworkingBooking.CoreLib.csproj b/CoworkingBooking.CoreLib/CoworkingBooking.CoreLib.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/CoworkingBooking.CoreLib/CoworkingBooking.CoreLib.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/CoworkingBooking.CoreLib/Results/Result.cs b/CoworkingBooking.CoreLib/Results/Result.cs new file mode 100644 index 0000000..1861f29 --- /dev/null +++ b/CoworkingBooking.CoreLib/Results/Result.cs @@ -0,0 +1,20 @@ +namespace CoreLib.Results; + +public record Error(string Code, string Message); + +public class Result +{ + public bool IsSuccess { get; init; } + public Error? Error { get; init; } + + public static Result Success() => new() { IsSuccess = true }; + public static Result Fail(string code, string message) => new() { IsSuccess = false, Error = new Error(code, message) }; +} + +public class Result : Result +{ + public T? Value { get; init; } + + public static Result Success(T value) => new() { IsSuccess = true, Value = value }; + public new static Result Fail(string code, string message) => new() { IsSuccess = false, Error = new Error(code, message) }; +} diff --git a/CoworkingBooking.sln b/CoworkingBooking.sln new file mode 100644 index 0000000..b948b17 --- /dev/null +++ b/CoworkingBooking.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.CoreLib", "CoworkingBooking.CoreLib\CoworkingBooking.CoreLib.csproj", "{61123FD4-4466-4C82-A3AB-17390885B402}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.BookingService.Application", "CoworkingBooking.BookingService.Application\CoworkingBooking.BookingService.Application.csproj", "{0C0577C4-2724-49D4-9AE8-E827FFE855E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.BookingService.Infrastructure", "CoworkingBooking.BookingService.Infrastructure\CoworkingBooking.BookingService.Infrastructure.csproj", "{293A5393-86FB-4974-964F-3F746AA9AE0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.BookingService.Api", "CoworkingBooking.BookingService.Api\CoworkingBooking.BookingService.Api.csproj", "{5C12336E-4A2B-4746-85D3-566F4FFE8935}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {61123FD4-4466-4C82-A3AB-17390885B402}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61123FD4-4466-4C82-A3AB-17390885B402}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61123FD4-4466-4C82-A3AB-17390885B402}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61123FD4-4466-4C82-A3AB-17390885B402}.Release|Any CPU.Build.0 = Release|Any CPU + {0C0577C4-2724-49D4-9AE8-E827FFE855E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C0577C4-2724-49D4-9AE8-E827FFE855E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C0577C4-2724-49D4-9AE8-E827FFE855E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C0577C4-2724-49D4-9AE8-E827FFE855E8}.Release|Any CPU.Build.0 = Release|Any CPU + {293A5393-86FB-4974-964F-3F746AA9AE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {293A5393-86FB-4974-964F-3F746AA9AE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {293A5393-86FB-4974-964F-3F746AA9AE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {293A5393-86FB-4974-964F-3F746AA9AE0C}.Release|Any CPU.Build.0 = Release|Any CPU + {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index cbb7f17..567dcd2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -https://docs.google.com/document/d/1TPwnsXIzg2GqpulzFTTa_gpqMIPARD6FP_nER3hbO-k/edit?tab=t.0 - Сделано: -- 7 функциональных требований -- Перечень микросервисов и зоны ответственности -- System Design (схема) -- Первый сервис (Booking): сущности БД -- Таблица REST-эндпоинтов + +ASP.NET Core Minimal API + 3 слоя (Api, Application, Infrastructure, CoreLib) +PostgreSQL через docker-compose, EF Core миграции +Реализованы CRUD: Rooms, Rules; агрегатный CRUD: Bookings +Swagger: /swagger + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c6499b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.9" +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: booking + POSTGRES_PASSWORD: bookingpwd + POSTGRES_DB: bookingdb + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data +volumes: + pgdata: From 06c497abf99029a90824449cb485b63f09e93b3e Mon Sep 17 00:00:00 2001 From: Artyom Barkovsky Date: Wed, 8 Oct 2025 17:19:13 +0500 Subject: [PATCH 2/5] NotificationService --- CoworkingBooking.sln | 24 +++ .../Controllers/ChannelsController.cs | 32 ++++ .../Controllers/MessagesController.cs | 20 +++ .../Controllers/TemplatesController.cs | 32 ++++ .../NotificationService.Api.csproj | 26 +++ .../NotificationService.Api.http | 6 + NotificationService.Api/Program.cs | 26 +++ .../Properties/launchSettings.json | 41 +++++ NotificationService.Api/appsettings.json | 7 + .../Abstractions.cs | 27 +++ NotificationService.Application/Class1.cs | 6 + NotificationService.Application/Dto.cs | 15 ++ .../MappingProfile.cs | 14 ++ .../NotificationService.Application.csproj | 18 ++ NotificationService.Application/Validators.cs | 35 ++++ NotificationService.Domain/Class1.cs | 6 + NotificationService.Domain/Entities.cs | 44 +++++ .../NotificationService.Domain.csproj | 9 + NotificationService.Infrastructure/Class1.cs | 6 + .../DiExtensions.cs | 19 ++ .../20251008112229_InitNotify.Designer.cs | 162 ++++++++++++++++++ .../Migrations/20251008112229_InitNotify.cs | 116 +++++++++++++ .../NotifyDbContextModelSnapshot.cs | 159 +++++++++++++++++ .../NotificationService.Infrastructure.csproj | 22 +++ .../NotifyDbContext.cs | 20 +++ .../Services.cs | 98 +++++++++++ README.md | 9 +- 27 files changed, 996 insertions(+), 3 deletions(-) create mode 100644 NotificationService.Api/Controllers/ChannelsController.cs create mode 100644 NotificationService.Api/Controllers/MessagesController.cs create mode 100644 NotificationService.Api/Controllers/TemplatesController.cs create mode 100644 NotificationService.Api/NotificationService.Api.csproj create mode 100644 NotificationService.Api/NotificationService.Api.http create mode 100644 NotificationService.Api/Program.cs create mode 100644 NotificationService.Api/Properties/launchSettings.json create mode 100644 NotificationService.Api/appsettings.json create mode 100644 NotificationService.Application/Abstractions.cs create mode 100644 NotificationService.Application/Class1.cs create mode 100644 NotificationService.Application/Dto.cs create mode 100644 NotificationService.Application/MappingProfile.cs create mode 100644 NotificationService.Application/NotificationService.Application.csproj create mode 100644 NotificationService.Application/Validators.cs create mode 100644 NotificationService.Domain/Class1.cs create mode 100644 NotificationService.Domain/Entities.cs create mode 100644 NotificationService.Domain/NotificationService.Domain.csproj create mode 100644 NotificationService.Infrastructure/Class1.cs create mode 100644 NotificationService.Infrastructure/DiExtensions.cs create mode 100644 NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.Designer.cs create mode 100644 NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.cs create mode 100644 NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs create mode 100644 NotificationService.Infrastructure/NotificationService.Infrastructure.csproj create mode 100644 NotificationService.Infrastructure/NotifyDbContext.cs create mode 100644 NotificationService.Infrastructure/Services.cs diff --git a/CoworkingBooking.sln b/CoworkingBooking.sln index b948b17..de22924 100644 --- a/CoworkingBooking.sln +++ b/CoworkingBooking.sln @@ -11,6 +11,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.BookingSer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CoworkingBooking.BookingService.Api", "CoworkingBooking.BookingService.Api\CoworkingBooking.BookingService.Api.csproj", "{5C12336E-4A2B-4746-85D3-566F4FFE8935}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotificationService.Domain", "NotificationService.Domain\NotificationService.Domain.csproj", "{097FB2C4-0013-441A-86D1-21BE13482308}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotificationService.Application", "NotificationService.Application\NotificationService.Application.csproj", "{316A8F97-3CC2-41D1-9B15-A68965DFF089}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotificationService.Infrastructure", "NotificationService.Infrastructure\NotificationService.Infrastructure.csproj", "{550319AF-3300-4DE7-8F3A-CBED2FC7884B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NotificationService.Api", "NotificationService.Api\NotificationService.Api.csproj", "{ECBB477E-62F6-4D10-BC6F-3E22EE81DF4E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +44,21 @@ Global {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Debug|Any CPU.Build.0 = Debug|Any CPU {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Release|Any CPU.ActiveCfg = Release|Any CPU {5C12336E-4A2B-4746-85D3-566F4FFE8935}.Release|Any CPU.Build.0 = Release|Any CPU + {097FB2C4-0013-441A-86D1-21BE13482308}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {097FB2C4-0013-441A-86D1-21BE13482308}.Debug|Any CPU.Build.0 = Debug|Any CPU + {097FB2C4-0013-441A-86D1-21BE13482308}.Release|Any CPU.ActiveCfg = Release|Any CPU + {097FB2C4-0013-441A-86D1-21BE13482308}.Release|Any CPU.Build.0 = Release|Any CPU + {316A8F97-3CC2-41D1-9B15-A68965DFF089}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {316A8F97-3CC2-41D1-9B15-A68965DFF089}.Debug|Any CPU.Build.0 = Debug|Any CPU + {316A8F97-3CC2-41D1-9B15-A68965DFF089}.Release|Any CPU.ActiveCfg = Release|Any CPU + {316A8F97-3CC2-41D1-9B15-A68965DFF089}.Release|Any CPU.Build.0 = Release|Any CPU + {550319AF-3300-4DE7-8F3A-CBED2FC7884B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {550319AF-3300-4DE7-8F3A-CBED2FC7884B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {550319AF-3300-4DE7-8F3A-CBED2FC7884B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {550319AF-3300-4DE7-8F3A-CBED2FC7884B}.Release|Any CPU.Build.0 = Release|Any CPU + {ECBB477E-62F6-4D10-BC6F-3E22EE81DF4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECBB477E-62F6-4D10-BC6F-3E22EE81DF4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECBB477E-62F6-4D10-BC6F-3E22EE81DF4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECBB477E-62F6-4D10-BC6F-3E22EE81DF4E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/NotificationService.Api/Controllers/ChannelsController.cs b/NotificationService.Api/Controllers/ChannelsController.cs new file mode 100644 index 0000000..c585493 --- /dev/null +++ b/NotificationService.Api/Controllers/ChannelsController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using NotificationService.Application; + +namespace NotificationService.Api.Controllers; + +[ApiController] +[Route("api/notify/v1/channels")] +public class ChannelsController(IChannelService svc) : ControllerBase +{ + [HttpGet] + public async Task Get(int page = 1, int pageSize = 20, CancellationToken ct = default) => + Ok(await svc.GetAsync(page, pageSize, ct)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken ct) => + (await svc.GetByIdAsync(id, ct)) is { } x ? Ok(x) : NotFound(); + + [HttpPost] + public async Task Create(ChannelCreateDto dto, CancellationToken ct) + { + var created = await svc.CreateAsync(dto, ct); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, ChannelUpdateDto dto, CancellationToken ct) => + await svc.UpdateAsync(id, dto, ct) ? NoContent() : NotFound(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) => + await svc.DeleteAsync(id, ct) ? NoContent() : NotFound(); +} diff --git a/NotificationService.Api/Controllers/MessagesController.cs b/NotificationService.Api/Controllers/MessagesController.cs new file mode 100644 index 0000000..50c530c --- /dev/null +++ b/NotificationService.Api/Controllers/MessagesController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; +using NotificationService.Application; + +namespace NotificationService.Api.Controllers; + +[ApiController] +[Route("api/notify/v1/messages")] +public class MessagesController(IMessageService svc) : ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) => + (await svc.GetAsync(id, ct)) is { } x ? Ok(x) : NotFound(); + + [HttpPost] + public async Task Enqueue(MessageCreateDto dto, CancellationToken ct) + { + var created = await svc.EnqueueAsync(dto, ct); + return CreatedAtAction(nameof(Get), new { id = created.Id }, created); + } +} diff --git a/NotificationService.Api/Controllers/TemplatesController.cs b/NotificationService.Api/Controllers/TemplatesController.cs new file mode 100644 index 0000000..675a654 --- /dev/null +++ b/NotificationService.Api/Controllers/TemplatesController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using NotificationService.Application; + +namespace NotificationService.Api.Controllers; + +[ApiController] +[Route("api/notify/v1/templates")] +public class TemplatesController(ITemplateService svc) : ControllerBase +{ + [HttpGet] + public async Task Get(int page = 1, int pageSize = 20, CancellationToken ct = default) => + Ok(await svc.GetAsync(page, pageSize, ct)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken ct) => + (await svc.GetByIdAsync(id, ct)) is { } x ? Ok(x) : NotFound(); + + [HttpPost] + public async Task Create(TemplateCreateDto dto, CancellationToken ct) + { + var created = await svc.CreateAsync(dto, ct); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, TemplateUpdateDto dto, CancellationToken ct) => + await svc.UpdateAsync(id, dto, ct) ? NoContent() : NotFound(); + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) => + await svc.DeleteAsync(id, ct) ? NoContent() : NotFound(); +} diff --git a/NotificationService.Api/NotificationService.Api.csproj b/NotificationService.Api/NotificationService.Api.csproj new file mode 100644 index 0000000..c76fbed --- /dev/null +++ b/NotificationService.Api/NotificationService.Api.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/NotificationService.Api/NotificationService.Api.http b/NotificationService.Api/NotificationService.Api.http new file mode 100644 index 0000000..83d11a8 --- /dev/null +++ b/NotificationService.Api/NotificationService.Api.http @@ -0,0 +1,6 @@ +@NotificationService.Api_HostAddress = http://localhost:5212 + +GET {{NotificationService.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/NotificationService.Api/Program.cs b/NotificationService.Api/Program.cs new file mode 100644 index 0000000..97bbc4a --- /dev/null +++ b/NotificationService.Api/Program.cs @@ -0,0 +1,26 @@ +using AutoMapper; +using FluentValidation; +using FluentValidation.AspNetCore; +using NotificationService.Application; +using NotificationService.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddControllers(); +builder.Services.AddHealthChecks(); + +builder.Services.AddAutoMapper(typeof(MappingProfile)); +builder.Services.AddFluentValidationAutoValidation(); +builder.Services.AddValidatorsFromAssemblyContaining(); + +builder.Services.AddNotificationInfrastructure(builder.Configuration); + +var app = builder.Build(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.MapControllers(); +app.MapHealthChecks("/health"); +app.MapGet("/", () => Results.Redirect("/swagger")); +app.Run(); diff --git a/NotificationService.Api/Properties/launchSettings.json b/NotificationService.Api/Properties/launchSettings.json new file mode 100644 index 0000000..0251c9a --- /dev/null +++ b/NotificationService.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:4313", + "sslPort": 44387 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7231;http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/NotificationService.Api/appsettings.json b/NotificationService.Api/appsettings.json new file mode 100644 index 0000000..6015024 --- /dev/null +++ b/NotificationService.Api/appsettings.json @@ -0,0 +1,7 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=notifydb;Username=booking;Password=bookingpwd" + }, + "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, + "AllowedHosts": "*" +} diff --git a/NotificationService.Application/Abstractions.cs b/NotificationService.Application/Abstractions.cs new file mode 100644 index 0000000..39e9e0a --- /dev/null +++ b/NotificationService.Application/Abstractions.cs @@ -0,0 +1,27 @@ +using NotificationService.Domain; + +namespace NotificationService.Application; + +public interface IChannelService +{ + Task> GetAsync(int page, int pageSize, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task CreateAsync(ChannelCreateDto dto, CancellationToken ct); + Task UpdateAsync(Guid id, ChannelUpdateDto dto, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +public interface ITemplateService +{ + Task> GetAsync(int page, int pageSize, CancellationToken ct); + Task GetByIdAsync(Guid id, CancellationToken ct); + Task CreateAsync(TemplateCreateDto dto, CancellationToken ct); + Task UpdateAsync(Guid id, TemplateUpdateDto dto, CancellationToken ct); + Task DeleteAsync(Guid id, CancellationToken ct); +} + +public interface IMessageService +{ + Task GetAsync(Guid id, CancellationToken ct); + Task EnqueueAsync(MessageCreateDto dto, CancellationToken ct); +} diff --git a/NotificationService.Application/Class1.cs b/NotificationService.Application/Class1.cs new file mode 100644 index 0000000..c39ceb4 --- /dev/null +++ b/NotificationService.Application/Class1.cs @@ -0,0 +1,6 @@ +namespace NotificationService.Application; + +public class Class1 +{ + +} diff --git a/NotificationService.Application/Dto.cs b/NotificationService.Application/Dto.cs new file mode 100644 index 0000000..3da394b --- /dev/null +++ b/NotificationService.Application/Dto.cs @@ -0,0 +1,15 @@ +using NotificationService.Domain; + +namespace NotificationService.Application; + +public record ChannelDto(Guid Id, string Name, ChannelType Type, bool IsActive, string ConfigJson); +public record ChannelCreateDto(string Name, ChannelType Type, string ConfigJson, bool IsActive); +public record ChannelUpdateDto(string Name, ChannelType Type, string ConfigJson, bool IsActive); + +public record TemplateDto(Guid Id, string Key, ChannelType ChannelType, string? Subject, string Body, bool IsActive); +public record TemplateCreateDto(string Key, ChannelType ChannelType, string? Subject, string Body, bool IsActive); +public record TemplateUpdateDto(string Key, ChannelType ChannelType, string? Subject, string Body, bool IsActive); + +public record MessageDto(Guid Id, Guid ChannelId, Guid TemplateId, string To, string? Cc, + string PayloadJson, MessageStatus Status, string? Error, DateTime CreatedAt, DateTime? SentAt); +public record MessageCreateDto(Guid ChannelId, Guid TemplateId, string To, string? Cc, string PayloadJson); diff --git a/NotificationService.Application/MappingProfile.cs b/NotificationService.Application/MappingProfile.cs new file mode 100644 index 0000000..877e962 --- /dev/null +++ b/NotificationService.Application/MappingProfile.cs @@ -0,0 +1,14 @@ +using AutoMapper; +using NotificationService.Domain; + +namespace NotificationService.Application; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + CreateMap(); + CreateMap(); + } +} diff --git a/NotificationService.Application/NotificationService.Application.csproj b/NotificationService.Application/NotificationService.Application.csproj new file mode 100644 index 0000000..3a942eb --- /dev/null +++ b/NotificationService.Application/NotificationService.Application.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/NotificationService.Application/Validators.cs b/NotificationService.Application/Validators.cs new file mode 100644 index 0000000..1b8259c --- /dev/null +++ b/NotificationService.Application/Validators.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using NotificationService.Domain; + +namespace NotificationService.Application; + +public class ChannelCreateValidator : AbstractValidator +{ + public ChannelCreateValidator() + { + RuleFor(x => x.Name).NotEmpty().MaximumLength(100); + RuleFor(x => x.ConfigJson).NotEmpty(); + RuleFor(x => x.Type).IsInEnum(); + } +} + +public class TemplateCreateValidator : AbstractValidator +{ + public TemplateCreateValidator() + { + RuleFor(x => x.Key).NotEmpty().MaximumLength(100); + RuleFor(x => x.Body).NotEmpty(); + RuleFor(x => x.ChannelType).IsInEnum(); + } +} + +public class MessageCreateValidator : AbstractValidator +{ + public MessageCreateValidator() + { + RuleFor(x => x.ChannelId).NotEmpty(); + RuleFor(x => x.TemplateId).NotEmpty(); + RuleFor(x => x.To).NotEmpty().MaximumLength(200); + RuleFor(x => x.PayloadJson).NotEmpty(); + } +} diff --git a/NotificationService.Domain/Class1.cs b/NotificationService.Domain/Class1.cs new file mode 100644 index 0000000..47a5a22 --- /dev/null +++ b/NotificationService.Domain/Class1.cs @@ -0,0 +1,6 @@ +namespace NotificationService.Domain; + +public class Class1 +{ + +} diff --git a/NotificationService.Domain/Entities.cs b/NotificationService.Domain/Entities.cs new file mode 100644 index 0000000..ab9c38d --- /dev/null +++ b/NotificationService.Domain/Entities.cs @@ -0,0 +1,44 @@ +namespace NotificationService.Domain; + +public enum ChannelType { Email = 1, Sms = 2, Webhook = 3 } +public enum MessageStatus { Queued = 0, Sent = 1, Failed = 2 } + +public class Channel +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public ChannelType Type { get; set; } + public string ConfigJson { get; set; } = "{}"; + public bool IsActive { get; set; } = true; + + public ICollection Messages { get; set; } = new List(); +} + +public class Template +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Key { get; set; } = string.Empty; // "BookingCreated" + public ChannelType ChannelType { get; set; } + public string? Subject { get; set; } // для Email + public string Body { get; set; } = string.Empty; // с плейсхолдерами + public bool IsActive { get; set; } = true; + + public ICollection Messages { get; set; } = new List(); +} + +public class Message +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid ChannelId { get; set; } + public Channel Channel { get; set; } = null!; + public Guid TemplateId { get; set; } + public Template Template { get; set; } = null!; + + public string To { get; set; } = string.Empty; // email/phone/url + public string? Cc { get; set; } + public string PayloadJson { get; set; } = "{}"; // данные для подстановки + public MessageStatus Status { get; set; } = MessageStatus.Queued; + public string? Error { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? SentAt { get; set; } +} diff --git a/NotificationService.Domain/NotificationService.Domain.csproj b/NotificationService.Domain/NotificationService.Domain.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/NotificationService.Domain/NotificationService.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/NotificationService.Infrastructure/Class1.cs b/NotificationService.Infrastructure/Class1.cs new file mode 100644 index 0000000..ab2e3d8 --- /dev/null +++ b/NotificationService.Infrastructure/Class1.cs @@ -0,0 +1,6 @@ +namespace NotificationService.Infrastructure; + +public class Class1 +{ + +} diff --git a/NotificationService.Infrastructure/DiExtensions.cs b/NotificationService.Infrastructure/DiExtensions.cs new file mode 100644 index 0000000..468f54c --- /dev/null +++ b/NotificationService.Infrastructure/DiExtensions.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NotificationService.Application; + +namespace NotificationService.Infrastructure; + +public static class DiExtensions +{ + public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration cfg) + { + services.AddDbContext(opt => + opt.UseNpgsql(cfg.GetConnectionString("Postgres"))); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.Designer.cs b/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.Designer.cs new file mode 100644 index 0000000..1d3373e --- /dev/null +++ b/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.Designer.cs @@ -0,0 +1,162 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NotificationService.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NotificationService.Infrastructure.Migrations +{ + [DbContext(typeof(NotifyDbContext))] + [Migration("20251008112229_InitNotify")] + partial class InitNotify + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cc") + .HasColumnType("text"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChannelType") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "ChannelType"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.HasOne("NotificationService.Domain.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NotificationService.Domain.Template", "Template") + .WithMany("Messages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.cs b/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.cs new file mode 100644 index 0000000..fd13351 --- /dev/null +++ b/NotificationService.Infrastructure/Migrations/20251008112229_InitNotify.cs @@ -0,0 +1,116 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NotificationService.Infrastructure.Migrations +{ + /// + public partial class InitNotify : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Channels", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Type = table.Column(type: "integer", nullable: false), + ConfigJson = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Channels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Templates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "text", nullable: false), + ChannelType = table.Column(type: "integer", nullable: false), + Subject = table.Column(type: "text", nullable: true), + Body = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Templates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + TemplateId = table.Column(type: "uuid", nullable: false), + To = table.Column(type: "text", nullable: false), + Cc = table.Column(type: "text", nullable: true), + PayloadJson = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + Error = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + SentAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Messages_Templates_TemplateId", + column: x => x.TemplateId, + principalTable: "Templates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_Type_IsActive", + table: "Channels", + columns: new[] { "Type", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_ChannelId", + table: "Messages", + column: "ChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_Status_CreatedAt", + table: "Messages", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_TemplateId", + table: "Messages", + column: "TemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Templates_Key_ChannelType", + table: "Templates", + columns: new[] { "Key", "ChannelType" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "Channels"); + + migrationBuilder.DropTable( + name: "Templates"); + } + } +} diff --git a/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs b/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs new file mode 100644 index 0000000..edb579f --- /dev/null +++ b/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs @@ -0,0 +1,159 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NotificationService.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NotificationService.Infrastructure.Migrations +{ + [DbContext(typeof(NotifyDbContext))] + partial class NotifyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cc") + .HasColumnType("text"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChannelType") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "ChannelType"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.HasOne("NotificationService.Domain.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NotificationService.Domain.Template", "Template") + .WithMany("Messages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj b/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj new file mode 100644 index 0000000..0a748b5 --- /dev/null +++ b/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net8.0 + enable + enable + + + diff --git a/NotificationService.Infrastructure/NotifyDbContext.cs b/NotificationService.Infrastructure/NotifyDbContext.cs new file mode 100644 index 0000000..dfb1f99 --- /dev/null +++ b/NotificationService.Infrastructure/NotifyDbContext.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using NotificationService.Domain; + +namespace NotificationService.Infrastructure; + +public class NotifyDbContext : DbContext +{ + public NotifyDbContext(DbContextOptions options) : base(options) { } + + public DbSet Channels => Set(); + public DbSet