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/Contracts/BookingMessages.cs b/CoworkingBooking.BookingService.Api/Contracts/BookingMessages.cs
new file mode 100644
index 0000000..f7f31c5
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Contracts/BookingMessages.cs
@@ -0,0 +1,20 @@
+// CoworkingBooking.BookingService.Api/Contracts/BookingMessages.cs
+namespace CoworkingBooking.BookingService.Api.Contracts;
+
+///
+/// Команда: старт процесса бронирования (запуск саги)
+///
+public sealed record StartBooking(
+ Guid BookingId,
+ Guid RoomId,
+ string UserEmail,
+ string UserName
+);
+
+///
+/// Событие: комната успешно зарезервирована (переход саги на следующий шаг)
+///
+public sealed record RoomReserved(
+ Guid BookingId,
+ string ReservationId
+);
diff --git a/CoworkingBooking.BookingService.Api/Coordinator/Consumers/ReserveRoomConsumer.cs b/CoworkingBooking.BookingService.Api/Coordinator/Consumers/ReserveRoomConsumer.cs
new file mode 100644
index 0000000..49f65f6
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Coordinator/Consumers/ReserveRoomConsumer.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Threading.Tasks;
+using MassTransit;
+using CoworkingBooking.BookingService.Api.Coordinator;
+using CoworkingBooking.BookingService.Api.Coordinator.Status;
+
+namespace CoworkingBooking.BookingService.Api.Coordinator.Consumers;
+
+public class ReserveRoomConsumer : IConsumer
+{
+ private readonly ICoordStatusStore _status;
+
+ public ReserveRoomConsumer(ICoordStatusStore status) => _status = status;
+
+ public async Task Consume(ConsumeContext ctx)
+ {
+ var msg = ctx.Message;
+
+ var reservationId = $"R-{Random.Shared.Next(100000, 999999)}";
+
+ _status.Set(msg.BookingId, $"RoomReserved:{reservationId}");
+
+ await ctx.Publish(new RoomReserved(msg.BookingId, reservationId));
+ }
+}
diff --git a/CoworkingBooking.BookingService.Api/Coordinator/Consumers/SendNotifyConsumer.cs b/CoworkingBooking.BookingService.Api/Coordinator/Consumers/SendNotifyConsumer.cs
new file mode 100644
index 0000000..353426c
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Coordinator/Consumers/SendNotifyConsumer.cs
@@ -0,0 +1,28 @@
+using System.Threading.Tasks;
+using MassTransit;
+using CoworkingBooking.BookingService.Api.Coordinator;
+using CoworkingBooking.BookingService.Api.Coordinator.Status;
+using CoworkingBooking.BookingService.Infrastructure.Integration;
+
+namespace CoworkingBooking.BookingService.Api.Coordinator.Consumers;
+
+public class SendNotifyConsumer : IConsumer
+{
+ private readonly INotificationClient _notify;
+ private readonly ICoordStatusStore _status;
+
+ public SendNotifyConsumer(INotificationClient notify, ICoordStatusStore status)
+ {
+ _notify = notify;
+ _status = status;
+ }
+
+ public async Task Consume(ConsumeContext ctx)
+ {
+ await _notify.PingAsync();
+
+ _status.Set(ctx.Message.BookingId, "Notified");
+
+ await ctx.Publish(new NotifySent(ctx.Message.BookingId));
+ }
+}
diff --git a/CoworkingBooking.BookingService.Api/Coordinator/Messages.cs b/CoworkingBooking.BookingService.Api/Coordinator/Messages.cs
new file mode 100644
index 0000000..91a3aa5
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Coordinator/Messages.cs
@@ -0,0 +1,8 @@
+using System;
+
+namespace CoworkingBooking.BookingService.Api.Coordinator;
+
+// События/команды для варианта "координатор" (choreography)
+public record BookingRequested(Guid BookingId, Guid RoomId, string UserEmail, string UserName);
+public record RoomReserved(Guid BookingId, string ReservationId);
+public record NotifySent(Guid BookingId);
diff --git a/CoworkingBooking.BookingService.Api/Coordinator/Status/ICoordStatusStore.cs b/CoworkingBooking.BookingService.Api/Coordinator/Status/ICoordStatusStore.cs
new file mode 100644
index 0000000..89eaaca
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Coordinator/Status/ICoordStatusStore.cs
@@ -0,0 +1,9 @@
+using System;
+
+namespace CoworkingBooking.BookingService.Api.Coordinator.Status;
+
+public interface ICoordStatusStore
+{
+ void Set(Guid bookingId, string status);
+ bool TryGet(Guid bookingId, out string status);
+}
diff --git a/CoworkingBooking.BookingService.Api/Coordinator/Status/InMemoryCoordStatusStore.cs b/CoworkingBooking.BookingService.Api/Coordinator/Status/InMemoryCoordStatusStore.cs
new file mode 100644
index 0000000..ab6d321
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Coordinator/Status/InMemoryCoordStatusStore.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Concurrent;
+
+namespace CoworkingBooking.BookingService.Api.Coordinator.Status;
+
+public class InMemoryCoordStatusStore : ICoordStatusStore
+{
+ private readonly ConcurrentDictionary _store = new();
+
+ public void Set(Guid bookingId, string status) => _store[bookingId] = status;
+
+ public bool TryGet(Guid bookingId, out string status) => _store.TryGetValue(bookingId, out status!);
+}
diff --git a/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj b/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj
new file mode 100644
index 0000000..f54fe47
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/CoworkingBooking.BookingService.Api.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/CoworkingBooking.BookingService.Api/Orchestrator/ReserveRoomActivity.cs b/CoworkingBooking.BookingService.Api/Orchestrator/ReserveRoomActivity.cs
new file mode 100644
index 0000000..f0b96c0
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Orchestrator/ReserveRoomActivity.cs
@@ -0,0 +1,21 @@
+using MassTransit;
+using CoworkingBooking.CoreLib.Messaging;
+
+namespace BookingService.Api.Orchestrator;
+
+public class ReserveRoomActivity : IActivity
+{
+ public async Task Execute(ExecuteContext context)
+ {
+ // имитация проверок и резервирования
+ await Task.Delay(50);
+ return context.Completed(new ReserveRoomLog(context.Arguments.RoomId));
+ }
+
+ public async Task Compensate(CompensateContext context)
+ {
+ // откат резервирования
+ await Task.Delay(10);
+ return context.Compensated();
+ }
+}
diff --git a/CoworkingBooking.BookingService.Api/Program.cs b/CoworkingBooking.BookingService.Api/Program.cs
new file mode 100644
index 0000000..be57102
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Program.cs
@@ -0,0 +1,264 @@
+using BookingService.Application.Bookings;
+using BookingService.Application.Rooms;
+using BookingService.Application.Rules;
+using BookingService.Infrastructure.Persistence;
+using BookingService.Infrastructure.Services;
+using CoworkingBooking.BookingService.Infrastructure.Integration;
+using CoworkingBooking.CoreLib.Http;
+using CoworkingBooking.CoreLib.Tracing;
+using MassTransit;
+using Microsoft.EntityFrameworkCore;
+using CoworkingBooking.BookingService.Api.Contracts;
+using CoworkingBooking.BookingService.Api.Saga;
+using CoworkingBooking.BookingService.Api.Coordinator;
+using CoworkingBooking.BookingService.Api.Coordinator.Status;
+using CoworkingBooking.BookingService.Api.Coordinator.Consumers;
+using CoordRoomReserved = CoworkingBooking.BookingService.Api.Coordinator.RoomReserved;
+using ContractsRoomReserved = CoworkingBooking.BookingService.Api.Contracts.RoomReserved;
+using ContractsStartBooking = CoworkingBooking.BookingService.Api.Contracts.StartBooking;
+using Microsoft.AspNetCore.OpenApi;
+using CoworkingBooking.CoreLib.Distributed;
+using CoworkingBooking.BookingService.Infrastructure.Distributed;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddSingleton();
+
+// Swagger
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen();
+
+// EF Core
+builder.Services.AddDbContext(opt =>
+ opt.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
+
+// DI приложений
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+// общий HttpClient с трейсингом/политиками и интеграция с Notification
+builder.Services.AddCoreHttpService();
+builder.Services.AddNotificationIntegration(builder.Configuration);
+builder.Services.AddRedisSemaphore(builder.Configuration);
+
+// MassTransit + InMemory транспорт + Saga + Consumer
+builder.Services.AddMassTransit(cfg =>
+{
+ cfg.SetKebabCaseEndpointNameFormatter();
+
+ // State Machine + хранилище состояний в памяти
+ cfg.AddSagaStateMachine()
+ .InMemoryRepository();
+
+ // Командный consumer (резервация комнаты)
+ cfg.AddConsumer();
+
+ cfg.AddConsumer();
+ cfg.AddConsumer();
+
+ // Транспорт InMemory
+ cfg.UsingInMemory((context, bus) =>
+ {
+ // политика повторов на транспортном уровне (легкая защита)
+ bus.UseMessageRetry(r => r.Immediate(3));
+
+ // Автоматически создаёт receive-эндпойнты под сагу/консьюмеров
+ bus.ConfigureEndpoints(context);
+ });
+});
+
+var app = builder.Build();
+
+// Swagger UI
+app.UseSwagger();
+app.UseSwaggerUI();
+
+// Корреляция/трассировка (ДЗ-4)
+app.UseTraceId();
+
+// 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();
+ });
+
+// Integrations (Notify)
+app.MapGet("/api/booking/v1/integrations/notify/ping",
+ async (INotificationClient client, CancellationToken ct) =>
+ (await client.PingAsync(ct)) ? Results.Ok() : Results.StatusCode(503));
+
+app.MapGet("/api/booking/v1/integrations/notify/ping-raw",
+ async (INotificationClient client, CancellationToken ct) =>
+ {
+ try
+ {
+ var raw = await client.PingRawAsync(ct);
+ return Results.Content(raw, "application/json");
+ }
+ catch
+ {
+ return Results.StatusCode(503);
+ }
+ });
+
+// /info
+app.MapGet("/api/booking/v1/integrations/notify/info",
+ async (INotificationClient client, CancellationToken ct) =>
+ {
+ var data = await client.GetInfoAsync(ct);
+ return data is null ? Results.StatusCode(503) : Results.Ok(data);
+ });
+
+app.MapPost("/api/booking/v1/saga/start",
+ async (ContractsStartBooking dto, IPublishEndpoint publish, CancellationToken ct) =>
+{
+ await publish.Publish(dto, ct);
+ return Results.Accepted($"/api/booking/v1/saga/{dto.BookingId}");
+});
+
+app.MapPost("/api/booking/v1/saga/room-reserved",
+ async (ContractsRoomReserved dto, IPublishEndpoint publish, CancellationToken ct) =>
+{
+ await publish.Publish(dto, ct);
+ return Results.Accepted($"/api/booking/v1/saga/{dto.BookingId}");
+});
+
+app.MapGet("/api/booking/v1/saga/{id:guid}", (Guid id) =>
+{
+ return Results.Text("not implemented (add read model or repository query)");
+});
+// COORD (Choreography) API
+
+// Старт процесса без центрального оркестратора
+app.MapPost("/api/booking/v1/coord/start", async (BookingRequested dto, IBus bus, ICoordStatusStore store) =>
+{
+ store.Set(dto.BookingId, "Requested");
+ await bus.Publish(dto);
+ return Results.Accepted($"/api/booking/v1/coord/{dto.BookingId}");
+})
+.WithTags("Coord")
+.WithOpenApi();
+
+// Получить текущий статус (берём из in-memory стора)
+app.MapGet("/api/booking/v1/coord/{id:guid}", (Guid id, ICoordStatusStore store) =>
+{
+ return store.TryGet(id, out var status)
+ ? Results.Ok(new { id, status })
+ : Results.NotFound(new { id, status = "unknown" });
+})
+.WithTags("Coord")
+.WithOpenApi();
+
+app.MapPost("/api/sem/v1/acquire", async (
+ string name,
+ int maxPermits,
+ int leaseSeconds,
+ IDistributedSemaphore sem,
+ CancellationToken ct) =>
+{
+ var token = await sem.TryAcquireAsync(name, maxPermits, TimeSpan.FromSeconds(leaseSeconds), ct);
+ return token is null
+ ? Results.StatusCode(409) // нет свободных разрешений
+ : Results.Ok(new { token });
+});
+
+app.MapPost("/api/sem/v1/release", async (
+ string name,
+ string token,
+ IDistributedSemaphore sem,
+ CancellationToken ct) =>
+{
+ var ok = await sem.ReleaseAsync(name, token, ct);
+ return ok ? Results.NoContent() : Results.NotFound();
+});
+
+app.MapPost("/api/sem/v1/renew", async (
+ string name,
+ string token,
+ int leaseSeconds,
+ IDistributedSemaphore sem,
+ CancellationToken ct) =>
+{
+ var ok = await sem.RenewAsync(name, token, TimeSpan.FromSeconds(leaseSeconds), ct);
+ return ok ? Results.Ok() : 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/Saga/BookingState.cs b/CoworkingBooking.BookingService.Api/Saga/BookingState.cs
new file mode 100644
index 0000000..3d3f498
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Saga/BookingState.cs
@@ -0,0 +1,11 @@
+using MassTransit;
+
+namespace CoworkingBooking.BookingService.Api.Saga;
+
+public class BookingState : SagaStateMachineInstance
+{
+ public Guid CorrelationId { get; set; }
+ public string CurrentState { get; set; } = default!;
+ public Guid RoomId { get; set; }
+ public string? UserEmail { get; set; }
+}
diff --git a/CoworkingBooking.BookingService.Api/Saga/BookingStateMachine.cs b/CoworkingBooking.BookingService.Api/Saga/BookingStateMachine.cs
new file mode 100644
index 0000000..673388e
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Saga/BookingStateMachine.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Threading.Tasks;
+using MassTransit;
+using CoworkingBooking.BookingService.Api.Saga.Contracts;
+
+namespace CoworkingBooking.BookingService.Api.Saga;
+
+public class BookingStateMachine : MassTransitStateMachine
+{
+ public State Reserving { get; private set; } = default!;
+ public State Notifying { get; private set; } = default!;
+
+ public Event Started { get; private set; } = default!;
+ public Event Reserved { get; private set; } = default!;
+
+ public BookingStateMachine()
+ {
+ InstanceState(x => x.CurrentState);
+
+ Event(() => Started, x =>
+ {
+ x.CorrelateById(ctx => ctx.Message.CorrelationId);
+ x.InsertOnInitial = true;
+ });
+
+ Event(() => Reserved, x =>
+ {
+ x.CorrelateById(ctx => ctx.Message.CorrelationId);
+ });
+
+ Initially(
+ When(Started)
+ .Then(ctx =>
+ {
+ ctx.Saga.RoomId = ctx.Message.RoomId;
+ ctx.Saga.UserEmail = ctx.Message.UserEmail;
+ })
+ .ThenAsync(async ctx =>
+ {
+ var endpoint = await ctx.GetSendEndpoint(new Uri("queue:send-notify"));
+ await endpoint.Send(new SendNotifyCommand(
+ ctx.Saga.CorrelationId,
+ ctx.Saga.UserEmail ?? "unknown@local",
+ $"Room {ctx.Saga.RoomId} booking started"),
+ ctx.CancellationToken);
+ })
+ .TransitionTo(Notifying)
+ );
+
+ During(Notifying,
+ When(Reserved)
+ .ThenAsync(async ctx =>
+ {
+ await ctx.Publish(new RoomReserved(ctx.Saga.CorrelationId, ctx.Saga.RoomId),
+ ctx.CancellationToken);
+ })
+ .Finalize()
+ );
+
+ SetCompletedWhenFinalized();
+ }
+}
diff --git a/CoworkingBooking.BookingService.Api/Saga/Contracts.cs b/CoworkingBooking.BookingService.Api/Saga/Contracts.cs
new file mode 100644
index 0000000..b3ffc4a
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Saga/Contracts.cs
@@ -0,0 +1,6 @@
+namespace CoworkingBooking.BookingService.Api.Saga.Contracts;
+
+public record StartBooking(Guid CorrelationId, Guid RoomId, string UserEmail);
+public record ReserveRoom(Guid CorrelationId, Guid RoomId);
+public record RoomReserved(Guid CorrelationId, Guid RoomId);
+public record SendNotifyCommand(Guid CorrelationId, string Email, string Text);
diff --git a/CoworkingBooking.BookingService.Api/Saga/ReserveRoomCommandConsumer.cs b/CoworkingBooking.BookingService.Api/Saga/ReserveRoomCommandConsumer.cs
new file mode 100644
index 0000000..ce118ea
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/Saga/ReserveRoomCommandConsumer.cs
@@ -0,0 +1,18 @@
+using MassTransit;
+using CoworkingBooking.BookingService.Api.Saga.Contracts;
+
+namespace CoworkingBooking.BookingService.Api.Saga;
+
+public class ReserveRoomCommandConsumer : IConsumer
+{
+ private readonly ILogger _logger;
+ public ReserveRoomCommandConsumer(ILogger logger) => _logger = logger;
+
+ public async Task Consume(ConsumeContext context)
+ {
+ var msg = context.Message;
+ _logger.LogInformation("Reserving room {RoomId} (CorrelationId={Cid})", msg.RoomId, msg.CorrelationId);
+
+ await context.Publish(new RoomReserved(msg.CorrelationId, msg.RoomId), context.CancellationToken);
+ }
+}
diff --git a/CoworkingBooking.BookingService.Api/appsettings.json b/CoworkingBooking.BookingService.Api/appsettings.json
new file mode 100644
index 0000000..e8a40f3
--- /dev/null
+++ b/CoworkingBooking.BookingService.Api/appsettings.json
@@ -0,0 +1,22 @@
+{
+ "ConnectionStrings": {
+ "Postgres": "Host=localhost;Port=5432;Database=bookingdb;Username=booking;Password=bookingpwd"
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Services": {
+ "NotificationBaseUrl": "http://localhost:5212"
+ },
+ "Integrations": {
+ "Notify": {
+ "BaseUrl": "http://localhost:5212",
+ "BaseUrlIPv4": "http://127.0.0.1:5212"
+ },"ConnectionStrings": {
+ "Redis": "localhost:6379"
+ }
+}
+}
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..69409bc
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/CoworkingBooking.BookingService.Infrastructure.csproj
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/CoworkingBooking.BookingService.Infrastructure/Distributed/RedisSemaphore.cs b/CoworkingBooking.BookingService.Infrastructure/Distributed/RedisSemaphore.cs
new file mode 100644
index 0000000..6a80b68
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Distributed/RedisSemaphore.cs
@@ -0,0 +1,93 @@
+using StackExchange.Redis;
+using System.Security.Cryptography;
+using System.Text;
+using CoworkingBooking.CoreLib.Distributed;
+
+namespace CoworkingBooking.BookingService.Infrastructure.Distributed;
+
+public sealed class RedisSemaphore : IDistributedSemaphore
+{
+ private readonly IDatabase _db;
+ private readonly string _prefix;
+
+ public RedisSemaphore(IConnectionMultiplexer mux, string keyPrefix = "sem:")
+ {
+ _db = mux.GetDatabase();
+ _prefix = keyPrefix;
+ }
+
+ string Key(string name) => $"{_prefix}{name}"; // ZSET: owners (member = token, score = expiry unix-ms)
+ static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+
+ const string AcquireLua = @"
+-- KEYS[1] = zset key
+-- ARGV[1] = nowMs
+-- ARGV[2] = expiryMs
+-- ARGV[3] = maxPermits
+-- ARGV[4] = token
+redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
+local count = redis.call('ZCARD', KEYS[1])
+if tonumber(count) < tonumber(ARGV[3]) then
+ local added = redis.call('ZADD', KEYS[1], 'NX', ARGV[2], ARGV[4])
+ if tonumber(added) == 1 then
+ return ARGV[4]
+ end
+end
+return nil
+";
+
+ const string RenewLua = @"
+-- KEYS[1] = zset key
+-- ARGV[1] = nowMs
+-- ARGV[2] = newExpiryMs
+-- ARGV[3] = token
+redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
+if redis.call('ZSCORE', KEYS[1], ARGV[3]) then
+ redis.call('ZADD', KEYS[1], 'XX', ARGV[2], ARGV[3])
+ return 1
+end
+return 0
+";
+
+ public async Task TryAcquireAsync(string name, int maxPermits, TimeSpan leaseTime, CancellationToken ct = default)
+ {
+ var key = (RedisKey)Key(name);
+ var now = NowMs();
+ var expiry = now + (long)leaseTime.TotalMilliseconds;
+ var token = CreateToken();
+
+ var res = (string?)await _db.ScriptEvaluateAsync(
+ AcquireLua,
+ keys: new RedisKey[] { key },
+ values: new RedisValue[] { now, expiry, maxPermits, token });
+
+ return string.IsNullOrEmpty(res) ? null : res;
+ }
+
+ public async Task ReleaseAsync(string name, string ownerToken, CancellationToken ct = default)
+ {
+ var removed = await _db.SortedSetRemoveAsync(Key(name), ownerToken);
+ return removed;
+ }
+
+ public async Task RenewAsync(string name, string ownerToken, TimeSpan leaseTime, CancellationToken ct = default)
+ {
+ var key = (RedisKey)Key(name);
+ var now = NowMs();
+ var newExpiry = now + (long)leaseTime.TotalMilliseconds;
+
+ var res = (int)(long)await _db.ScriptEvaluateAsync(
+ RenewLua,
+ keys: new RedisKey[] { key },
+ values: new RedisValue[] { now, newExpiry, ownerToken });
+
+ return res == 1;
+ }
+
+ static string CreateToken()
+ {
+ Span bytes = stackalloc byte[16];
+ RandomNumberGenerator.Fill(bytes);
+ return Convert.ToHexString(bytes).ToLowerInvariant();
+ }
+}
diff --git a/CoworkingBooking.BookingService.Infrastructure/Distributed/ServiceCollectionExtensions.cs b/CoworkingBooking.BookingService.Infrastructure/Distributed/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..b47b932
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Distributed/ServiceCollectionExtensions.cs
@@ -0,0 +1,17 @@
+using CoworkingBooking.CoreLib.Distributed;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using StackExchange.Redis;
+
+namespace CoworkingBooking.BookingService.Infrastructure.Distributed;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddRedisSemaphore(this IServiceCollection services, IConfiguration cfg)
+ {
+ var cs = cfg.GetConnectionString("Redis") ?? "localhost:6379";
+ services.AddSingleton(_ => ConnectionMultiplexer.Connect(cs));
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/CoworkingBooking.BookingService.Infrastructure/Integration/HttpNotificationClient.cs b/CoworkingBooking.BookingService.Infrastructure/Integration/HttpNotificationClient.cs
new file mode 100644
index 0000000..d64855c
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Integration/HttpNotificationClient.cs
@@ -0,0 +1,27 @@
+using System.Net.Http.Json;
+
+namespace CoworkingBooking.BookingService.Infrastructure.Integration;
+
+public sealed class HttpNotificationClient : INotificationClient
+{
+ private readonly HttpClient _http;
+
+ public HttpNotificationClient(HttpClient http) => _http = http;
+
+ public async Task PingAsync(CancellationToken ct = default)
+ {
+ using var resp = await _http.GetAsync("/api/notify/v1/ping", ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ Console.WriteLine($"[Notify->Ping] {resp.StatusCode}; body='{body}'");
+ }
+ return resp.IsSuccessStatusCode;
+ }
+
+ public async Task PingRawAsync(CancellationToken ct = default)
+ => await _http.GetStringAsync("/api/notify/v1/ping-raw", ct);
+
+ public async Task GetInfoAsync(CancellationToken ct = default)
+ => await _http.GetFromJsonAsync("/api/notify/v1/info", cancellationToken: ct);
+}
diff --git a/CoworkingBooking.BookingService.Infrastructure/Integration/INotificationClient.cs b/CoworkingBooking.BookingService.Infrastructure/Integration/INotificationClient.cs
new file mode 100644
index 0000000..3cc57bf
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Integration/INotificationClient.cs
@@ -0,0 +1,10 @@
+namespace CoworkingBooking.BookingService.Infrastructure.Integration;
+
+public interface INotificationClient
+{
+ Task PingAsync(CancellationToken ct = default);
+ Task PingRawAsync(CancellationToken ct = default);
+ Task GetInfoAsync(CancellationToken ct = default);
+}
+
+public sealed record NotifyInfoDto(string service, DateTime utc, string host);
diff --git a/CoworkingBooking.BookingService.Infrastructure/Integration/NotifyOptions.cs b/CoworkingBooking.BookingService.Infrastructure/Integration/NotifyOptions.cs
new file mode 100644
index 0000000..4ea0191
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Integration/NotifyOptions.cs
@@ -0,0 +1,7 @@
+namespace CoworkingBooking.BookingService.Infrastructure.Integration;
+
+public sealed class NotifyOptions
+{
+ public string BaseUrl { get; set; } = "";
+ public int TimeoutSeconds { get; set; } = 5;
+}
diff --git a/CoworkingBooking.BookingService.Infrastructure/Integration/ServiceCollectionExtensions.cs b/CoworkingBooking.BookingService.Infrastructure/Integration/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..c92344c
--- /dev/null
+++ b/CoworkingBooking.BookingService.Infrastructure/Integration/ServiceCollectionExtensions.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using System;
+using System.Net.Http; // SocketsHttpHandler
+
+namespace CoworkingBooking.BookingService.Infrastructure.Integration;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddNotificationIntegration(this IServiceCollection services, IConfiguration cfg)
+ {
+ var baseAddress = cfg["Integration:Notification:BaseAddress"]
+ ?? throw new InvalidOperationException("Integration:Notification:BaseAddress is missing");
+
+ services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri(baseAddress);
+ client.DefaultRequestVersion = new Version(1, 1);
+ client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower;
+ })
+
+ .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
+ {
+ UseProxy = false,
+ Proxy = null
+
+ });
+
+ return services;
+ }
+}
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..f435b34
--- /dev/null
+++ b/CoworkingBooking.CoreLib/CoworkingBooking.CoreLib.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CoworkingBooking.CoreLib/Distributed/IDistributedSemaphore.cs b/CoworkingBooking.CoreLib/Distributed/IDistributedSemaphore.cs
new file mode 100644
index 0000000..2f738bc
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Distributed/IDistributedSemaphore.cs
@@ -0,0 +1,17 @@
+namespace CoworkingBooking.CoreLib.Distributed;
+
+public interface IDistributedSemaphore
+{
+ /// Пытается занять 1 разрешение. Возвращает токен владельца или null.
+ Task TryAcquireAsync(
+ string name,
+ int maxPermits,
+ TimeSpan leaseTime,
+ CancellationToken ct = default);
+
+ /// Освобождает разрешение по токену.
+ Task ReleaseAsync(string name, string ownerToken, CancellationToken ct = default);
+
+ /// Продлевает аренду (lease) по токену.
+ Task RenewAsync(string name, string ownerToken, TimeSpan leaseTime, CancellationToken ct = default);
+}
diff --git a/CoworkingBooking.CoreLib/Http/HttpService.cs b/CoworkingBooking.CoreLib/Http/HttpService.cs
new file mode 100644
index 0000000..49ce425
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Http/HttpService.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+
+namespace CoworkingBooking.CoreLib.Http;
+
+public sealed class HttpService : IHttpService
+{
+ private readonly HttpClient _http;
+ private static readonly JsonSerializerOptions _json = new()
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ public HttpService(HttpClient http) => _http = http;
+
+ public async Task<(HttpStatusCode Status, string Body)> GetRawAsync(string url, CancellationToken ct = default)
+ {
+ using var resp = await _http.GetAsync(url, ct);
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ return (resp.StatusCode, body);
+ }
+
+ public async Task<(HttpStatusCode Status, T? Data, string? Error)> GetAsync(string url, CancellationToken ct = default)
+ {
+ using var resp = await _http.GetAsync(url, ct);
+ if (!resp.IsSuccessStatusCode)
+ {
+ var err = await resp.Content.ReadAsStringAsync(ct);
+ return (resp.StatusCode, default, string.IsNullOrWhiteSpace(err) ? resp.ReasonPhrase : err);
+ }
+
+ var data = await resp.Content.ReadFromJsonAsync(_json, ct);
+ return (resp.StatusCode, data, null);
+ }
+}
diff --git a/CoworkingBooking.CoreLib/Http/IHttpService.cs b/CoworkingBooking.CoreLib/Http/IHttpService.cs
new file mode 100644
index 0000000..eca283d
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Http/IHttpService.cs
@@ -0,0 +1,9 @@
+using System.Net;
+
+namespace CoworkingBooking.CoreLib.Http;
+
+public interface IHttpService
+{
+ Task<(HttpStatusCode Status, string Body)> GetRawAsync(string url, CancellationToken ct = default);
+ Task<(HttpStatusCode Status, T? Data, string? Error)> GetAsync(string url, CancellationToken ct = default);
+}
diff --git a/CoworkingBooking.CoreLib/Http/ServiceCollectionExtensions.cs b/CoworkingBooking.CoreLib/Http/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..9479bfe
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Http/ServiceCollectionExtensions.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.DependencyInjection;
+using CoworkingBooking.CoreLib.Tracing;
+
+namespace CoworkingBooking.CoreLib.Http;
+
+public static class HttpDiExtensions
+{
+ /// Общие зависимости для исходящих HTTP-запросов.
+ public static IServiceCollection AddCoreHttpService(this IServiceCollection services)
+ {
+ services.AddHttpContextAccessor();
+ services.AddTransient(); // <-- нужная регистрация
+ return services;
+ }
+}
diff --git a/CoworkingBooking.CoreLib/Messaging/Contracts.cs b/CoworkingBooking.CoreLib/Messaging/Contracts.cs
new file mode 100644
index 0000000..9b2e278
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Messaging/Contracts.cs
@@ -0,0 +1,43 @@
+namespace CoworkingBooking.CoreLib.Messaging;
+
+// --- Coordinator (StateMachine) ---
+public interface StartBooking
+{
+ Guid CorrelationId { get; }
+ Guid RoomId { get; }
+ string UserEmail { get; }
+}
+
+public interface RoomReserved
+{
+ Guid CorrelationId { get; }
+}
+
+public interface NotificationSent
+{
+ Guid CorrelationId { get; }
+}
+
+public interface BookingCompleted
+{
+ Guid CorrelationId { get; }
+}
+
+public interface BookingFailed
+{
+ Guid CorrelationId { get; }
+ string Reason { get; }
+}
+
+// --- Orchestrator (RoutingSlip/Courier) ---
+public static class Activities
+{
+ public const string ReserveRoom = "reserve-room";
+ public const string SendNotify = "send-notification";
+}
+
+public record ReserveRoomArguments(Guid RoomId);
+public record ReserveRoomLog(Guid RoomId);
+
+public record SendNotifyArguments(string Email, string Text);
+public record SendNotifyLog(string Email);
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.CoreLib/Tracing/TraceIdApplicationExtensions.cs b/CoworkingBooking.CoreLib/Tracing/TraceIdApplicationExtensions.cs
new file mode 100644
index 0000000..6036c51
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Tracing/TraceIdApplicationExtensions.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Builder;
+
+namespace CoworkingBooking.CoreLib.Tracing;
+
+public static class TraceIdApplicationExtensions
+{
+ ///
+ /// Подключает middleware, который проставляет/пробрасывает X-Trace-Id.
+ ///
+ public static IApplicationBuilder UseTraceId(this IApplicationBuilder app)
+ {
+ return app.UseMiddleware();
+ }
+}
\ No newline at end of file
diff --git a/CoworkingBooking.CoreLib/Tracing/TraceIdDelegatingHandler.cs b/CoworkingBooking.CoreLib/Tracing/TraceIdDelegatingHandler.cs
new file mode 100644
index 0000000..1ba7058
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Tracing/TraceIdDelegatingHandler.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Http;
+using System.Net.Http;
+
+namespace CoworkingBooking.CoreLib.Tracing;
+
+public sealed class TraceIdDelegatingHandler : DelegatingHandler
+{
+ private readonly IHttpContextAccessor _ctx;
+ public TraceIdDelegatingHandler(IHttpContextAccessor ctx) => _ctx = ctx;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var traceId = _ctx.HttpContext?.TraceIdentifier;
+ if (!string.IsNullOrWhiteSpace(traceId))
+ {
+ request.Headers.Remove("x-trace-id");
+ request.Headers.Add("x-trace-id", traceId);
+ }
+ return base.SendAsync(request, cancellationToken);
+ }
+}
diff --git a/CoworkingBooking.CoreLib/Tracing/TraceIdHandler.cs b/CoworkingBooking.CoreLib/Tracing/TraceIdHandler.cs
new file mode 100644
index 0000000..3a7a6f8
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Tracing/TraceIdHandler.cs
@@ -0,0 +1,35 @@
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace CoworkingBooking.CoreLib.Tracing;
+
+public class TraceIdHandler : DelegatingHandler
+{
+ private const string TraceHeader = "x-trace-id";
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ // прокидываем trace-id в исходящие запросы, если он есть
+ if (!request.Headers.Contains(TraceHeader))
+ {
+ var traceId = request.GetOrCreateCorrelationId();
+ request.Headers.Add(TraceHeader, traceId);
+ }
+ return base.SendAsync(request, ct);
+ }
+}
+
+file static class HttpRequestMessageExtensions
+{
+ private const string TraceHeader = "x-trace-id";
+ public static string GetOrCreateCorrelationId(this HttpRequestMessage req)
+ {
+ if (req.Headers.TryGetValues(TraceHeader, out var values))
+ return values.First();
+
+ var id = Guid.NewGuid().ToString("N");
+ req.Headers.Add(TraceHeader, id);
+ return id;
+ }
+}
diff --git a/CoworkingBooking.CoreLib/Tracing/TraceIdMiddleware.cs b/CoworkingBooking.CoreLib/Tracing/TraceIdMiddleware.cs
new file mode 100644
index 0000000..57714e8
--- /dev/null
+++ b/CoworkingBooking.CoreLib/Tracing/TraceIdMiddleware.cs
@@ -0,0 +1,26 @@
+using System.Diagnostics;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CoworkingBooking.CoreLib.Tracing;
+
+public class TraceIdMiddleware
+{
+ private readonly RequestDelegate _next;
+ private const string TraceHeader = "x-trace-id";
+
+ public TraceIdMiddleware(RequestDelegate next) => _next = next;
+
+ public async Task Invoke(HttpContext ctx)
+ {
+ if (!ctx.Request.Headers.TryGetValue(TraceHeader, out var traceId) || string.IsNullOrWhiteSpace(traceId))
+ traceId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
+
+ ctx.Items[TraceHeader] = traceId.ToString();
+ ctx.Response.Headers[TraceHeader] = traceId.ToString();
+
+ await _next(ctx);
+ }
+}
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/Activities/SendNotifyActivity.cs b/NotificationService.Api/Activities/SendNotifyActivity.cs
new file mode 100644
index 0000000..1f9669d
--- /dev/null
+++ b/NotificationService.Api/Activities/SendNotifyActivity.cs
@@ -0,0 +1,14 @@
+using MassTransit;
+using CoworkingBooking.CoreLib.Messaging;
+
+namespace NotificationService.Api.Activities;
+
+public class SendNotifyActivity : IExecuteActivity
+{
+ public async Task Execute(ExecuteContext context)
+ {
+ await Task.Delay(50);
+
+ return context.Completed(new(context.Arguments.Email));
+ }
+}
diff --git a/NotificationService.Api/Consumers/SendNotificationCommandConsumer.cs b/NotificationService.Api/Consumers/SendNotificationCommandConsumer.cs
new file mode 100644
index 0000000..4873054
--- /dev/null
+++ b/NotificationService.Api/Consumers/SendNotificationCommandConsumer.cs
@@ -0,0 +1,21 @@
+using System.Threading.Tasks;
+using MassTransit;
+
+namespace NotificationService.Api.Consumers;
+
+public record SendNotifyCommand(Guid CorrelationId, string Email, string Text);
+
+public class SendNotificationCommandConsumer : IConsumer
+{
+ private readonly ILogger _logger;
+ public SendNotificationCommandConsumer(ILogger logger)
+ => _logger = logger;
+
+ public Task Consume(ConsumeContext context)
+ {
+ var msg = context.Message;
+ _logger.LogInformation("[Notify] CorrelationId={CorrelationId} email={Email} text={Text}",
+ msg.CorrelationId, msg.Email, msg.Text);
+ return Task.CompletedTask;
+ }
+}
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/NotifyController.cs b/NotificationService.Api/Controllers/NotifyController.cs
new file mode 100644
index 0000000..e239374
--- /dev/null
+++ b/NotificationService.Api/Controllers/NotifyController.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace NotificationService.Api.Controllers;
+
+[ApiController]
+[Route("api/notify/v1")]
+public class NotifyController : ControllerBase
+{
+ [HttpGet("ping")]
+ public IActionResult Ping()
+ => Ok(new { ok = true });
+
+ [HttpGet("ping-raw")]
+ public IActionResult PingRaw()
+ => Content("{\"ok\":true}", "application/json");
+
+ [HttpGet("info")]
+ public IActionResult Info()
+ => Ok(new
+ {
+ service = "notification",
+ utc = DateTime.UtcNow,
+ host = Environment.MachineName
+ });
+}
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..e7df708
--- /dev/null
+++ b/NotificationService.Api/NotificationService.Api.csproj
@@ -0,0 +1,31 @@
+
+
+
+ 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..651ba23
--- /dev/null
+++ b/NotificationService.Api/Program.cs
@@ -0,0 +1,51 @@
+using AutoMapper;
+using FluentValidation;
+using FluentValidation.AspNetCore;
+using NotificationService.Application;
+using NotificationService.Infrastructure;
+using MassTransit;
+using CoworkingBooking.CoreLib.Messaging;
+using NotificationService.Api.Consumers;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.WebHost.UseUrls("http://127.0.0.1:5212");
+
+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);
+builder.Services.AddMassTransit(x =>
+{
+ x.AddConsumer();
+
+ x.UsingInMemory((context, cfg) =>
+ {
+ cfg.ConfigureEndpoints(context);
+ });
+});
+
+var app = builder.Build();
+
+app.UseSwagger();
+app.UseSwaggerUI();
+
+app.MapControllers();
+app.MapHealthChecks("/health");
+
+app.Use(async (ctx, next) =>
+{
+ var path = $"{ctx.Request.Method} {ctx.Request.Path}";
+ Console.WriteLine($"[IN] {path}");
+ await next();
+ Console.WriteLine($"[OUT] {path} -> {ctx.Response.StatusCode}");
+});
+
+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