Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Booking/Booking.Api/Booking.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Booking.Core\Booking.Core.csproj" />
<ProjectReference Include="..\Booking.Infrastructure\Booking.Infrastructure.csproj" />
</ItemGroup>

</Project>
7 changes: 7 additions & 0 deletions Booking/Booking.Api/Contracts/CreateAppointmentRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Booking.Api.Contracts;

public sealed record CreateAppointmentRequest(
DateTime StartUtc,
DateTime EndUtc,
string Customer
);
69 changes: 69 additions & 0 deletions Booking/Booking.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Booking.Api.Contracts;
using Booking.Core;
using Booking.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

// OpenAPI + Swagger UI
builder.Services.AddOpenApi();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// DI
builder.Services.AddSingleton<IAppointmentRepository, InMemoryAppointmentRepository>();
builder.Services.AddSingleton<ITimeProvider, SystemTimeProvider>();
builder.Services.AddSingleton<BookingService>();

var app = builder.Build();

// Swagger UI in Development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// -------------------------
// Endpoints (per spec)
// -------------------------

app.MapPost("/appointments", async (
CreateAppointmentRequest req,
BookingService service,
CancellationToken ct) =>
{
try
{
var created = await service.CreateAsync(
startUtc: req.StartUtc,
endUtc: req.EndUtc,
customer: req.Customer,
ct: ct);

return Results.Ok(created);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (InvalidOperationException ex) when (ex.Message == BookingErrors.Conflict)
{
return Results.Conflict(new { error = ex.Message });
}
})
.WithName("CreateAppointment")
.WithOpenApi();

app.MapGet("/appointments", async (
IAppointmentRepository repo,
CancellationToken ct) =>
{
var items = await repo.GetAllAsync(ct);
return Results.Ok(items);
})
.WithName("GetAppointments")
.WithOpenApi();

app.Run();
8 changes: 8 additions & 0 deletions Booking/Booking.Core/Appointment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Booking.Core;

public sealed record Appointment(
Guid Id,
DateTime StartUtc,
DateTime EndUtc,
string Customer
);
9 changes: 9 additions & 0 deletions Booking/Booking.Core/Booking.Core.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
10 changes: 10 additions & 0 deletions Booking/Booking.Core/BookingErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Booking.Core;

public static class BookingErrors
{
public const string CustomerRequired = "Customer is required.";
public const string InvalidRange = "EndUtc must be after StartUtc.";
public const string TooShort = "Minimum duration is 15 minutes.";
public const string InPast = "Cannot book an appointment in the past.";
public const string Conflict = "Appointment time conflicts with an existing booking.";
}
50 changes: 50 additions & 0 deletions Booking/Booking.Core/BookingService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
namespace Booking.Core;

public sealed class BookingService
{
private readonly IAppointmentRepository _repo;
private readonly ITimeProvider _time;

public BookingService(IAppointmentRepository repo, ITimeProvider time)
{
_repo = repo;
_time = time;
}

public async Task<Appointment> CreateAsync(
DateTime startUtc,
DateTime endUtc,
string customer,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(customer))
throw new ArgumentException(BookingErrors.CustomerRequired);

// (Spec) End must be after Start
if (endUtc <= startUtc)
throw new ArgumentException(BookingErrors.InvalidRange);

// (Spec) Min duration 15 minutes
if (endUtc - startUtc < TimeSpan.FromMinutes(15))
throw new ArgumentException(BookingErrors.TooShort);

// (Spec) Cannot book in the past
if (startUtc < _time.UtcNow)
throw new ArgumentException(BookingErrors.InPast);

// (Spec) No overlap allowed
var conflict = await _repo.ExistsOverlapAsync(startUtc, endUtc, ct);
if (conflict)
throw new InvalidOperationException(BookingErrors.Conflict);

var appointment = new Appointment(
Id: Guid.NewGuid(),
StartUtc: startUtc,
EndUtc: endUtc,
Customer: customer.Trim()
);

await _repo.AddAsync(appointment, ct);
return appointment;
}
}
8 changes: 8 additions & 0 deletions Booking/Booking.Core/IAppointmentRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Booking.Core;

public interface IAppointmentRepository
{
Task AddAsync(Appointment appointment, CancellationToken ct = default);
Task<IReadOnlyList<Appointment>> GetAllAsync(CancellationToken ct = default);
Task<bool> ExistsOverlapAsync(DateTime startUtc, DateTime endUtc, CancellationToken ct = default);
}
11 changes: 11 additions & 0 deletions Booking/Booking.Core/TimeProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Booking.Core;

public interface ITimeProvider
{
DateTime UtcNow { get; }
}

public sealed class SystemTimeProvider : ITimeProvider
{
public DateTime UtcNow => DateTime.UtcNow;
}
13 changes: 13 additions & 0 deletions Booking/Booking.Infrastructure/Booking.Infrastructure.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Booking.Core\Booking.Core.csproj" />
</ItemGroup>

</Project>
37 changes: 37 additions & 0 deletions Booking/Booking.Infrastructure/InMemoryAppointmentRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Booking.Core;

namespace Booking.Infrastructure;

public sealed class InMemoryAppointmentRepository : IAppointmentRepository
{
private readonly List<Appointment> _items = new();
private readonly object _lock = new();

public Task AddAsync(Appointment appointment, CancellationToken ct = default)
{
lock (_lock)
{
_items.Add(appointment);
}
return Task.CompletedTask;
}

public Task<IReadOnlyList<Appointment>> GetAllAsync(CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult((IReadOnlyList<Appointment>)_items.ToList());
}
}

public Task<bool> ExistsOverlapAsync(DateTime startUtc, DateTime endUtc, CancellationToken ct = default)
{
lock (_lock)
{
// Spec overlap rule:
// overlap when newStart < existingEnd AND newEnd > existingStart
var exists = _items.Any(a => startUtc < a.EndUtc && endUtc > a.StartUtc);
return Task.FromResult(exists);
}
}
}
27 changes: 27 additions & 0 deletions Booking/Booking.Tests/Booking.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Booking.Core\Booking.Core.csproj" />
<ProjectReference Include="..\Booking.Infrastructure\Booking.Infrastructure.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
Loading