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..de22924
--- /dev/null
+++ b/CoworkingBooking.sln
@@ -0,0 +1,64 @@
+
+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
+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
+ 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
+ {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 Templates => Set();
+ public DbSet Messages => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity().HasIndex(x => new { x.Type, x.IsActive });
+ modelBuilder.Entity().HasIndex(x => new { x.Key, x.ChannelType }).IsUnique(false);
+ modelBuilder.Entity().HasIndex(x => new { x.Status, x.CreatedAt });
+ }
+}
diff --git a/NotificationService.Infrastructure/Services.cs b/NotificationService.Infrastructure/Services.cs
new file mode 100644
index 0000000..134f8d0
--- /dev/null
+++ b/NotificationService.Infrastructure/Services.cs
@@ -0,0 +1,98 @@
+using AutoMapper;
+using AutoMapper.QueryableExtensions;
+using Microsoft.EntityFrameworkCore;
+using NotificationService.Application;
+using NotificationService.Domain;
+
+namespace NotificationService.Infrastructure;
+
+public class ChannelService : IChannelService
+{
+ private readonly NotifyDbContext _db;
+ private readonly IMapper _mapper;
+
+ public ChannelService(NotifyDbContext db, IMapper mapper) { _db = db; _mapper = mapper; }
+
+ public async Task> GetAsync(int page, int pageSize, CancellationToken ct) =>
+ await _db.Channels.AsNoTracking().OrderBy(x => x.Name)
+ .Skip((page-1)*pageSize).Take(pageSize)
+ .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(ct);
+
+ public async Task GetByIdAsync(Guid id, CancellationToken ct) =>
+ await _db.Channels.AsNoTracking().Where(x => x.Id == id)
+ .ProjectTo(_mapper.ConfigurationProvider).FirstOrDefaultAsync(ct);
+
+ public async Task CreateAsync(ChannelCreateDto dto, CancellationToken ct)
+ {
+ var e = new Channel { Name = dto.Name, Type = dto.Type, ConfigJson = dto.ConfigJson, IsActive = dto.IsActive };
+ _db.Channels.Add(e); await _db.SaveChangesAsync(ct);
+ return _mapper.Map(e);
+ }
+
+ public async Task UpdateAsync(Guid id, ChannelUpdateDto dto, CancellationToken ct)
+ {
+ var e = await _db.Channels.FindAsync([id], ct); if (e is null) return false;
+ e.Name = dto.Name; e.Type = dto.Type; e.ConfigJson = dto.ConfigJson; e.IsActive = dto.IsActive;
+ await _db.SaveChangesAsync(ct); return true;
+ }
+
+ public async Task