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(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Channels", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Templates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Key = table.Column(type: "text", nullable: false), + ChannelType = table.Column(type: "integer", nullable: false), + Subject = table.Column(type: "text", nullable: true), + Body = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Templates", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelId = table.Column(type: "uuid", nullable: false), + TemplateId = table.Column(type: "uuid", nullable: false), + To = table.Column(type: "text", nullable: false), + Cc = table.Column(type: "text", nullable: true), + PayloadJson = table.Column(type: "text", nullable: false), + Status = table.Column(type: "integer", nullable: false), + Error = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + SentAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Messages", x => x.Id); + table.ForeignKey( + name: "FK_Messages_Channels_ChannelId", + column: x => x.ChannelId, + principalTable: "Channels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Messages_Templates_TemplateId", + column: x => x.TemplateId, + principalTable: "Templates", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Channels_Type_IsActive", + table: "Channels", + columns: new[] { "Type", "IsActive" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_ChannelId", + table: "Messages", + column: "ChannelId"); + + migrationBuilder.CreateIndex( + name: "IX_Messages_Status_CreatedAt", + table: "Messages", + columns: new[] { "Status", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Messages_TemplateId", + table: "Messages", + column: "TemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_Templates_Key_ChannelType", + table: "Templates", + columns: new[] { "Key", "ChannelType" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Messages"); + + migrationBuilder.DropTable( + name: "Channels"); + + migrationBuilder.DropTable( + name: "Templates"); + } + } +} diff --git a/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs b/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs new file mode 100644 index 0000000..edb579f --- /dev/null +++ b/NotificationService.Infrastructure/Migrations/NotifyDbContextModelSnapshot.cs @@ -0,0 +1,159 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NotificationService.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NotificationService.Infrastructure.Migrations +{ + [DbContext(typeof(NotifyDbContext))] + partial class NotifyDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfigJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Cc") + .HasColumnType("text"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("To") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChannelType") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "ChannelType"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("NotificationService.Domain.Message", b => + { + b.HasOne("NotificationService.Domain.Channel", "Channel") + .WithMany("Messages") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NotificationService.Domain.Template", "Template") + .WithMany("Messages") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("NotificationService.Domain.Channel", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("NotificationService.Domain.Template", b => + { + b.Navigation("Messages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj b/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj new file mode 100644 index 0000000..0a748b5 --- /dev/null +++ b/NotificationService.Infrastructure/NotificationService.Infrastructure.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + net8.0 + enable + enable + + + diff --git a/NotificationService.Infrastructure/NotifyDbContext.cs b/NotificationService.Infrastructure/NotifyDbContext.cs new file mode 100644 index 0000000..dfb1f99 --- /dev/null +++ b/NotificationService.Infrastructure/NotifyDbContext.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using NotificationService.Domain; + +namespace NotificationService.Infrastructure; + +public class NotifyDbContext : DbContext +{ + public NotifyDbContext(DbContextOptions options) : base(options) { } + + public DbSet Channels => Set(); + public DbSet