diff --git a/api/Contracts/CheckInRequest.cs b/api/Contracts/CheckInRequest.cs new file mode 100644 index 0000000..aecc264 --- /dev/null +++ b/api/Contracts/CheckInRequest.cs @@ -0,0 +1,4 @@ +namespace Contracts +{ + public record CheckInRequest(string GuestEmail); +} diff --git a/api/Contracts/ErrorResponse.cs b/api/Contracts/ErrorResponse.cs new file mode 100644 index 0000000..20a03f4 --- /dev/null +++ b/api/Contracts/ErrorResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Contracts +{ + public record ErrorResponse( + string Title, + string Detail, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ResourceType = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ResourceId = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] Dictionary? Errors = null + ); +} diff --git a/api/Contracts/ImportResult.cs b/api/Contracts/ImportResult.cs new file mode 100644 index 0000000..1759644 --- /dev/null +++ b/api/Contracts/ImportResult.cs @@ -0,0 +1,11 @@ +namespace Contracts +{ + public record ImportResult( + int TotalRows, + int Imported, + int Failed, + List Errors + ); + + public record ImportError(int Line, string RoomNumber, string Message); +} diff --git a/api/Contracts/ReservationRequest.cs b/api/Contracts/ReservationRequest.cs new file mode 100644 index 0000000..cc10be6 --- /dev/null +++ b/api/Contracts/ReservationRequest.cs @@ -0,0 +1,10 @@ +namespace Contracts +{ + public class ReservationRequest + { + public required string RoomNumber { get; set; } + public required string GuestEmail { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } +} diff --git a/api/Contracts/UpdateRoomStateRequest.cs b/api/Contracts/UpdateRoomStateRequest.cs new file mode 100644 index 0000000..eff4f36 --- /dev/null +++ b/api/Contracts/UpdateRoomStateRequest.cs @@ -0,0 +1,6 @@ +using Models; + +namespace Contracts +{ + public record UpdateRoomStateRequest(State State); +} diff --git a/api/Controllers/GuestController.cs b/api/Controllers/GuestsController.cs similarity index 57% rename from api/Controllers/GuestController.cs rename to api/Controllers/GuestsController.cs index 095d570..db75dab 100644 --- a/api/Controllers/GuestController.cs +++ b/api/Controllers/GuestsController.cs @@ -4,22 +4,24 @@ namespace Controllers { - [Tags("Guests"), Route("guest")] - public class GuestController : Controller + [ApiController] + [Tags("Guests"), Route("guests")] + public class GuestsController : ControllerBase { private GuestRepository _repo; - public GuestController(GuestRepository guestRepository) + public GuestsController(GuestRepository guestRepository) { _repo = guestRepository; } [HttpGet, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] public async Task> GetGuests() { var guests = await _repo.GetGuests(); - return Json(guests); + return Ok(guests); } } } diff --git a/api/Controllers/ReservationController.cs b/api/Controllers/ReservationController.cs deleted file mode 100644 index f17fe4d..0000000 --- a/api/Controllers/ReservationController.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Models; -using Models.Errors; -using Repositories; - -namespace Controllers -{ - [Tags("Reservations"), Route("reservation")] - public class ReservationController : Controller - { - private ReservationRepository _repo { get; set; } - - public ReservationController(ReservationRepository reservationRepository) - { - _repo = reservationRepository; - } - - [HttpGet, Produces("application/json"), Route("")] - public async Task> GetReservations() - { - var reservations = await _repo.GetReservations(); - - return Json(reservations); - } - - [HttpGet, Produces("application/json"), Route("{reservationId}")] - public async Task> GetRoom(Guid reservationId) - { - try - { - var reservation = await _repo.GetReservation(reservationId); - return Json(reservation); - } - catch (NotFoundException) - { - return NotFound(); - } - } - - /// - /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) - /// - /// - /// - [HttpPost, Produces("application/json"), Route("")] - public async Task> BookReservation( - [FromBody] Reservation newBooking - ) - { - // Provide a real ID if one is not provided - if (newBooking.Id == Guid.Empty) - { - newBooking.Id = Guid.NewGuid(); - } - - try - { - var createdReservation = await _repo.CreateReservation(newBooking); - return Created($"/reservation/${createdReservation.Id}", createdReservation); - } - catch (Exception ex) - { - Console.WriteLine("An error occured when trying to book a reservation:"); - Console.WriteLine(ex.ToString()); - - return BadRequest("Invalid reservation"); - } - } - - [HttpDelete, Produces("application/json"), Route("{reservationId}")] - public async Task DeleteReservation(Guid reservationId) - { - var result = await _repo.DeleteReservation(reservationId); - - return result ? NoContent() : NotFound(); - } - } -} diff --git a/api/Controllers/ReservationsController.cs b/api/Controllers/ReservationsController.cs new file mode 100644 index 0000000..b9bd89a --- /dev/null +++ b/api/Controllers/ReservationsController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Contracts; +using FluentValidation; +using Models; +using Repositories; + +namespace Controllers +{ + [ApiController] + [Tags("Reservations"), Route("reservations")] + public class ReservationsController : ControllerBase + { + private ReservationRepository _repo { get; set; } + private IValidator _validator { get; set; } + + public ReservationsController(ReservationRepository reservationRepository, IValidator validator) + { + _repo = reservationRepository; + _validator = validator; + } + + [Authorize] + [HttpGet, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> GetReservations( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string? roomNumber, + [FromQuery] string? guestEmail) + { + var reservations = await _repo.GetReservations(from, to, roomNumber, guestEmail); + + return Ok(reservations); + } + + [HttpGet, Produces("application/json"), Route("{reservationId}")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task> GetReservation(Guid reservationId) + { + var reservation = await _repo.GetReservation(reservationId); + + return Ok(reservation); + } + + /// + /// Create a new reservation, to generate the GUID ID on the server, send an Empty GUID (all 0s) + /// + /// The request/booking data. + /// The created reservation. + [HttpPost, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task> BookReservation([FromBody] ReservationRequest request) + { + await _validator.ValidateAndThrowAsync(request); + + var createdReservation = await _repo.CreateReservation(request); + return Created($"/reservations/{createdReservation.Id}", createdReservation); + } + + [Authorize] + [HttpPost, Produces("application/json"), Route("{reservationId}/check-in")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)] + public async Task> CheckIn(Guid reservationId, [FromBody] CheckInRequest request) + { + var reservation = await _repo.CheckIn(reservationId, request.GuestEmail); + return Ok(reservation); + } + + [Authorize] + [HttpPost, Produces("application/json"), Route("{reservationId}/check-out")] + [ProducesResponseType(typeof(Reservation), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)] + public async Task> CheckOut(Guid reservationId, [FromBody] CheckInRequest request) + { + var reservation = await _repo.CheckOut(reservationId, request.GuestEmail); + return Ok(reservation); + } + + [Authorize] + [HttpDelete, Produces("application/json"), Route("{reservationId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task DeleteReservation(Guid reservationId) + { + await _repo.DeleteReservation(reservationId); + + return NoContent(); + } + } +} diff --git a/api/Controllers/RoomController.cs b/api/Controllers/RoomController.cs deleted file mode 100644 index 6e97650..0000000 --- a/api/Controllers/RoomController.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Models; -using Models.Errors; -using Repositories; - -namespace Controllers -{ - [Tags("Rooms"), Route("room")] - public class RoomController : Controller - { - private RoomRepository _repo { get; set; } - - public RoomController(RoomRepository roomRepository) - { - _repo = roomRepository; - } - - [HttpGet, Produces("application/json"), Route("")] - public async Task> GetRooms() - { - var rooms = await _repo.GetRooms(); - - if (rooms == null) - { - return Json(Enumerable.Empty()); - } - - return Json(rooms); - } - - [HttpGet, Produces("application/json"), Route("{roomNumber}")] - public async Task> GetRoom(string roomNumber) - { - if (roomNumber.Length != 3) - { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); - } - - try - { - var room = await _repo.GetRoom(roomNumber); - - return Json(room); - } - catch (NotFoundException) - { - return NotFound(); - } - } - - [HttpPost, Produces("application/json"), Route("")] - public async Task> CreateRoom([FromBody] Room newRoom) - { - var createdRoom = await _repo.CreateRoom(newRoom); - - if (createdRoom == null) - { - return NotFound(); - } - - return Json(createdRoom); - } - - [HttpDelete, Produces("application/json"), Route("{roomNumber}")] - public async Task DeleteRoom(string roomNumber) - { - if (roomNumber.Length != 3) - { - return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); - } - - var deleted = await _repo.DeleteRoom(roomNumber); - - return deleted ? NoContent() : NotFound(); - } - } -} diff --git a/api/Controllers/RoomsController.cs b/api/Controllers/RoomsController.cs new file mode 100644 index 0000000..cb7161a --- /dev/null +++ b/api/Controllers/RoomsController.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Contracts; +using Models; +using Repositories; + +namespace Controllers +{ + [ApiController] + [Tags("Rooms"), Route("rooms")] + public class RoomsController : ControllerBase + { + private RoomRepository _repo { get; set; } + + public RoomsController(RoomRepository roomRepository) + { + _repo = roomRepository; + } + + [HttpGet, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + public async Task>> GetRooms() + { + var rooms = await _repo.GetRooms(); + + if (rooms == null) + { + return Ok(Enumerable.Empty()); + } + + return Ok(rooms); + } + + [HttpGet, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task> GetRoom(string roomNumber) + { + var room = await _repo.GetRoom(roomNumber); + + return Ok(room); + } + + [Authorize] + [HttpPost, Produces("application/json"), Route("")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task> CreateRoom([FromBody] Room newRoom) + { + var createdRoom = await _repo.CreateRoom(newRoom); + + if (createdRoom == null) + { + return NotFound(); + } + + return Ok(createdRoom); + } + + [Authorize] + [HttpPost, Produces("application/json"), Route("import")] + [ProducesResponseType(typeof(ImportResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + public async Task> ImportRooms(IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new { detail = "No file provided" }); + + if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)) + return BadRequest(new { detail = "Only CSV files are accepted" }); + + if (file.Length > 50_000) // ~500 rooms max + return BadRequest(new { detail = "File is too large. Maximum 500 rooms supported" }); + + var errors = new List(); + var validRooms = new List<(int Line, Room Room)>(); + var lineNumber = 0; + + // Parse and validate + using var reader = new StreamReader(file.OpenReadStream()); + while (await reader.ReadLineAsync() is { } line) + { + lineNumber++; + var roomNumber = line.Trim(); + + if (string.IsNullOrEmpty(roomNumber)) + continue; + + if (lineNumber == 1 && roomNumber.Equals("number", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!Room.IsValidRoomNumber(roomNumber)) + { + errors.Add(new ImportError(lineNumber, roomNumber, $"Invalid room number: {roomNumber}")); + continue; + } + + validRooms.Add((lineNumber, new Room { Number = roomNumber })); + } + + var imported = await _repo.CreateRoomsBatch(validRooms, errors); + + return Ok(new ImportResult(lineNumber, imported, errors.Count, errors)); + } + + [Authorize] + [HttpPatch, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(typeof(Room), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateRoomState(string roomNumber, [FromBody] UpdateRoomStateRequest request) + { + var room = await _repo.UpdateRoomState(roomNumber, request.State); + return Ok(room); + } + + [Authorize] + [HttpDelete, Produces("application/json"), Route("{roomNumber}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] + public async Task DeleteRoom(string roomNumber) + { + if (roomNumber.Length != 3) + { + return BadRequest("Invalid room ID - format is ###, ex 001 / 002 / 101"); + } + + var deleted = await _repo.DeleteRoom(roomNumber); + + return deleted ? NoContent() : NotFound(); + } + } +} diff --git a/api/Controllers/StaffController.cs b/api/Controllers/StaffController.cs index 881ab7b..e8acd4d 100644 --- a/api/Controllers/StaffController.cs +++ b/api/Controllers/StaffController.cs @@ -1,69 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; namespace Controllers { + [ApiController] [Route("staff")] - public class StaffController : Controller + public class StaffController : ControllerBase { - private IConfiguration Config { get; set; } + private readonly IConfiguration _config; public StaffController(IConfiguration config) { - Config = config; + _config = config; } - /// - /// Checks if the request is from a staff member, if not returns true and a 403 result - /// - /// - private bool IsNotStaff(HttpRequest request, out IActionResult? result) + [HttpPost("login")] + public IActionResult Login([FromHeader(Name = "X-Staff-Code")] string accessCode) { - // TODO explore UseAuthentication - request.Cookies.TryGetValue("access", out string? accessValue); - - if (accessValue == null || accessValue == "0") + var configuredSecret = _config.GetValue("staffAccessCode"); + if (accessCode != configuredSecret) { - result = StatusCode(403); - return true; + return Unauthorized(new { detail = "Invalid access code" }); } - result = null; - return false; - } + var key = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiryHours = _config.GetValue("Jwt:ExpiryHours"); - [HttpGet, Route("login")] - public IActionResult CheckCode([FromHeader(Name = "X-Staff-Code")] string accessCode) - { - var configuredSecret = Config.GetValue("staffAccessCode"); - if (configuredSecret != accessCode) - { - // don't set cookie, don't indicate anything - return NoContent(); - } - Response.Cookies.Append( - "access", - "1", - new CookieOptions - // TODO evaluate cookie options & auth mechanism for best security practices - { - IsEssential = true, - SameSite = SameSiteMode.Strict, - HttpOnly = true, - Secure = true - } + var token = new JwtSecurityToken( + claims: [new Claim(ClaimTypes.Role, "Staff")], + expires: DateTime.UtcNow.AddHours(expiryHours), + signingCredentials: credentials ); - return NoContent(); - } - [HttpGet, Route("check")] - public IActionResult CheckCookie() - { - if (IsNotStaff(Request, out IActionResult? result)) - { - return result!; - } - - return Ok("Authorized"); + return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token) }); } } + } diff --git a/api/Db/GuidTypeHandler.cs b/api/Db/GuidTypeHandler.cs new file mode 100644 index 0000000..db64576 --- /dev/null +++ b/api/Db/GuidTypeHandler.cs @@ -0,0 +1,18 @@ +using System.Data; +using Dapper; + +namespace Db +{ + public class GuidTypeHandler : SqlMapper.TypeHandler + { + public override Guid Parse(object value) + { + return Guid.Parse((string)value); + } + + public override void SetValue(IDbDataParameter parameter, Guid value) + { + parameter.Value = value.ToString(); + } + } +} diff --git a/api/Db/Seed.cs b/api/Db/Seed.cs new file mode 100644 index 0000000..e4cd263 --- /dev/null +++ b/api/Db/Seed.cs @@ -0,0 +1,29 @@ +using Dapper; +using Microsoft.Data.Sqlite; +using Models; + +namespace Db +{ + public static class Seed + { + public static async Task SeedData(SqliteConnection db) + { + await SeedRooms(db); + } + + private static async Task SeedRooms(SqliteConnection db) + { + var existingRooms = await db.QueryFirstOrDefaultAsync("SELECT COUNT(*) FROM Rooms"); + if (existingRooms > 0) return; + + var rooms = new[] { "101", "102", "103", "104", "105", "201", "202", "203" }; + foreach (var room in rooms) + { + await db.ExecuteAsync( + "INSERT OR IGNORE INTO Rooms(Number, State) VALUES(@Number, @State)", + new { Number = room, State = (int)State.Ready } + ); + } + } + } +} diff --git a/api/Db/Setup.cs b/api/Db/Setup.cs index 1f11061..3db619d 100644 --- a/api/Db/Setup.cs +++ b/api/Db/Setup.cs @@ -9,10 +9,8 @@ public static class Setup /// /// Ensures the DB is available and the requried tables are made /// - public static async void EnsureDb(IServiceScope scope) + public static async Task EnsureDb(SqliteConnection db) { - using var db = scope.ServiceProvider.GetRequiredService(); - // SQLite WAL (write-ahead log) go brrrr await db.ExecuteAsync("PRAGMA journal_mode = wal;"); // SQLite does not enforce FKs by default @@ -30,7 +28,7 @@ CREATE TABLE IF NOT EXISTS Guests ( await db.ExecuteAsync( $@" CREATE TABLE IF NOT Exists Rooms ( - {nameof(Room.Number)} INT PRIMARY KEY NOT NULL, + {nameof(Room.Number)} TEXT PRIMARY KEY NOT NULL, {nameof(Room.State)} INT NOT NULL ); " @@ -41,11 +39,12 @@ await db.ExecuteAsync( CREATE TABLE IF NOT EXISTS Reservations ( {nameof(Reservation.Id)} TEXT PRIMARY KEY NOT NULL, {nameof(Reservation.GuestEmail)} TEXT NOT NULL, - {nameof(Reservation.RoomNumber)} INT NOT NULL, + {nameof(Reservation.RoomNumber)} TEXT NOT NULL, {nameof(Reservation.Start)} INT NOT NULL, {nameof(Reservation.End)} INT NOT NULL, {nameof(Reservation.CheckedIn)} INT NOT NULL DEFAULT FALSE, {nameof(Reservation.CheckedOut)} INT NOT NULL DEFAULT FALSE, + {nameof(Reservation.CheckedOutAt)} TEXT, FOREIGN KEY ({nameof(Reservation.GuestEmail)}) REFERENCES Guests ({nameof(Guest.Email)}), FOREIGN KEY ({nameof(Reservation.RoomNumber)}) @@ -54,5 +53,7 @@ REFERENCES Rooms ({nameof(Room.Number)}) " ); } + } } + diff --git a/api/Extensions/DbConnectionExtensions.cs b/api/Extensions/DbConnectionExtensions.cs new file mode 100644 index 0000000..df45185 --- /dev/null +++ b/api/Extensions/DbConnectionExtensions.cs @@ -0,0 +1,17 @@ +using System.Data; + +namespace Extensions +{ + public static class DbConnectionExtensions + { + public static IDbTransaction BeginSerializableTransaction(this IDbConnection dbConnection) + { + // Dapper takes care of the connection for other queries, + // but using ADO transactions we need to open it if closed + if(dbConnection.State is ConnectionState.Closed) + dbConnection.Open(); + + return dbConnection.BeginTransaction(IsolationLevel.Serializable); + } + } +} diff --git a/api/Extensions/LoggingExtensions.cs b/api/Extensions/LoggingExtensions.cs new file mode 100644 index 0000000..e49d4af --- /dev/null +++ b/api/Extensions/LoggingExtensions.cs @@ -0,0 +1,21 @@ +namespace Extensions +{ + public static class LoggingExtensions + { + public static void ConfigureLogging(this IServiceCollection services, IConfiguration config, string env) + { + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.AddConfiguration(config.GetSection("Logging")); + logging.AddConsole(); + +#if DEBUG + logging.AddDebug(); +#endif + + logging.SetMinimumLevel(LogLevel.Information); + }); + } + } +} diff --git a/api/Middlewares/ExceptionHandlingMiddleware.cs b/api/Middlewares/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..0f37a59 --- /dev/null +++ b/api/Middlewares/ExceptionHandlingMiddleware.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Text.Json; +using Contracts; +using Models.Errors; +using FluentValidationException = FluentValidation.ValidationException; + +namespace Middlewares +{ + + /// + /// Middleware that handles unhandled exceptions, logs them, and returns standardized error responses. + /// Also measures and logs request duration. + /// + internal class ExceptionHandlingMiddleware( + RequestDelegate next, + ILogger _logger) + { + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await next(httpContext); + } + catch (NotFoundException e) + { + await SetResponse(e, httpContext, HttpStatusCode.NotFound); + } + catch (ConflictException e) + { + await SetResponse(e, httpContext, HttpStatusCode.Conflict); + } + catch (ValidationException e) + { + await SetResponse(e, httpContext, HttpStatusCode.BadRequest); + } + catch (FluentValidationException e) + { + await SetValidationResponse(e, httpContext); + } + catch(Exception e) + { + await SetResponse(e, httpContext, HttpStatusCode.InternalServerError); + } + } + + private async Task SetResponse(Exception e, HttpContext httpContext, HttpStatusCode code) + { + if (httpContext.Response.HasStarted) + { + _logger.LogWarning("Response has already started, cannot write error details"); + return; + } + + var response = e is ResourceException resourceException + ? new ErrorResponse( + Title: code.ToString(), + Detail: GetMessage(resourceException, code), + ResourceType: resourceException.ResourceType, + ResourceId: resourceException.ResourceId + ) + : new ErrorResponse( + Title: code.ToString(), + Detail: e.Message + ); + + httpContext.Response.StatusCode = (int)code; + httpContext.Response.ContentType = "application/json"; + + var content = JsonSerializer.Serialize(response, _jsonOptions); + await httpContext.Response.WriteAsync(content); + } + + private async Task SetValidationResponse(FluentValidationException e, HttpContext httpContext) + { + if (httpContext.Response.HasStarted) + { + _logger.LogWarning("Response has already started, cannot write error details"); + return; + } + + var errors = e.Errors + .GroupBy(f => f.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(f => f.ErrorMessage).ToArray()); + + var response = new ErrorResponse( + Title: "BadRequest", + Detail: "One or more validation errors occurred", + Errors: errors + ); + + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + httpContext.Response.ContentType = "application/json"; + + var content = JsonSerializer.Serialize(response, _jsonOptions); + await httpContext.Response.WriteAsync(content); + } + + private string GetMessage(ResourceException e, HttpStatusCode code) + { + return code switch + { + HttpStatusCode.NotFound => !string.IsNullOrEmpty(e.Message) ? e.Message : $"{e.ResourceType} not found", + HttpStatusCode.Conflict => !string.IsNullOrEmpty(e.Message) ? e.Message : $"{e.ResourceType} conflict occurred", + HttpStatusCode.Unauthorized => !string.IsNullOrEmpty(e.Message) ? e.Message : "Unauthorized access", + HttpStatusCode.Forbidden => !string.IsNullOrEmpty(e.Message) ? e.Message : "Access forbidden", + HttpStatusCode.BadRequest => !string.IsNullOrEmpty(e.Message) ? e.Message : "Invalid request", + _ => !string.IsNullOrEmpty(e.Message) ? e.Message : "An error occurred" + }; + } + + } +} \ No newline at end of file diff --git a/api/Models/Errors/ConflictException.cs b/api/Models/Errors/ConflictException.cs new file mode 100644 index 0000000..1870e65 --- /dev/null +++ b/api/Models/Errors/ConflictException.cs @@ -0,0 +1,6 @@ +namespace Models.Errors +{ + public class ConflictException(string resourceType, string resourceId, string message) : ResourceException(resourceType, resourceId, message) + { + } +} diff --git a/api/Models/Errors/InvalidRoomNumber.cs b/api/Models/Errors/InvalidRoomNumber.cs deleted file mode 100644 index 59a690b..0000000 --- a/api/Models/Errors/InvalidRoomNumber.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Models.Errors -{ - public class InvalidRoomNumber : Exception - { - public InvalidRoomNumber(string invalidRoomNumber) - : base($"The value ${invalidRoomNumber} is not a valid") { } - } -} diff --git a/api/Models/Errors/NotFoundException.cs b/api/Models/Errors/NotFoundException.cs index ec73642..ac3c552 100644 --- a/api/Models/Errors/NotFoundException.cs +++ b/api/Models/Errors/NotFoundException.cs @@ -1,8 +1,6 @@ namespace Models.Errors { - public class NotFoundException : Exception + public class NotFoundException(string resourceType, string resourceId) : ResourceException(resourceType, resourceId, $"{resourceType} {resourceId} not found") { - public NotFoundException(string message) - : base(message) { } } } diff --git a/api/Models/Errors/ResourceException.cs b/api/Models/Errors/ResourceException.cs new file mode 100644 index 0000000..b3dd727 --- /dev/null +++ b/api/Models/Errors/ResourceException.cs @@ -0,0 +1,13 @@ +namespace Models.Errors +{ + public abstract class ResourceException(Exception? innerException, string resourceType, string resourceId, string message) : Exception(message, innerException) + { + public string ResourceType { get; } = resourceType; + public string ResourceId { get; } = resourceId; + + + public ResourceException(string resourceType, string resourceId, string message) : this(null, resourceType, resourceId, message) + { + } + } +} \ No newline at end of file diff --git a/api/Models/Errors/ValidationException.cs b/api/Models/Errors/ValidationException.cs new file mode 100644 index 0000000..a440e1e --- /dev/null +++ b/api/Models/Errors/ValidationException.cs @@ -0,0 +1,6 @@ +namespace Models.Errors +{ + public class ValidationException(string resourceType, string resourceId, string message) : ResourceException(resourceType, resourceId, message) + { + } +} diff --git a/api/Models/Reservation.cs b/api/Models/Reservation.cs index 978929a..114e954 100644 --- a/api/Models/Reservation.cs +++ b/api/Models/Reservation.cs @@ -15,5 +15,6 @@ public class Reservation public DateTime End { get; set; } public bool CheckedIn { get; set; } public bool CheckedOut { get; set; } + public DateTime? CheckedOutAt { get; set; } } } diff --git a/api/Models/Room.cs b/api/Models/Room.cs index cbd6536..ca627e2 100644 --- a/api/Models/Room.cs +++ b/api/Models/Room.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Models.Errors; namespace Models @@ -7,10 +8,11 @@ namespace Models /// public class Room { + private static readonly Regex RoomNumberPattern = new(@"^[0-9]\d{2}$"); // allow ground floor 0 + /// - /// PKID For Rooms. MewsHotel format is a three digit number with the first - /// number being the floor number (up to 9) and the remaining two digits - /// as the number of the door on the floor + /// PKID For Rooms. Format is "###" where first digit is floor (1-9) + /// and last two digits are the door number (01-99). /// public required string Number { get; set; } @@ -20,24 +22,19 @@ public class Room public State State { get; set; } = State.Ready; /// - /// Formats the room number filling it with 0s - /// to get a three digit string + /// Validates the room number format. Must be 3 digits, first digit 1-9, last two not "00". /// - /// - public static string FormatRoomNumber(int number) + public static bool IsValidRoomNumber(string roomNumber) { - return number.ToString().PadLeft(3, '0'); + return RoomNumberPattern.IsMatch(roomNumber) && roomNumber[1..] != "00"; } - public static int ConvertRoomNumberToInt(string roomNumber) + public static void ValidateRoomNumber(string roomNumber) { - var success = int.TryParse(roomNumber, out int roomNumberInt); - if (!success) + if (!IsValidRoomNumber(roomNumber)) { - throw new InvalidRoomNumber(roomNumber); + throw new ValidationException(nameof(Room), roomNumber, $"The value {roomNumber} is not a valid room number"); } - - return roomNumberInt; } } diff --git a/api/Program.cs b/api/Program.cs index 52dc5a2..60c6baf 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,48 +1,83 @@ using System.Data; +using System.Text; +using Dapper; using Db; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Data.Sqlite; +using Microsoft.IdentityModel.Tokens; using Repositories; +using Extensions; +using FluentValidation; +using Middlewares; + +SqlMapper.AddTypeHandler(new GuidTypeHandler()); var builder = WebApplication.CreateBuilder(args); { var Services = builder.Services; + Services.ConfigureLogging(builder.Configuration, builder.Environment.EnvironmentName); var connectionString = builder.Configuration.GetConnectionString("ReservationsDb") ?? "Data Source=reservations.db;Cache=Shared"; - Services.AddSingleton(_ => new SqliteConnection(connectionString)); - Services.AddSingleton(sp => sp.GetRequiredService()); - Services.AddSingleton(); - Services.AddSingleton(); - Services.AddSingleton(); + Services.AddScoped(_ => new SqliteConnection(connectionString)); + Services.AddScoped(sp => sp.GetRequiredService()); + Services.AddScoped(); + Services.AddScoped(); + Services.AddScoped(); Services.AddMvc(opt => { opt.EnableEndpointRouting = false; }); + Services.AddValidatorsFromAssemblyContaining(); + Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)) + }; + }); + Services.AddAuthorization(); Services.AddCors(); Services.AddEndpointsApiExplorer(); Services.AddSwaggerGen(); } var app = builder.Build(); - +var logger = app.Services.GetRequiredService>(); { try { - Setup.EnsureDb(app.Services.CreateScope()); + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Setup.EnsureDb(db); + + if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Testing") + { + await Seed.SeedData(db); + } } catch (Exception ex) { - Console.WriteLine("Failed to setup the database, aborting"); - Console.WriteLine(ex.ToString()); + logger.LogCritical(ex, "Failed to setup the database, aborting"); Environment.Exit(1); return; } + app.UseMiddleware(); + app.UsePathBase("/api") + .UseAuthentication() + .UseAuthorization() .UseMvc() .UseCors(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()) .UseSwagger() @@ -50,3 +85,5 @@ } app.Run(); + +public partial class Program { } diff --git a/api/Repositories/GuestRepository.cs b/api/Repositories/GuestRepository.cs index 54182bc..4014b56 100644 --- a/api/Repositories/GuestRepository.cs +++ b/api/Repositories/GuestRepository.cs @@ -35,7 +35,7 @@ public async Task GetGuestByEmail(string guestEmail) if (guest == null) { - throw new NotFoundException($"Guest {guestEmail} not found"); + throw new NotFoundException(nameof(Guest), guestEmail); } return guest; diff --git a/api/Repositories/ReservationRepository.cs b/api/Repositories/ReservationRepository.cs index 5e0dd1c..865ac50 100644 --- a/api/Repositories/ReservationRepository.cs +++ b/api/Repositories/ReservationRepository.cs @@ -1,7 +1,9 @@ using System.Data; using Dapper; using Models; +using Contracts; using Models.Errors; +using Extensions; namespace Repositories { @@ -14,99 +16,245 @@ public ReservationRepository(IDbConnection db) _db = db; } - public async Task> GetReservations() + public async Task> GetReservations( + DateTime? from = null, + DateTime? to = null, + string? roomNumber = null, + string? guestEmail = null) { - var reservations = await _db.QueryAsync("SELECT * FROM Reservations"); + var sql = "SELECT * FROM Reservations WHERE 1=1"; + var parameters = new DynamicParameters(); - if (reservations == null) + if (from.HasValue) { - return []; + sql += " AND [End] >= @From"; + parameters.Add("From", from.Value); } - return reservations.Select(r => r.ToDomain()); + if (to.HasValue) + { + sql += " AND Start <= @To"; + parameters.Add("To", to.Value); + } + + if (!string.IsNullOrEmpty(roomNumber)) + { + sql += " AND RoomNumber = @RoomNumber"; + parameters.Add("RoomNumber", roomNumber); + } + + if (!string.IsNullOrEmpty(guestEmail)) + { + sql += " AND GuestEmail LIKE @GuestEmail"; + parameters.Add("GuestEmail", $"%{guestEmail}%"); + } + + sql += " ORDER BY Start"; + + var reservations = await _db.QueryAsync(sql, parameters); + return reservations ?? []; } /// /// Find a reservation by its Guid ID, throwing if not found /// - /// - /// An existing reservation /// public async Task GetReservation(Guid reservationId) { - var reservation = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Reservations WHERE Id = @reservationIdStr;", - new { reservationIdStr = reservationId.ToString() } + var reservation = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Reservations WHERE Id = @reservationId;", + new { reservationId } ); if (reservation == null) { - throw new NotFoundException($"Room {reservationId} not found"); + throw new NotFoundException(nameof(Reservation), reservationId.ToString()); } - return reservation.ToDomain(); + return reservation; } - public async Task CreateReservation(Reservation newReservation) + public async Task CreateReservation(ReservationRequest request) { - // TODO Implement - return await Task.FromResult( - new Reservation { RoomNumber = "000", GuestEmail = "todo" } - ); + ArgumentNullException.ThrowIfNull(request); + + + using var transaction = _db.BeginSerializableTransaction(); + + try + { + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @RoomNumber;", + new { request.RoomNumber }, + transaction + ) ?? throw new NotFoundException(nameof(Room), request.RoomNumber); + + var conflict = await _db.QueryFirstOrDefaultAsync( + @"SELECT * FROM Reservations + WHERE RoomNumber = @RoomNumber + AND Start < @End + AND [End] > @Start", + new { request.RoomNumber, request.Start, request.End }, + transaction + ); + + if (conflict != null) + { + throw new ConflictException( + nameof(Reservation), + request.RoomNumber, + $"Room {request.RoomNumber} is already booked from {conflict.Start:yyyy-MM-dd} to {conflict.End:yyyy-MM-dd}" + ); + } + + await _db.ExecuteAsync( + "INSERT OR IGNORE INTO Guests(Email, Name) VALUES(@GuestEmail, @GuestEmail)", + new { request.GuestEmail }, + transaction + ); + + var reservation = new Reservation + { + Id = Guid.NewGuid(), + RoomNumber = request.RoomNumber, + GuestEmail = request.GuestEmail, + Start = request.Start, + End = request.End + }; + + var created = await _db.QuerySingleAsync( + @"INSERT INTO Reservations(Id, GuestEmail, RoomNumber, Start, End) + VALUES(@Id, @GuestEmail, @RoomNumber, @Start, @End) + RETURNING *", + reservation, + transaction + ); + + transaction.Commit(); + return created; + } + catch + { + transaction.Rollback(); + throw; + } } - public async Task DeleteReservation(Guid reservationId) + public async Task CheckIn(Guid reservationId, string guestEmail) { - var deleted = await _db.ExecuteAsync( - "DELETE FROM Reservations WHERE Id = @reservationIdStr;", - new { reservationIdStr = reservationId.ToString() } + var reservation = await GetReservation(reservationId); + + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest email does not match the reservation"); + } + + if (reservation.CheckedIn) + { + throw new ConflictException(nameof(Reservation), reservationId.ToString(), "Reservation is already checked in"); + } + + if (reservation.Start > DateTime.Today || reservation.End <= DateTime.Today) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Check-in is only allowed during the reservation period"); + } + + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @RoomNumber", + new { reservation.RoomNumber } ); - return deleted > 0; + if (room != null && room.State == State.Dirty) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Cannot check in to a dirty room. Room must be cleaned first"); + } + + using var transaction = _db.BeginSerializableTransaction(); + try + { + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedIn = 1 WHERE Id = @reservationId", + new { reservationId }, + transaction + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", + new { State = (int)State.Occupied, reservation.RoomNumber }, + transaction + ); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + + reservation.CheckedIn = true; + return reservation; } - private class ReservationDb + public async Task CheckOut(Guid reservationId, string guestEmail) { - public string Id { get; set; } - public int RoomNumber { get; set; } + var reservation = await GetReservation(reservationId); - public string GuestEmail { get; set; } + if (!string.Equals(reservation.GuestEmail, guestEmail, StringComparison.OrdinalIgnoreCase)) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest email does not match the reservation"); + } - public DateTime Start { get; set; } - public DateTime End { get; set; } - public bool CheckedIn { get; set; } - public bool CheckedOut { get; set; } + if (!reservation.CheckedIn) + { + throw new ValidationException(nameof(Reservation), reservationId.ToString(), "Guest has not checked in yet"); + } - public ReservationDb() + if (reservation.CheckedOut) { - Id = Guid.Empty.ToString(); - RoomNumber = 0; - GuestEmail = ""; + throw new ConflictException(nameof(Reservation), reservationId.ToString(), "Reservation is already checked out"); } - public ReservationDb(Reservation reservation) + var checkedOutAt = DateTime.Now; + + using var transaction = _db.BeginSerializableTransaction(); + try + { + await _db.ExecuteAsync( + "UPDATE Reservations SET CheckedOut = 1, CheckedOutAt = @checkedOutAt WHERE Id = @reservationId", + new { reservationId, checkedOutAt }, + transaction + ); + + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @RoomNumber", + new { State = (int)State.Dirty, reservation.RoomNumber }, + transaction + ); + + transaction.Commit(); + } + catch { - Id = reservation.Id.ToString(); - RoomNumber = Room.ConvertRoomNumberToInt(reservation.RoomNumber); - GuestEmail = reservation.GuestEmail; - Start = reservation.Start; - End = reservation.End; - CheckedIn = reservation.CheckedIn; - CheckedOut = reservation.CheckedOut; + transaction.Rollback(); + throw; } - public Reservation ToDomain() + reservation.CheckedOut = true; + reservation.CheckedOutAt = checkedOutAt; + return reservation; + } + + public async Task DeleteReservation(Guid reservationId) + { + var deleted = await _db.ExecuteAsync( + "DELETE FROM Reservations WHERE Id = @reservationId;", + new { reservationId } + ); + + if (deleted == 0) { - return new Reservation - { - Id = Guid.Parse(Id), - RoomNumber = Room.FormatRoomNumber(RoomNumber), - GuestEmail = GuestEmail, - Start = Start, - End = End, - CheckedIn = CheckedIn, - CheckedOut = CheckedOut - }; + throw new NotFoundException(nameof(Reservation), reservationId.ToString()); } } } diff --git a/api/Repositories/RoomRepository.cs b/api/Repositories/RoomRepository.cs index 2b9f904..0194ea2 100644 --- a/api/Repositories/RoomRepository.cs +++ b/api/Repositories/RoomRepository.cs @@ -1,5 +1,6 @@ using System.Data; using Dapper; +using Extensions; using Models; using Models.Errors; @@ -15,87 +16,123 @@ public RoomRepository(IDbConnection db) } /// - /// Find a room by its formatted room number, throwing if not found + /// Find a room by its room number, throwing if not found /// - /// - /// An existing room /// public async Task GetRoom(string roomNumber) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + Room.ValidateRoomNumber(roomNumber); - var room = await _db.QueryFirstOrDefaultAsync( - "SELECT * FROM Rooms WHERE Number = @roomNumberInt;", - new { roomNumberInt } + var room = await _db.QueryFirstOrDefaultAsync( + "SELECT * FROM Rooms WHERE Number = @roomNumber;", + new { roomNumber } ); if (room == null) { - throw new NotFoundException($"Room {roomNumber} not found"); + throw new NotFoundException(nameof(Room), roomNumber); } - return room.ToDomain(); + return room; } public async Task> GetRooms() { - var rooms = await _db.QueryAsync("SELECT * FROM Rooms"); + var rooms = await _db.QueryAsync("SELECT * FROM Rooms"); if (rooms == null) { return []; } - return rooms.Select(r => r.ToDomain()); + return rooms; } public async Task CreateRoom(Room newRoom) { - var createdRoom = await _db.QuerySingleAsync( + Room.ValidateRoomNumber(newRoom.Number); + + var createdRoom = await _db.QuerySingleAsync( "INSERT INTO Rooms(Number, State) Values(@Number, @State) RETURNING *", - new RoomDb(newRoom) + newRoom ); - return createdRoom.ToDomain(); + return createdRoom; } - public async Task DeleteRoom(string roomNumber) + public async Task CreateRoomsBatch(List<(int Line, Room Room)> rooms, List errors) { - var roomNumberInt = Room.ConvertRoomNumberToInt(roomNumber); + if (rooms.Count == 0) return 0; - var deleted = await _db.ExecuteAsync( - "DELETE FROM Rooms WHERE Number = @roomNumberInt;", - new { roomNumberInt } - ); + using var transaction = _db.BeginSerializableTransaction(); + var imported = 0; - return deleted > 0; + try + { + var existingNumbers = (await _db.QueryAsync( + "SELECT Number FROM Rooms WHERE Number IN @Numbers", + new { Numbers = rooms.Select(r => r.Room.Number).ToList() }, + transaction + )).ToHashSet(); + + foreach (var (line, room) in rooms) + { + if (existingNumbers.Contains(room.Number)) + { + errors.Add(new Contracts.ImportError(line, room.Number, $"Room {room.Number} already exists")); + continue; + } + + await _db.ExecuteAsync( + "INSERT INTO Rooms(Number, State) VALUES(@Number, @State)", + room, + transaction + ); + imported++; + } + + transaction.Commit(); + return imported; + } + catch + { + transaction.Rollback(); + throw; + } } - // Inner class to hide the details of a direct mapping to SQLite - private class RoomDb + public async Task RoomExists(string roomNumber) { - /// - /// PKID For Rooms. SQLite stores as an integer - /// - public int Number { get; set; } + var count = await _db.ExecuteScalarAsync( + "SELECT COUNT(1) FROM Rooms WHERE Number = @roomNumber", + new { roomNumber } + ); + return count > 0; + } - /// - /// Whether the room is available for reservation - /// - public State State { get; set; } = State.Ready; + public async Task UpdateRoomState(string roomNumber, State state) + { + var room = await GetRoom(roomNumber); - public RoomDb() { } + await _db.ExecuteAsync( + "UPDATE Rooms SET State = @State WHERE Number = @roomNumber", + new { State = (int)state, roomNumber } + ); - public RoomDb(Room room) - { - Number = Room.ConvertRoomNumberToInt(room.Number); - State = room.State; - } + room.State = state; + return room; + } - public Room ToDomain() - { - return new Room { Number = Room.FormatRoomNumber(Number), State = State }; - } + public async Task DeleteRoom(string roomNumber) + { + Room.ValidateRoomNumber(roomNumber); + + var deleted = await _db.ExecuteAsync( + "DELETE FROM Rooms WHERE Number = @roomNumber;", + new { roomNumber } + ); + + return deleted > 0; } } } diff --git a/api/Validators/ReservationRequestValidator.cs b/api/Validators/ReservationRequestValidator.cs new file mode 100644 index 0000000..743d984 --- /dev/null +++ b/api/Validators/ReservationRequestValidator.cs @@ -0,0 +1,44 @@ +using FluentValidation; +using Contracts; +using Models; + +namespace Validators +{ + public class ReservationRequestValidator : AbstractValidator + { + public ReservationRequestValidator() + { + RuleFor(x => x.GuestEmail) + .NotEmpty().WithMessage("Email is required") + .EmailAddress().WithMessage("Email must be a valid email address") + .Must(email => email.Contains('.')).WithMessage("Email must include a domain"); + + RuleFor(x => x.RoomNumber) + .NotEmpty().WithMessage("Room number is required") + .Must(Room.IsValidRoomNumber).WithMessage("Room number must be in format '###' (e.g. 101, 202)"); + + RuleFor(x => x.Start) + .NotEmpty().WithMessage("Start date is required") + .LessThan(x => x.End).WithMessage("Start date must be before end date") + .Must(start => start >= DateTime.Today).WithMessage("Time travellers are not welcomed in Mewstel"); + + RuleFor(x => x.End) + .NotEmpty().WithMessage("End date is required") + .GreaterThan(x => x.Start).WithMessage("End date must be after start date"); + + RuleFor(x => x) + .Must(HaveMinimumDuration).WithMessage("Minimum booking duration is 1 day") + .Must(HaveMaximumDuration).WithMessage("Maximum booking duration is 30 days"); + } + + private static bool HaveMinimumDuration(ReservationRequest request) + { + return (request.End - request.Start).TotalDays >= 1; + } + + private static bool HaveMaximumDuration(ReservationRequest request) + { + return (request.End - request.Start).TotalDays <= 30; + } + } +} diff --git a/api/api.csproj b/api/api.csproj index ef55adc..0afb679 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -8,6 +8,8 @@ + + diff --git a/api/appsettings.json b/api/appsettings.json index c06ebf6..1c4c193 100644 --- a/api/appsettings.json +++ b/api/appsettings.json @@ -6,5 +6,9 @@ } }, "AllowedHosts": "*", - "staffAccessCode": "pass" + "staffAccessCode": "pass", + "Jwt": { + "Key": "MewstelSuperSecretKeyForJwtSigning", + "ExpiryHours": 8 + } } diff --git a/backlog/RE-006.md b/backlog/RE-006.md index 8a5162d..dae5dbd 100644 --- a/backlog/RE-006.md +++ b/backlog/RE-006.md @@ -2,5 +2,5 @@ Staff must be able to mark a room as clean or dirty -- Check in automatically marks the room as dirty +- Check out automatically marks the room as dirty - Staff cannot check in a guest to a dirty room diff --git a/test-rooms.csv b/test-rooms.csv new file mode 100644 index 0000000..73a3e52 --- /dev/null +++ b/test-rooms.csv @@ -0,0 +1,103 @@ +number +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +000 +abc +100 diff --git a/tests/api.IntegrationTests/.gitignore b/tests/api.IntegrationTests/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tests/api.IntegrationTests/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tests/api.IntegrationTests/ExceptionHandlingTests.cs b/tests/api.IntegrationTests/ExceptionHandlingTests.cs new file mode 100644 index 0000000..e11485a --- /dev/null +++ b/tests/api.IntegrationTests/ExceptionHandlingTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace api.IntegrationTests; + +public class ExceptionHandlingTests : IClassFixture +{ + private readonly HttpClient _client; + + public ExceptionHandlingTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + [Fact] + public async Task Returns_404_with_error_body_when_resource_not_found() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var body = await DeserializeResponse(response); + Assert.Equal("Reservation", body.ResourceType); + Assert.Equal(fakeId.ToString(), body.ResourceId); + Assert.Equal("NotFound", body.Title); + Assert.False(string.IsNullOrEmpty(body.Detail)); + } + + [Fact] + public async Task Returns_400_with_error_body_when_validation_fails() + { + var response = await _client.GetAsync("/api/rooms/abc"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + + var body = await DeserializeResponse(response); + Assert.Equal("Room", body.ResourceType); + Assert.Equal("abc", body.ResourceId); + Assert.Equal("BadRequest", body.Title); + Assert.Contains("not a valid room number", body.Detail); + } + + [Fact] + public async Task Returns_json_content_type_for_error_responses() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Error_response_contains_all_expected_fields() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + var content = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("resourceType", out _)); + Assert.True(root.TryGetProperty("resourceId", out _)); + Assert.True(root.TryGetProperty("detail", out _)); + Assert.True(root.TryGetProperty("title", out _)); + } + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static async Task DeserializeResponse(HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, _jsonOptions) + ?? throw new Exception("Failed to deserialize error response"); + } + + private record ErrorResponse(string ResourceType, string ResourceId, string Detail, string Title); +} diff --git a/tests/api.IntegrationTests/ReservationsEndpointTests.cs b/tests/api.IntegrationTests/ReservationsEndpointTests.cs new file mode 100644 index 0000000..d98f1a4 --- /dev/null +++ b/tests/api.IntegrationTests/ReservationsEndpointTests.cs @@ -0,0 +1,252 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Contracts; +using Models; + +namespace api.IntegrationTests; + +/// +/// Tests run against in-memory sqlite. Room numbers (existing) should differ along tests to avoid tests conflicts. +/// +public class ReservationsEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public ReservationsEndpointTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + + [Fact] + public async Task Post_valid_reservation_returns_201() + { + var booking = new ReservationRequest + { + RoomNumber = "101", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + + Assert.True( + response.StatusCode == HttpStatusCode.Created, + $"Expected 201 but got {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}" + ); + + var reservation = await response.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(reservation); + Assert.NotEqual(Guid.Empty, reservation.Id); + Assert.Equal("101", reservation.RoomNumber); + Assert.Equal("guest@mjail.com", reservation.GuestEmail); + } + + [Fact] + public async Task Get_nonexistent_reservation_returns_404() + { + var fakeId = Guid.NewGuid(); + var response = await _client.GetAsync($"/api/reservations/{fakeId}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(error); + Assert.Equal("NotFound", error.Title); + Assert.Equal("Reservation", error.ResourceType); + } + + + [Fact] + public async Task Post_reservation_with_nonexistent_room_returns_404() + { + var booking = new ReservationRequest + { + RoomNumber = "999", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.NotNull(error); + Assert.Equal("NotFound", error.Title); + Assert.Equal("Room", error.ResourceType); + Assert.Equal("999", error.ResourceId); + } + + [Fact] + public async Task Post_reservation_with_invalid_data_returns_400_with_errors() + { + var invalidBooking = new ReservationRequest + { + RoomNumber = "000", + GuestEmail = "not-an-email", + Start = DateTime.Today.AddDays(-1), + End = DateTime.Today.AddDays(-1) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", invalidBooking); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error); + Assert.Equal("BadRequest", error.Title); + Assert.NotNull(error.Errors); + Assert.True(error.Errors.ContainsKey("RoomNumber")); + Assert.True(error.Errors.ContainsKey("GuestEmail")); + Assert.True(error.Errors.ContainsKey("Start")); + } + + [Fact] + public async Task Post_reservation_with_invalid_room_number_returns_room_error() + { + var booking = new ReservationRequest + { + RoomNumber = "abc", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error?.Errors); + Assert.True(error.Errors.ContainsKey("RoomNumber")); + Assert.False(error.Errors.ContainsKey("GuestEmail")); + } + + [Fact] + public async Task Post_reservation_with_invalid_email_returns_email_error() + { + var booking = new ReservationRequest + { + RoomNumber = "101", + GuestEmail = "nodomain", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + var response = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.NotNull(error?.Errors); + Assert.True(error.Errors.ContainsKey("GuestEmail")); + Assert.False(error.Errors.ContainsKey("RoomNumber")); + } + + [Fact] + public async Task Post_double_booking_same_dates_returns_409() + { + var booking = new ReservationRequest + { + RoomNumber = "201", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var first = await _client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, first.StatusCode); + + var second = await _client.PostAsJsonAsync("/api/reservations", booking); + var error = await second.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); + Assert.NotNull(error); + Assert.Equal("Conflict", error.Title); + Assert.Equal("Reservation", error.ResourceType); + Assert.Contains("201", error.Detail); + } + + [Fact] + public async Task Post_overlapping_booking_returns_409() + { + var first = new ReservationRequest + { + RoomNumber = "202", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(15) + }; + + var firstResponse = await _client.PostAsJsonAsync("/api/reservations", first); + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + + var overlapping = new ReservationRequest + { + RoomNumber = "202", + GuestEmail = "other@mjail.com", + Start = DateTime.Today.AddDays(13), + End = DateTime.Today.AddDays(18) + }; + + var secondResponse = await _client.PostAsJsonAsync("/api/reservations", overlapping); + var error = await secondResponse.Content.ReadFromJsonAsync(_jsonOptions); + + Assert.Equal(HttpStatusCode.Conflict, secondResponse.StatusCode); + Assert.NotNull(error); + Assert.Equal("Conflict", error.Title); + } + + [Fact] + public async Task Post_non_overlapping_booking_same_room_succeeds() + { + var first = new ReservationRequest + { + RoomNumber = "203", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var firstResponse = await _client.PostAsJsonAsync("/api/reservations", first); + Assert.Equal(HttpStatusCode.Created, firstResponse.StatusCode); + + var nonOverlapping = new ReservationRequest + { + RoomNumber = "203", + GuestEmail = "other@mjail.com", + Start = DateTime.Today.AddDays(13), + End = DateTime.Today.AddDays(16) + }; + + var secondResponse = await _client.PostAsJsonAsync("/api/reservations", nonOverlapping); + Assert.Equal(HttpStatusCode.Created, secondResponse.StatusCode); + } + + [Fact] + public async Task Post_concurrent_double_booking_same_dates_returns_409() + { + var booking = new ReservationRequest + { + RoomNumber = "103", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(10), + End = DateTime.Today.AddDays(13) + }; + + var task1 = _client.PostAsJsonAsync("/api/reservations", booking); + var task2 = _client.PostAsJsonAsync("/api/reservations", booking); + var task3 = _client.PostAsJsonAsync("/api/reservations", booking); + var task4 = _client.PostAsJsonAsync("/api/reservations", booking); + + var responses = await Task.WhenAll(task1, task2, task3, task4); + + var statuses = responses.Select(r => r.StatusCode).ToList(); + Assert.Equal(1, statuses.Count(s => s == HttpStatusCode.Created)); + Assert.Equal(3, statuses.Count(s => s == HttpStatusCode.Conflict)); + } +} diff --git a/tests/api.IntegrationTests/StaffEndpointTests.cs b/tests/api.IntegrationTests/StaffEndpointTests.cs new file mode 100644 index 0000000..309dfd5 --- /dev/null +++ b/tests/api.IntegrationTests/StaffEndpointTests.cs @@ -0,0 +1,480 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Contracts; +using Models; + +namespace api.IntegrationTests; + +public class StaffEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public StaffEndpointTests(TestWebApplicationFactory factory) + { + _client = factory.CreateClient(); + } + + #region Helpers + + private async Task GetStaffToken() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "pass"); + + var response = await _client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("token").GetString()!; + } + + private async Task GetAuthenticatedClient() + { + var token = await GetStaffToken(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return _client; + } + + private static MultipartFormDataContent CreateCsvFile(string content, string fileName = "rooms.csv") + { + var formData = new MultipartFormDataContent(); + var fileContent = new StringContent(content); + formData.Add(fileContent, "file", fileName); + return formData; + } + + #endregion + + #region Auth + + [Fact] + public async Task Login_with_valid_code_returns_token() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "pass"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync(); + Assert.True(body.TryGetProperty("token", out var token)); + Assert.False(string.IsNullOrEmpty(token.GetString())); + } + + [Fact] + public async Task Login_with_invalid_code_returns_401() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/staff/login"); + request.Headers.Add("X-Staff-Code", "wrong"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Get_reservations_without_token_returns_401() + { + var client = _client; + client.DefaultRequestHeaders.Authorization = null; + + var response = await client.GetAsync("/api/reservations"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Delete_reservation_without_token_returns_401() + { + var client = _client; + client.DefaultRequestHeaders.Authorization = null; + + var response = await client.DeleteAsync($"/api/reservations/{Guid.NewGuid()}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion + + #region Reservations + + [Fact] + public async Task Get_reservations_with_token_returns_200() + { + var client = await GetAuthenticatedClient(); + + var response = await client.GetAsync("/api/reservations"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Get_reservations_filtered_by_from_date() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "104", + GuestEmail = "staff-test@mjail.com", + Start = DateTime.Today.AddDays(50), + End = DateTime.Today.AddDays(53) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + // should include our booking + var from = DateTime.Today.AddDays(1).ToString("yyyy-MM-dd"); + var response = await client.GetAsync($"/api/reservations?from={from}"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.Contains(reservations, r => r.RoomNumber == "104"); + } + + [Fact] + public async Task Get_reservations_filtered_by_room_number() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "105", + GuestEmail = "room-filter@mjail.com", + Start = DateTime.Today.AddDays(60), + End = DateTime.Today.AddDays(63) + }; + await client.PostAsJsonAsync("/api/reservations", booking); + + var response = await client.GetAsync("/api/reservations?roomNumber=105"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.All(reservations, r => Assert.Equal("105", r.RoomNumber)); + } + + [Fact] + public async Task Get_reservations_filtered_by_guest_email() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "201", + GuestEmail = "email-filter@mjail.com", + Start = DateTime.Today.AddDays(70), + End = DateTime.Today.AddDays(73) + }; + await client.PostAsJsonAsync("/api/reservations", booking); + + var response = await client.GetAsync("/api/reservations?guestEmail=email-filter@mjail.com"); + var reservations = await response.Content.ReadFromJsonAsync>(_jsonOptions); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(reservations); + Assert.All(reservations, r => Assert.Equal("email-filter@mjail.com", r.GuestEmail)); + } + + #endregion + + #region Check-in + + [Fact] + public async Task CheckIn_today_reservation_returns_200_and_marks_checked_in() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "104", + GuestEmail = "checkin-test@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + // Check in + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-in", + new { guestEmail = "checkin-test@mjail.com" }); + + Assert.Equal(HttpStatusCode.OK, checkInResponse.StatusCode); + var checkedIn = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(checkedIn); + Assert.True(checkedIn.CheckedIn); + + // Verify room is marked as occupied + var roomResponse = await client.GetAsync($"/api/rooms/{created.RoomNumber}"); + var room = await roomResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(room); + Assert.Equal((int)State.Occupied, (int)room.State); + } + + [Fact] + public async Task CheckIn_with_wrong_email_returns_400() + { + var client = await GetAuthenticatedClient(); + + var booking = new ReservationRequest + { + RoomNumber = "105", + GuestEmail = "correct@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + // Check in with wrong email + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-in", + new { guestEmail = "wrong@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkInResponse.StatusCode); + var error = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(error); + Assert.Contains("email", error.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CheckIn_dirty_room_returns_400() + { + var client = await GetAuthenticatedClient(); + + // Create the room first + await client.PostAsJsonAsync("/api/rooms", new { number = "501", state = 0 }); + + var booking1 = new ReservationRequest + { + RoomNumber = "501", + GuestEmail = "first@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(1) + }; + var create1 = await client.PostAsJsonAsync("/api/reservations", booking1); + var res1 = await create1.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(res1); + + await client.PostAsJsonAsync($"/api/reservations/{res1.Id}/check-in", + new { guestEmail = "first@mjail.com" }); + + // Check out to mark room dirty + await client.PostAsJsonAsync($"/api/reservations/{res1.Id}/check-out", + new { guestEmail = "first@mjail.com" }); + + await client.DeleteAsync($"/api/reservations/{res1.Id}"); + + // Try to check in another reservation + var booking2 = new ReservationRequest + { + RoomNumber = "501", + GuestEmail = "second@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(3) + }; + var create2 = await client.PostAsJsonAsync("/api/reservations", booking2); + var res2 = await create2.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(res2); + + var checkInResponse = await client.PostAsJsonAsync( + $"/api/reservations/{res2.Id}/check-in", + new { guestEmail = "second@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkInResponse.StatusCode); + var error = await checkInResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(error); + Assert.Contains("dirty", error.Detail, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Check-out + + [Fact] + public async Task CheckOut_checked_in_reservation_returns_200_and_marks_room_dirty() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "601", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "601", + GuestEmail = "checkout@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + await client.PostAsJsonAsync($"/api/reservations/{created.Id}/check-in", + new { guestEmail = "checkout@mjail.com" }); + + var checkOutResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "checkout@mjail.com" }); + + Assert.Equal(HttpStatusCode.OK, checkOutResponse.StatusCode); + var result = await checkOutResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(result); + Assert.True(result.CheckedOut); + Assert.NotNull(result.CheckedOutAt); + + var roomResponse = await client.GetFromJsonAsync("/api/rooms/601"); + Assert.Equal(2, roomResponse.GetProperty("state").GetInt32()); // Dirty + } + + [Fact] + public async Task CheckOut_without_check_in_returns_400() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "602", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "602", + GuestEmail = "notin@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + var checkOutResponse = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "notin@mjail.com" }); + + Assert.Equal(HttpStatusCode.BadRequest, checkOutResponse.StatusCode); + } + + [Fact] + public async Task CheckOut_already_checked_out_returns_409() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "603", state = 0 }); + + var booking = new ReservationRequest + { + RoomNumber = "603", + GuestEmail = "double-out@mjail.com", + Start = DateTime.Today, + End = DateTime.Today.AddDays(2) + }; + var createResponse = await client.PostAsJsonAsync("/api/reservations", booking); + var created = await createResponse.Content.ReadFromJsonAsync(_jsonOptions); + Assert.NotNull(created); + + await client.PostAsJsonAsync($"/api/reservations/{created.Id}/check-in", + new { guestEmail = "double-out@mjail.com" }); + await client.PostAsJsonAsync($"/api/reservations/{created!.Id}/check-out", + new { guestEmail = "double-out@mjail.com" }); + + var secondCheckOut = await client.PostAsJsonAsync( + $"/api/reservations/{created.Id}/check-out", + new { guestEmail = "double-out@mjail.com" }); + + Assert.Equal(HttpStatusCode.Conflict, secondCheckOut.StatusCode); + } + + #endregion + + #region Import + + [Fact] + public async Task Import_valid_csv_returns_200_with_results() + { + var client = await GetAuthenticatedClient(); + var csv = "number\n301\n302\n303"; + + var response = await client.PostAsync("/api/rooms/import", CreateCsvFile(csv)); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(3, result.GetProperty("imported").GetInt32()); + Assert.Equal(0, result.GetProperty("failed").GetInt32()); + } + + [Fact] + public async Task Import_csv_with_invalid_rooms_returns_errors() + { + var client = await GetAuthenticatedClient(); + var csv = "number\n401\n000\nabc\n402"; + + var response = await client.PostAsync("/api/rooms/import", CreateCsvFile(csv)); + var result = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, result.GetProperty("imported").GetInt32()); + Assert.Equal(2, result.GetProperty("failed").GetInt32()); + + var errors = result.GetProperty("errors"); + Assert.Equal(2, errors.GetArrayLength()); + } + + #endregion + + #region Room Status + + [Fact] + public async Task Patch_room_state_to_ready_returns_200() + { + var client = await GetAuthenticatedClient(); + + await client.PostAsJsonAsync("/api/rooms", new { number = "701", state = 0 }); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/701") + { + Content = JsonContent.Create(new { state = 2 }) // Dirty + }; + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var room = await response.Content.ReadFromJsonAsync(); + Assert.Equal(2, room.GetProperty("state").GetInt32()); + + // Now mark as ready + var request2 = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/701") + { + Content = JsonContent.Create(new { state = 0 }) + }; + var response2 = await client.SendAsync(request2); + + Assert.Equal(HttpStatusCode.OK, response2.StatusCode); + var room2 = await response2.Content.ReadFromJsonAsync(); + Assert.Equal(0, room2.GetProperty("state").GetInt32()); + } + + [Fact] + public async Task Patch_nonexistent_room_returns_404() + { + var client = await GetAuthenticatedClient(); + + var request = new HttpRequestMessage(HttpMethod.Patch, "/api/rooms/999") + { + Content = JsonContent.Create(new { state = 0 }) + }; + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion +} diff --git a/tests/api.IntegrationTests/TestWebApplicationFactory.cs b/tests/api.IntegrationTests/TestWebApplicationFactory.cs new file mode 100644 index 0000000..75af634 --- /dev/null +++ b/tests/api.IntegrationTests/TestWebApplicationFactory.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using System.Data; + +namespace api.IntegrationTests; + +public class TestWebApplicationFactory : WebApplicationFactory +{ + private const string ConnectionString = "Data Source=IntegrationTest;Mode=Memory;Cache=Shared"; + + // keep one dummy connection alive for the lifetime of the factory + // so the in-memory DB is not destroyed between scoped connections + private readonly SqliteConnection _keepAlive = new(ConnectionString); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + _keepAlive.Open(); + + builder.UseEnvironment("Testing"); + + builder.ConfigureServices(services => + { + var descriptors = services + .Where(d => d.ServiceType == typeof(SqliteConnection) || d.ServiceType == typeof(IDbConnection)) + .ToList(); + foreach (var d in descriptors) services.Remove(d); + + // each scope gets its own open connection + services.AddScoped(_ => + { + var conn = new SqliteConnection(ConnectionString); + conn.Open(); + return conn; + }); + services.AddScoped(sp => sp.GetRequiredService()); + }); + } + + protected override void Dispose(bool disposing) + { + _keepAlive.Close(); + base.Dispose(disposing); + } +} diff --git a/tests/api.IntegrationTests/api.IntegrationTests.csproj b/tests/api.IntegrationTests/api.IntegrationTests.csproj new file mode 100644 index 0000000..99b02db --- /dev/null +++ b/tests/api.IntegrationTests/api.IntegrationTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/api.UnitTests/.gitignore b/tests/api.UnitTests/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/tests/api.UnitTests/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/tests/api.UnitTests/ReservationRequestValidatorTests.cs b/tests/api.UnitTests/ReservationRequestValidatorTests.cs new file mode 100644 index 0000000..954aaa4 --- /dev/null +++ b/tests/api.UnitTests/ReservationRequestValidatorTests.cs @@ -0,0 +1,181 @@ +using Contracts; +using FluentValidation.TestHelper; +using Validators; + +namespace api.UnitTests; + +public class ReservationRequestValidatorTests +{ + private readonly ReservationRequestValidator _validator = new(); + + private static ReservationRequest ValidRequest() => new() + { + RoomNumber = "101", + GuestEmail = "guest@mjail.com", + Start = DateTime.Today.AddDays(1), + End = DateTime.Today.AddDays(3) + }; + + + [Theory] + [InlineData("101")] + [InlineData("201")] + [InlineData("999")] + [InlineData("110")] + public void Valid_room_numbers_pass(string roomNumber) + { + // Arrange + var request = ValidRequest(); + request.RoomNumber = roomNumber; + + // Act + var result = _validator.TestValidate(request); + + // Assert + result.ShouldNotHaveValidationErrorFor(x => x.RoomNumber); + } + + [Theory] + [InlineData("000")] + [InlineData("100")] + [InlineData("200")] + [InlineData("0")] + [InlineData("1")] + [InlineData("2020")] + [InlineData("-101")] + [InlineData("abc")] + [InlineData("")] + public void Invalid_room_numbers_fail(string roomNumber) + { + var request = ValidRequest(); + request.RoomNumber = roomNumber; + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.RoomNumber); + } + + + [Theory] + [InlineData("someone@test.com")] + [InlineData("someonelse@hotel.co.uk")] + public void Valid_emails_pass(string email) + { + var request = ValidRequest(); + request.GuestEmail = email; + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveValidationErrorFor(x => x.GuestEmail); + } + + [Theory] + [InlineData("")] + [InlineData("notanemail")] + [InlineData("cecin'estpasunemail")] + public void Invalid_emails_fail(string email) + { + var request = ValidRequest(); + request.GuestEmail = email; + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.GuestEmail); + } + + + [Fact] + public void Start_date_must_be_before_end_date() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(5); + request.End = DateTime.Today.AddDays(3); + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.Start); + } + + [Fact] + public void Time_travellers_are_not_allowed() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(-1); + request.End = DateTime.Today.AddDays(-2); + + var result = _validator.TestValidate(request); + + result.ShouldHaveValidationErrorFor(x => x.Start) + .WithErrorMessage("Time travellers are not welcomed in Mewstel");; + } + + [Fact] + public void Start_date_today_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today; + request.End = DateTime.Today.AddDays(2); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveValidationErrorFor(x => x.Start); + } + + + [Fact] + public void Minimum_duration_is_1_day() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(1).AddHours(12); + + var result = _validator.TestValidate(request); + + result.ShouldHaveAnyValidationError() + .WithErrorMessage("Minimum booking duration is 1 day"); + } + + [Fact] + public void Maximum_duration_is_30_days() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(32); + + var result = _validator.TestValidate(request); + + result.ShouldHaveAnyValidationError() + .WithErrorMessage("Maximum booking duration is 30 days"); + } + + [Fact] + public void Exactly_30_days_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(31); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Exactly_1_day_is_valid() + { + var request = ValidRequest(); + request.Start = DateTime.Today.AddDays(1); + request.End = DateTime.Today.AddDays(2); + + var result = _validator.TestValidate(request); + + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Valid_request_has_no_errors() + { + var result = _validator.TestValidate(ValidRequest()); + result.ShouldNotHaveAnyValidationErrors(); + } +} diff --git a/tests/api.UnitTests/api.UnitTests.csproj b/tests/api.UnitTests/api.UnitTests.csproj new file mode 100644 index 0000000..b424567 --- /dev/null +++ b/tests/api.UnitTests/api.UnitTests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 0181fa0..52bd361 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,7 +1,12 @@ import "./App.css"; import { RouterProvider } from "@tanstack/react-router"; import { router } from "./router"; +import { AuthProvider } from "./utils/auth"; export function App() { - return ; + return ( + + + + ); } diff --git a/ui/src/LandingPage.tsx b/ui/src/LandingPage.tsx index 9f835b6..a25f2b8 100644 --- a/ui/src/LandingPage.tsx +++ b/ui/src/LandingPage.tsx @@ -1,42 +1,49 @@ -import { Box, Card, Flex, Heading, Inset } from "@radix-ui/themes"; +import { Card, Flex, Heading, Inset } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; +import { useAuth } from "./utils/auth"; +import { StaffLoginDialog } from "./staff/StaffLoginDialog"; -function handleLogin() { - // TODO have a staff view - alert("Not implemented"); +const STAFF_IMG = "https://images.unsplash.com/photo-1550527882-b71dea5f8089?q=80&w=700&h=600&auto=format&fit=crop"; +const RESERVE_IMG = "https://images.unsplash.com/photo-1531576788337-610fa9c67107?q=80&w=700&h=600&auto=format&fit=crop"; + +const IMG_STYLE: React.CSSProperties = { + width: 350, + height: 300, + objectFit: "cover", +}; + +function LandingCard({ img, alt, label }: { img: string; alt: string; label: string }) { + return ( + <> + + {alt} + + {label} + + ); } export function LandingPage() { + const { isAuthenticated } = useAuth(); + return ( - - - - Key on wood board - - Login - - + {isAuthenticated ? ( + + + + + + ) : ( + + + + + + )} - - Clean Bed - - Reserve + diff --git a/ui/src/Layout.tsx b/ui/src/Layout.tsx index c32639c..b7556d5 100644 --- a/ui/src/Layout.tsx +++ b/ui/src/Layout.tsx @@ -1,26 +1,47 @@ -import { Box, Text } from "@radix-ui/themes"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { Link, Outlet, useNavigate } from "@tanstack/react-router"; import React from "react"; +import { useAuth } from "./utils/auth"; const TOP_BAR_ACCENT_BACKGROUND: React.CSSProperties = { backgroundColor: "var(--accent-10)", }; -const UNDERLINE_HEADING: React.CSSProperties = { - textDecoration: "underline", - textDecorationColor: "var(--accent-2)", +const HEADING: React.CSSProperties = { + textDecoration: "none", }; export const Layout = () => { + const { isAuthenticated, logout } = useAuth(); + const navigate = useNavigate(); + + function handleLogout() { + logout(); + navigate({ to: "/" }); + } + return ( - - - - Reservations @ Mewstel + + + + Mewstel - + {isAuthenticated && ( + + + Reservations + + + Rooms + + + + )} + ); diff --git a/ui/src/components/ErrorToast.tsx b/ui/src/components/ErrorToast.tsx new file mode 100644 index 0000000..e6738ed --- /dev/null +++ b/ui/src/components/ErrorToast.tsx @@ -0,0 +1,43 @@ +import { Text, Box } from "@radix-ui/themes"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import styled from "styled-components"; + +export interface ErrorToastProps { + toastId: string | number; + messages: string[]; +} + +const BorderedErrorBox = styled(Box)` + background-color: var(--red-5); + border-radius: var(--radius-4); + border: 1px solid var(--red-9); +`; + +const ErrorList = styled.ul` + margin: 4px 0 0 0; + padding-left: 18px; +`; + +/** An error toast */ +export function ErrorToast({ toastId, messages }: ErrorToastProps) { + const closeToast = useCallback(() => toast.dismiss(toastId), [toastId]); + + return ( + + {messages.length === 1 ? ( + + {messages[0]} + + ) : ( + + {messages.map((msg, i) => ( +
  • + {msg} +
  • + ))} +
    + )} +
    + ); +} diff --git a/ui/src/reservations/BookingConfirmationModal.tsx b/ui/src/reservations/BookingConfirmationModal.tsx new file mode 100644 index 0000000..ed3d285 --- /dev/null +++ b/ui/src/reservations/BookingConfirmationModal.tsx @@ -0,0 +1,52 @@ +import { Box, Button, Dialog, Separator, Text } from "@radix-ui/themes"; +import { Reservation } from "./api"; + +interface BookingConfirmationModalProps { + reservation: Reservation; + onClose: () => void; +} + +export function BookingConfirmationModal({ + reservation, + onClose, +}: BookingConfirmationModalProps) { + const startDate = new Date(reservation.start).toLocaleDateString(); + const endDate = new Date(reservation.end).toLocaleDateString(); + + return ( + !open && onClose()}> + + Booking Confirmed + + Your reservation has been successful. We are happy to welcome you! + + + + + Confirmation ID: {reservation.id} + + + Room: #{reservation.roomNumber} + + + Email: {reservation.guestEmail} + + + Check-in: {startDate} + + + Check-out: {endDate} + + + + + + + + + + + ); +} diff --git a/ui/src/reservations/BookingDetailsModal.tsx b/ui/src/reservations/BookingDetailsModal.tsx index 6f1edfe..499a6bd 100644 --- a/ui/src/reservations/BookingDetailsModal.tsx +++ b/ui/src/reservations/BookingDetailsModal.tsx @@ -55,7 +55,6 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { null, ]); const [focusedInput, setFocusedInput] = useState(null); - const showProcessingToast = useShowInfoToast("Processing booking..."); const showNoInfoToast = useShowInfoToast("Missing email or dates."); function handleSubmit(evt: React.MouseEvent) { @@ -65,12 +64,11 @@ function BookingForm({ roomNumber, onSubmit }: BookingFormProps) { return false; } - showProcessingToast(); onSubmit({ - RoomNumber: roomNumber, - GuestEmail: email, - Start: fromDateStringToIso(dateRange[0]), - End: fromDateStringToIso(dateRange[1]), + roomNumber, + guestEmail: email, + start: fromDateStringToIso(dateRange[0]), + end: fromDateStringToIso(dateRange[1]), }); return true; } diff --git a/ui/src/reservations/ReservationCard.tsx b/ui/src/reservations/ReservationCard.tsx index d2c8f52..c72495d 100644 --- a/ui/src/reservations/ReservationCard.tsx +++ b/ui/src/reservations/ReservationCard.tsx @@ -4,10 +4,10 @@ import styled from "styled-components"; /** 600px wide image for Rooms */ const RoomImg = styled.img` - min-width: 300px; width: 100%; - max-width: 700px; - height: auto; + height: 200px; + object-fit: cover; + display: block; `; export type ReservationCardProps = PropsWithChildren<{ @@ -20,7 +20,7 @@ export type ReservationCardProps = PropsWithChildren<{ export function ReservationCard(props: ReservationCardProps) { return ( - + diff --git a/ui/src/reservations/ReservationPage.tsx b/ui/src/reservations/ReservationPage.tsx index 06a0036..2e9aeea 100644 --- a/ui/src/reservations/ReservationPage.tsx +++ b/ui/src/reservations/ReservationPage.tsx @@ -1,10 +1,12 @@ -import { useState } from "react"; -import { useShowSuccessToast } from "../utils/toasts"; -import { Grid, Heading, Section, Dialog } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useShowErrorToast } from "../utils/toasts"; +import { toast } from "sonner"; +import { Box, Button, Flex, Grid, Heading, Section, Text, Dialog } from "@radix-ui/themes"; import { ReservationCard } from "./ReservationCard"; -import { bookRoom, NewReservation, useGetRooms } from "./api"; +import { bookRoom, parseApiError, NewReservation, Reservation, useGetRooms } from "./api"; import { LoadingCard } from "../components/LoadingCard"; import { BookingDetailsModal } from "./BookingDetailsModal"; +import { BookingConfirmationModal } from "./BookingConfirmationModal"; const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { sm: "1", @@ -12,28 +14,48 @@ const RESPONSIVE_GRID_COLS: React.ComponentProps["columns"] = { lg: "4", }; +const PAGE_SIZE = 12; + export function ReservationPage() { const { isLoading, data: rooms } = useGetRooms(); const [selectedRoomNumber, setSelectedRoomNumber] = useState(""); + const [page, setPage] = useState(0); const formattedRoomNumber = String(selectedRoomNumber).padStart(3, "0"); - const showToast = useShowSuccessToast("We have received your booking!"); + const [confirmedReservation, setConfirmedReservation] = useState(null); + const showError = useShowErrorToast(); function onClose() { setSelectedRoomNumber(""); } - function onSubmit(booking: NewReservation) { - bookRoom(booking).then(onClose).then(showToast); + async function onSubmit(booking: NewReservation) { + const toastId = toast.loading("Processing booking..."); + try { + const reservation = await bookRoom(booking); + toast.dismiss(toastId); + onClose(); + setConfirmedReservation(reservation); + } catch (error) { + toast.dismiss(toastId); + const errors = await parseApiError(error); + showError(errors); + } } + const totalPages = useMemo(() => Math.ceil((rooms?.length ?? 0) / PAGE_SIZE), [rooms]); + const pagedRooms = useMemo( + () => rooms?.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) ?? [], + [rooms, page] + ); + const createClickHandler = (roomNumber: string) => () => { setSelectedRoomNumber(roomNumber); }; return ( -
    +
    Rooms @@ -41,7 +63,7 @@ export function ReservationPage() { {isLoading && } - {rooms?.map((room) => ( + {pagedRooms.map((room) => ( + + {totalPages > 1 && ( + + + Page {page + 1} of {totalPages} ({rooms?.length} rooms) + + + + + + + )} + + {confirmedReservation && ( + setConfirmedReservation(null)} + /> + )}
    ); } diff --git a/ui/src/reservations/api.ts b/ui/src/reservations/api.ts index 90c8d0f..8794900 100644 --- a/ui/src/reservations/api.ts +++ b/ui/src/reservations/api.ts @@ -1,48 +1,84 @@ import { useQuery } from "@tanstack/react-query"; import { ISO8601String, toIsoStr } from "../utils/datetime"; -import ky from "ky"; +import { HTTPError } from "ky"; +import { api } from "../utils/api-client"; import { z } from "zod"; export interface NewReservation { - RoomNumber: string; - GuestEmail: string; - Start: ISO8601String; - End: ISO8601String; + roomNumber: string; + guestEmail: string; + start: ISO8601String; + end: ISO8601String; } -/** The schema the API returns */ +/** ----- The schemas the API returns ---- */ + const ReservationSchema = z.object({ - Id: z.string(), - RoomNumber: z.string(), - GuestEmail: z.string().email(), - Start: z.string(), - End: z.string(), + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string().email(), + start: z.string(), + end: z.string(), }); -type Reservation = z.infer; +export type Reservation = z.infer; -export function bookRoom(booking: NewReservation) { - // unwrap branded types - const newReservation = { - ...booking, - Start: toIsoStr(booking.Start), - End: toIsoStr(booking.End), - }; +const ErrorResponseSchema = z.object({ + title: z.string(), + detail: z.string(), + resourceType: z.string().nullish(), + resourceId: z.string().nullish(), + errors: z.record(z.array(z.string())).nullish(), +}); - // TODO post some json with ky.post() - return Promise.resolve(newReservation as any as Reservation); -} +export type ErrorResponse = z.infer; const RoomSchema = z.object({ number: z.string(), state: z.number(), }); +export type Room = z.infer; + const RoomListSchema = RoomSchema.array(); + +/**----- API ---- */ + +export async function bookRoom(booking: NewReservation): Promise { + const body = { + ...booking, + start: toIsoStr(booking.start), + end: toIsoStr(booking.end), + }; + + return api + .post("/api/reservations", { json: body }) + .json() + .then(ReservationSchema.parseAsync); +} + +export async function parseApiError(error: unknown): Promise { + if (error instanceof HTTPError) { + try { + const body = await error.response.json(); + const parsed = ErrorResponseSchema.safeParse(body); + if (parsed.success) { + if (parsed.data.errors) { + return Object.values(parsed.data.errors).flat(); + } + return [parsed.data.detail]; + } + } catch { + // fall through + } + } + return ["An unexpected error occurred"]; +} + export function useGetRooms() { return useQuery({ queryKey: ["rooms"], - queryFn: () => ky.get("api/room").json().then(RoomListSchema.parseAsync), + queryFn: () => api.get("/api/rooms").json().then(RoomListSchema.parseAsync), }); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e3020bd..707511d 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -6,6 +6,8 @@ import { import { Layout } from "./Layout"; import { LandingPage } from "./LandingPage"; import { ReservationPage } from "./reservations/ReservationPage"; +import { StaffReservationsPage } from "./staff/StaffReservationsPage"; +import { StaffRoomsPage } from "./staff/StaffRoomsPage"; const rootRoute = createRootRoute({ component: Layout, @@ -26,6 +28,16 @@ const ROUTES = [ getParentRoute: getRootRoute, component: ReservationPage, }), + createRoute({ + path: "/staff/reservations", + getParentRoute: getRootRoute, + component: StaffReservationsPage, + }), + createRoute({ + path: "/staff/rooms", + getParentRoute: getRootRoute, + component: StaffRoomsPage, + }), ]; const routeTree = rootRoute.addChildren(ROUTES); diff --git a/ui/src/staff/ImportRoomsDialog.tsx b/ui/src/staff/ImportRoomsDialog.tsx new file mode 100644 index 0000000..474b241 --- /dev/null +++ b/ui/src/staff/ImportRoomsDialog.tsx @@ -0,0 +1,166 @@ +import { useCallback, useState } from "react"; +import { Badge, Box, Button, Dialog, Flex, Separator, Table, Text } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { importRooms, ImportResult } from "./api"; +import { useAuth } from "../utils/auth"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; + +interface ImportRoomsDialogProps { + children: React.ReactNode; +} + +export function ImportRoomsDialog({ children }: ImportRoomsDialogProps) { + const { token } = useAuth(); + const queryClient = useQueryClient(); + const showError = useShowErrorToast(); + const showSuccess = useShowSuccessToast("Rooms imported successfully!"); + + const [open, setOpen] = useState(false); + const [dragging, setDragging] = useState(false); + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + function reset() { + setFile(null); + setResult(null); + setDragging(false); + setLoading(false); + } + + function handleClose() { + reset(); + setOpen(false); + } + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragging(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile && droppedFile.name.endsWith(".csv")) { + setFile(droppedFile); + setResult(null); + } + }, []); + + function handleFileSelect(e: React.ChangeEvent) { + const selected = e.target.files?.[0]; + if (selected) { + setFile(selected); + setResult(null); + } + } + + async function handleImport() { + if (!file || !token) return; + setLoading(true); + try { + const res = await importRooms(token, file); + setResult(res); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + if (res.failed === 0) showSuccess(); + } catch { + showError(["Failed to import rooms"]); + } finally { + setLoading(false); + } + } + + return ( + { setOpen(o); if (!o) reset(); }}> + {children} + + Import Rooms + + Upload a CSV file with room numbers (one per line). + + + + {!result ? ( + <> + { e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} + style={{ + border: `2px dashed ${dragging ? "var(--accent-9)" : "var(--gray-6)"}`, + borderRadius: "var(--radius-3)", + padding: "40px 20px", + textAlign: "center", + backgroundColor: dragging ? "var(--accent-3)" : "var(--gray-2)", + cursor: "pointer", + transition: "all 0.2s", + }} + onClick={() => document.getElementById("csv-file-input")?.click()} + > + + {file ? file.name : "Drag & drop a CSV file here, or click to browse"} + + + + + + + + + + + + ) : ( + <> + + {result.imported} imported + {result.failed > 0 && ( + {result.failed} failed + )} + + + {result.errors.length > 0 && ( + + + + + Line + Room + Error + + + + {result.errors.map((err, i) => ( + + {err.line} + {err.roomNumber} + {err.message} + + ))} + + + + )} + + + + + + + )} + + + ); +} diff --git a/ui/src/staff/StaffLoginDialog.tsx b/ui/src/staff/StaffLoginDialog.tsx new file mode 100644 index 0000000..3188a5c --- /dev/null +++ b/ui/src/staff/StaffLoginDialog.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { Button, Dialog, Separator, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { staffLogin } from "./api"; +import { useAuth } from "../utils/auth"; +import { useShowErrorToast } from "../utils/toasts"; + +interface StaffLoginDialogProps { + children: React.ReactNode; +} + +export function StaffLoginDialog({ children }: StaffLoginDialogProps) { + const [accessCode, setAccessCode] = useState(""); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + const showError = useShowErrorToast(); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + const token = await staffLogin(accessCode); + login(token); + setOpen(false); + setAccessCode(""); + navigate({ to: "/staff/reservations" }); + } catch { + showError(["Invalid access code"]); + } finally { + setLoading(false); + } + } + + return ( + + {children} + + Staff Login + + Enter your staff access code + + +
    + setAccessCode(e.target.value)} + size="3" + mb="4" + /> + + +
    +
    + ); +} diff --git a/ui/src/staff/StaffReservationsPage.tsx b/ui/src/staff/StaffReservationsPage.tsx new file mode 100644 index 0000000..0fd1151 --- /dev/null +++ b/ui/src/staff/StaffReservationsPage.tsx @@ -0,0 +1,211 @@ +import { useState } from "react"; +import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text, TextField } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../utils/auth"; +import { checkInReservation, checkOutReservation, RoomStateLabels, ReservationFilters, StaffReservation, useGetStaffReservations } from "./api"; +import { Room, useGetRooms } from "../reservations/api"; +import { parseApiError } from "../reservations/api"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; +import { LoadingCard } from "../components/LoadingCard"; + +function todayStr() { + return new Date().toISOString().split("T")[0]; +} + +export function StaffReservationsPage() { + const { token, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + const [filters, setFilters] = useState({ + from: todayStr(), + }); + + const { data: reservations, isLoading } = useGetStaffReservations(token, filters); + const { data: rooms } = useGetRooms(); + const queryClient = useQueryClient(); + const showError = useShowErrorToast(); + const showCheckInSuccess = useShowSuccessToast("Guest checked in successfully!"); + const showCheckOutSuccess = useShowSuccessToast("Guest checked out successfully!"); + const [checkingIn, setCheckingIn] = useState(null); + const [checkingOut, setCheckingOut] = useState(null); + + const roomsByNumber = new Map(rooms?.map((r) => [r.number, r]) ?? []); + + function coversToday(start: string, end: string) { + const today = new Date().toDateString(); + return new Date(start) <= new Date(today) && new Date(end) > new Date(today); + } + + function isRoomDirty(roomNumber: string) { + const room = roomsByNumber.get(roomNumber); + return room?.state === 2; + } + + async function handleCheckIn(r: StaffReservation) { + setCheckingIn(r.id); + try { + await checkInReservation(token!, r.id, r.guestEmail); + showCheckInSuccess(); + queryClient.invalidateQueries({ queryKey: ["staff-reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setCheckingIn(null); + } + } + + async function handleCheckOut(r: StaffReservation) { + setCheckingOut(r.id); + try { + await checkOutReservation(token!, r.id, r.guestEmail); + showCheckOutSuccess(); + queryClient.invalidateQueries({ queryKey: ["staff-reservations"] }); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setCheckingOut(null); + } + } + + if (!isAuthenticated) { + navigate({ to: "/" }); + return null; + } + + function updateFilter(key: keyof ReservationFilters, value: string) { + setFilters((prev) => ({ ...prev, [key]: value || undefined })); + } + + return ( +
    + + Reservations + + + + + + From + updateFilter("from", e.target.value)} + size="2" + /> + + + To + updateFilter("to", e.target.value)} + size="2" + /> + + + Room + updateFilter("roomNumber", v === "all" ? "" : v)} + size="2" + > + + + All rooms + {rooms?.map((room) => ( + + #{room.number} + + ))} + + + + + Guest Email + updateFilter("guestEmail", e.target.value)} + size="2" + /> + + + + + {isLoading && } + + {reservations && reservations.length === 0 && ( + No reservations found. + )} + + {reservations && reservations.length > 0 && ( + + + + Room + Guest Email + Check-in + Check-out + Reservation Status + Room Status + + + + + {reservations.map((r) => ( + + #{r.roomNumber} + {r.guestEmail} + {new Date(r.start).toLocaleDateString()} + {new Date(r.end).toLocaleDateString()} + + {r.checkedOut + ? Checked Out {r.checkedOutAt && {new Date(r.checkedOutAt).toLocaleDateString()}} + : r.checkedIn + ? Checked In + : Pending} + + + {(() => { + const room = roomsByNumber.get(r.roomNumber); + const label = room ? RoomStateLabels[room.state] ?? "Unknown" : "—"; + const color = room?.state === 0 ? "green" : room?.state === 1 ? "orange" : room?.state === 2 ? "yellow" : "gray"; + return {label}; + })()} + + + {!r.checkedIn && coversToday(r.start, r.end) && ( + + )} + {r.checkedIn && !r.checkedOut && ( + + )} + + + ))} + + + )} +
    + ); +} diff --git a/ui/src/staff/StaffRoomsPage.tsx b/ui/src/staff/StaffRoomsPage.tsx new file mode 100644 index 0000000..1646521 --- /dev/null +++ b/ui/src/staff/StaffRoomsPage.tsx @@ -0,0 +1,206 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Button, Card, Flex, Heading, Section, Select, Table, Text } from "@radix-ui/themes"; +import { useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "../utils/auth"; +import { useGetRooms, parseApiError } from "../reservations/api"; +import { RoomStateLabels, updateRoomState } from "./api"; +import { ImportRoomsDialog } from "./ImportRoomsDialog"; +import { LoadingCard } from "../components/LoadingCard"; +import { useShowErrorToast, useShowSuccessToast } from "../utils/toasts"; + +const PAGE_SIZE = 20; + +const STATE_COLORS: Record = { + 0: "green", + 1: "orange", + 2: "yellow", +}; + +export function StaffRoomsPage() { + const { token, isAuthenticated } = useAuth(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: rooms, isLoading } = useGetRooms(); + const showError = useShowErrorToast(); + const showSuccess = useShowSuccessToast("Room status updated!"); + const [page, setPage] = useState(0); + const [statusFilter, setStatusFilter] = useState("all"); + const [floorFilter, setFloorFilter] = useState("all"); + const [updating, setUpdating] = useState(null); + + async function handleStateChange(roomNumber: string, newState: number) { + if (!token) return; + setUpdating(roomNumber); + try { + await updateRoomState(token, roomNumber, newState); + showSuccess(); + queryClient.invalidateQueries({ queryKey: ["rooms"] }); + } catch (error) { + const errors = await parseApiError(error); + showError(errors); + } finally { + setUpdating(null); + } + } + + if (!isAuthenticated) { + navigate({ to: "/" }); + return null; + } + + const floors = useMemo( + () => [...new Set(rooms?.map((r) => r.number[0]) ?? [])].sort(), + [rooms] + ); + + const filteredRooms = useMemo(() => { + let result = rooms ?? []; + if (statusFilter !== "all") { + result = result.filter((r) => r.state === Number(statusFilter)); + } + if (floorFilter !== "all") { + result = result.filter((r) => r.number[0] === floorFilter); + } + return result; + }, [rooms, statusFilter, floorFilter]); + + const totalPages = useMemo(() => Math.ceil(filteredRooms.length / PAGE_SIZE), [filteredRooms]); + const pagedRooms = useMemo( + () => filteredRooms.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE), + [filteredRooms, page] + ); + + return ( +
    + + + Rooms + + + + + + + + + + Status + { setStatusFilter(v); setPage(0); }} + size="2" + > + + + All + Ready + Occupied + Dirty + + + + + Floor + { setFloorFilter(v); setPage(0); }} + size="2" + > + + + All floors + {floors.map((f) => ( + Floor {f} + ))} + + + + + + + {isLoading && } + + {!isLoading && filteredRooms.length === 0 && ( + No rooms found. + )} + + {pagedRooms.length > 0 && ( + <> + + + + Room Number + Floor + Status + + + + + {pagedRooms.map((room) => ( + + #{room.number} + Floor {room.number[0]} + + + {RoomStateLabels[room.state] ?? "Unknown"} + + + + {room.state === 2 && ( + + )} + {room.state === 0 && ( + + )} + + + ))} + + + + {totalPages > 1 && ( + + + Page {page + 1} of {totalPages} ({filteredRooms.length} rooms) + + + + + + + )} + + )} +
    + ); +} diff --git a/ui/src/staff/api.ts b/ui/src/staff/api.ts new file mode 100644 index 0000000..f19f10b --- /dev/null +++ b/ui/src/staff/api.ts @@ -0,0 +1,131 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../utils/api-client"; +import { z } from "zod"; + +export async function staffLogin(accessCode: string): Promise { + const response = await api + .post("/api/staff/login", { + headers: { "X-Staff-Code": accessCode }, + }) + .json<{ token: string }>(); + + return response.token; +} + +const StaffReservationSchema = z.object({ + id: z.string(), + roomNumber: z.string(), + guestEmail: z.string(), + start: z.string(), + end: z.string(), + checkedIn: z.boolean(), + checkedOut: z.boolean(), + checkedOutAt: z.string().nullable(), +}); + +export type StaffReservation = z.infer; + +const StaffReservationListSchema = StaffReservationSchema.array(); + +export interface ReservationFilters { + from?: string; + to?: string; + roomNumber?: string; + guestEmail?: string; +} + + +const RoomStateLabels: Record = { + 0: "Ready", + 1: "Occupied", + 2: "Dirty", +}; + +export { RoomStateLabels }; + +const ImportErrorSchema = z.object({ + line: z.number(), + roomNumber: z.string(), + message: z.string(), +}); + +const ImportResultSchema = z.object({ + totalRows: z.number(), + imported: z.number(), + failed: z.number(), + errors: ImportErrorSchema.array(), +}); + +export type ImportResult = z.infer; +export type ImportError = z.infer; + +export async function importRooms(token: string, file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + + return api + .post("/api/rooms/import", { + body: formData, + headers: { Authorization: `Bearer ${token}` }, + }) + .json() + .then(ImportResultSchema.parseAsync); +} + +export async function updateRoomState(token: string, roomNumber: string, state: number) { + return api + .patch(`/api/rooms/${roomNumber}`, { + json: { state }, + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} + +export async function deleteRoom(token: string, roomNumber: string) { + return api + .delete(`/api/rooms/${roomNumber}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} + +export function useGetStaffReservations(token: string | null, filters: ReservationFilters) { + const searchParams: Record = {}; + if (filters.from) searchParams.from = filters.from; + if (filters.to) searchParams.to = filters.to; + if (filters.roomNumber) searchParams.roomNumber = filters.roomNumber; + if (filters.guestEmail) searchParams.guestEmail = filters.guestEmail; + + return useQuery({ + queryKey: ["staff-reservations", token, filters], + enabled: !!token, + retry: false, + refetchOnWindowFocus: false, + queryFn: () => + api + .get("/api/reservations", { + searchParams, + headers: { Authorization: `Bearer ${token}` }, + }) + .json() + .then(StaffReservationListSchema.parseAsync), + }); +} + +export async function checkInReservation(token: string, reservationId: string, guestEmail: string) { + return api + .post(`/api/reservations/${reservationId}/check-in`, { + json: { guestEmail }, + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} + +export async function checkOutReservation(token: string, reservationId: string, guestEmail: string) { + return api + .post(`/api/reservations/${reservationId}/check-out`, { + json: { guestEmail }, + headers: { Authorization: `Bearer ${token}` }, + }) + .json(); +} \ No newline at end of file diff --git a/ui/src/utils/api-client.ts b/ui/src/utils/api-client.ts new file mode 100644 index 0000000..c7abbad --- /dev/null +++ b/ui/src/utils/api-client.ts @@ -0,0 +1,17 @@ +import ky from "ky"; + +let redirecting = false; + +export const api = ky.create({ + hooks: { + afterResponse: [ + (_request, _options, response) => { + if (response.status === 401 && !redirecting) { + redirecting = true; + sessionStorage.removeItem("staffToken"); + window.location.href = "/"; + } + }, + ], + }, +}); diff --git a/ui/src/utils/auth.tsx b/ui/src/utils/auth.tsx new file mode 100644 index 0000000..b26ce0b --- /dev/null +++ b/ui/src/utils/auth.tsx @@ -0,0 +1,38 @@ +import { createContext, useCallback, useContext, useState } from "react"; + +interface AuthContextType { + token: string | null; + login: (token: string) => void; + logout: () => void; + isAuthenticated: boolean; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [token, setToken] = useState( + () => sessionStorage.getItem("staffToken") + ); + + const login = useCallback((newToken: string) => { + sessionStorage.setItem("staffToken", newToken); + setToken(newToken); + }, []); + + const logout = useCallback(() => { + sessionStorage.removeItem("staffToken"); + setToken(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/ui/src/utils/datetime.ts b/ui/src/utils/datetime.ts index 17d55a7..23aa547 100644 --- a/ui/src/utils/datetime.ts +++ b/ui/src/utils/datetime.ts @@ -10,11 +10,19 @@ export type ISO8601String = { readonly _value: string; }; +/** Format as YYYY-MM-DD to avoid timezone shifting */ +function toLocalDateString(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}T00:00:00`; +} + /** Any date will pass through transparently */ function fromDate(date: Date): ISO8601String { return { [DateTimeSym]: "utils/datetime/iso8601", - _value: date.toISOString(), + _value: toLocalDateString(date), _dateValue: date, }; } diff --git a/ui/src/utils/toasts.tsx b/ui/src/utils/toasts.tsx index d3358f7..c686d67 100644 --- a/ui/src/utils/toasts.tsx +++ b/ui/src/utils/toasts.tsx @@ -1,5 +1,6 @@ import { SuccessToast } from "../components/SuccessToast"; import { InfoToast } from "../components/InfoToast"; +import { ErrorToast } from "../components/ErrorToast"; import { ExternalToast, toast } from "sonner"; import { useCallback } from "react"; @@ -30,3 +31,14 @@ export function useShowInfoToast(message: string) { [message], ); } + +export function useShowErrorToast() { + return useCallback( + (messages: string[]) => + toast.custom( + (t) => , + { duration: 5_000 }, + ), + [], + ); +}