From a640703f2da8815dc26ad1600a6f206be1624379 Mon Sep 17 00:00:00 2001 From: ivarlovlie Date: Wed, 1 Jun 2022 22:10:32 +0200 Subject: feat: Initial after clean slate --- .../Internal/Account/CreateAccountPayload.cs | 17 ++ .../Internal/Account/CreateAccountRoute.cs | 44 +++++ .../Internal/Account/CreateGithubSessionRoute.cs | 17 ++ .../Internal/Account/CreateInitialAccountRoute.cs | 34 ++++ .../Internal/Account/DeleteAccountRoute.cs | 49 ++++++ .../Endpoints/Internal/Account/GetArchiveRoute.cs | 62 +++++++ server/src/Endpoints/Internal/Account/GetRoute.cs | 30 ++++ .../src/Endpoints/Internal/Account/LoginPayload.cs | 22 +++ .../src/Endpoints/Internal/Account/LoginRoute.cs | 37 ++++ .../src/Endpoints/Internal/Account/LogoutRoute.cs | 22 +++ .../Internal/Account/UpdateAccountPayload.cs | 17 ++ .../Internal/Account/UpdateAccountRoute.cs | 51 ++++++ server/src/Endpoints/Internal/BaseRoute.cs | 16 ++ .../CreateResetRequestRoute.cs | 59 +++++++ .../FulfillResetRequestPayload.cs | 14 ++ .../FulfillResetRequestRoute.cs | 34 ++++ .../IsResetRequestValidRoute.cs | 29 ++++ .../Internal/Root/GetApplicationVersionRoute.cs | 21 +++ server/src/Endpoints/Internal/Root/LogRoute.cs | 16 ++ server/src/Endpoints/Internal/RouteBaseAsync.cs | 73 ++++++++ server/src/Endpoints/Internal/RouteBaseSync.cs | 53 ++++++ server/src/Endpoints/V1/ApiSpecV1.cs | 18 ++ .../src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs | 52 ++++++ .../src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs | 33 ++++ .../src/Endpoints/V1/ApiTokens/GetTokensRoute.cs | 22 +++ server/src/Endpoints/V1/BaseRoute.cs | 39 +++++ .../Endpoints/V1/Categories/CreateCategoryRoute.cs | 43 +++++ .../Endpoints/V1/Categories/DeleteCategoryRoute.cs | 38 +++++ .../Endpoints/V1/Categories/GetCategoriesRoute.cs | 35 ++++ .../Endpoints/V1/Categories/UpdateCategoryRoute.cs | 39 +++++ .../src/Endpoints/V1/Entries/CreateEntryRoute.cs | 65 +++++++ .../src/Endpoints/V1/Entries/DeleteEntryRoute.cs | 35 ++++ .../src/Endpoints/V1/Entries/EntryQueryPayload.cs | 60 +++++++ .../src/Endpoints/V1/Entries/EntryQueryResponse.cs | 37 ++++ server/src/Endpoints/V1/Entries/EntryQueryRoute.cs | 186 +++++++++++++++++++++ server/src/Endpoints/V1/Entries/GetEntryRoute.cs | 34 ++++ .../src/Endpoints/V1/Entries/UpdateEntryRoute.cs | 66 ++++++++ server/src/Endpoints/V1/Labels/CreateLabelRoute.cs | 46 +++++ server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs | 35 ++++ server/src/Endpoints/V1/Labels/GetLabelRoute.cs | 34 ++++ server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs | 38 +++++ server/src/Endpoints/V1/RouteBaseAsync.cs | 73 ++++++++ server/src/Endpoints/V1/RouteBaseSync.cs | 53 ++++++ 43 files changed, 1798 insertions(+) create mode 100644 server/src/Endpoints/Internal/Account/CreateAccountPayload.cs create mode 100644 server/src/Endpoints/Internal/Account/CreateAccountRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/GetArchiveRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/GetRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/LoginPayload.cs create mode 100644 server/src/Endpoints/Internal/Account/LoginRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/LogoutRoute.cs create mode 100644 server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs create mode 100644 server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs create mode 100644 server/src/Endpoints/Internal/BaseRoute.cs create mode 100644 server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs create mode 100644 server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs create mode 100644 server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs create mode 100644 server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs create mode 100644 server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs create mode 100644 server/src/Endpoints/Internal/Root/LogRoute.cs create mode 100644 server/src/Endpoints/Internal/RouteBaseAsync.cs create mode 100644 server/src/Endpoints/Internal/RouteBaseSync.cs create mode 100644 server/src/Endpoints/V1/ApiSpecV1.cs create mode 100644 server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs create mode 100644 server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs create mode 100644 server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs create mode 100644 server/src/Endpoints/V1/BaseRoute.cs create mode 100644 server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs create mode 100644 server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs create mode 100644 server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs create mode 100644 server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs create mode 100644 server/src/Endpoints/V1/Entries/CreateEntryRoute.cs create mode 100644 server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs create mode 100644 server/src/Endpoints/V1/Entries/EntryQueryPayload.cs create mode 100644 server/src/Endpoints/V1/Entries/EntryQueryResponse.cs create mode 100644 server/src/Endpoints/V1/Entries/EntryQueryRoute.cs create mode 100644 server/src/Endpoints/V1/Entries/GetEntryRoute.cs create mode 100644 server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs create mode 100644 server/src/Endpoints/V1/Labels/CreateLabelRoute.cs create mode 100644 server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs create mode 100644 server/src/Endpoints/V1/Labels/GetLabelRoute.cs create mode 100644 server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs create mode 100644 server/src/Endpoints/V1/RouteBaseAsync.cs create mode 100644 server/src/Endpoints/V1/RouteBaseSync.cs (limited to 'server/src/Endpoints') diff --git a/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs new file mode 100644 index 0000000..dc73e68 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs @@ -0,0 +1,17 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// +/// Payload for creating new user accounts. +/// +public class CreateAccountPayload +{ + /// + /// Username for the new account. + /// + public string Username { get; set; } + + /// + /// Password for the new account. + /// + public string Password { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs new file mode 100644 index 0000000..954fbf5 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs @@ -0,0 +1,44 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// +public class CreateAccountRoute : RouteBaseAsync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// + public CreateAccountRoute(UserService userService, AppDbContext context) { + _userService = userService; + _context = context; + } + + /// + /// Create a new user account. + /// + /// + /// + /// + [AllowAnonymous] + [HttpPost("~/_/account/create")] + public override async Task HandleAsync(CreateAccountPayload request, CancellationToken cancellationToken = default) { + if (request.Username.IsValidEmailAddress() == false) { + return BadRequest(new ErrorResult("Invalid form", request.Username + " does not look like a valid email")); + } + + if (request.Password.Length < 6) { + return BadRequest(new ErrorResult("Invalid form", "The password requires 6 or more characters.")); + } + + var username = request.Username.Trim(); + if (_context.Users.Any(c => c.Username == username)) { + return BadRequest(new ErrorResult("Username is not available", "There is already a user registered with email: " + username)); + } + + var user = new User(username); + user.HashAndSetPassword(request.Password); + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogInUser(HttpContext, user); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs new file mode 100644 index 0000000..0cd1aa5 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs @@ -0,0 +1,17 @@ +using AspNet.Security.OAuth.GitHub; + +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class CreateGithubSessionRoute : RouteBaseSync.WithRequest.WithActionResult +{ + public CreateGithubSessionRoute(IConfiguration configuration) { } + + [AllowAnonymous] + [HttpGet("~/_/account/create-github-session")] + public override ActionResult Handle(string next) { + return Challenge(new AuthenticationProperties { + RedirectUri = next + }, + GitHubAuthenticationDefaults.AuthenticationScheme); + } +} diff --git a/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs new file mode 100644 index 0000000..13fbdf4 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs @@ -0,0 +1,34 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// +public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// + public CreateInitialAccountRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// + /// Create an initial user account. + /// + /// + /// + [AllowAnonymous] + [HttpGet("~/_/account/create-initial")] + public override async Task HandleAsync(CancellationToken cancellationToken = default) { + if (_context.Users.Any()) { + return NotFound(); + } + + var user = new User("admin@ivarlovlie.no"); + user.HashAndSetPassword("ivar123"); + _context.Users.Add(user); + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogInUser(HttpContext, user); + return Redirect("/"); + } +} diff --git a/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs new file mode 100644 index 0000000..2149e15 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs @@ -0,0 +1,49 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class DeleteAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// + public DeleteAccountRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// + /// Delete the logged on user's account. + /// + /// + /// + [HttpDelete("~/_/account/delete")] + public override async Task HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await _userService.LogOutUser(HttpContext); + return Unauthorized(); + } + + if (user.Username == "demo@demo.demo") { + await _userService.LogOutUser(HttpContext); + return Ok(); + } + + var githubMappings = _context.TimeCategories.Where(c => c.UserId == user.Id); + var passwordResets = _context.ForgotPasswordRequests.Where(c => c.UserId == user.Id); + var entries = _context.TimeEntries.Where(c => c.UserId == user.Id); + var labels = _context.TimeLabels.Where(c => c.UserId == user.Id); + var categories = _context.TimeCategories.Where(c => c.UserId == user.Id); + + _context.TimeCategories.RemoveRange(githubMappings); + _context.ForgotPasswordRequests.RemoveRange(passwordResets); + _context.TimeEntries.RemoveRange(entries); + _context.TimeLabels.RemoveRange(labels); + _context.TimeCategories.RemoveRange(categories); + _context.Users.Remove(user); + + await _context.SaveChangesAsync(cancellationToken); + await _userService.LogOutUser(HttpContext); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs new file mode 100644 index 0000000..44f5249 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs @@ -0,0 +1,62 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class GetAccountArchiveRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public GetAccountArchiveRoute(AppDbContext context) { + _context = context; + } + + /// + /// Get a data archive with the currently logged on user's data. + /// + /// + /// + [HttpGet("~/_/account/archive")] + public override async Task> HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await HttpContext.SignOutAsync(); + return Unauthorized(); + } + + var entries = _context.TimeEntries + .AsNoTracking() + .Include(c => c.Labels) + .Include(c => c.Category) + .Where(c => c.User.Id == user.Id) + .ToList(); + + var jsonOptions = new JsonSerializerOptions { + WriteIndented = true + }; + + var dto = new UserArchiveDto(user); + dto.Entries.AddRange(entries.Select(entry => new UserArchiveDto.EntryDto { + CreatedAt = entry.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"), + StartDateTime = entry.Start, + StopDateTime = entry.Stop, + Description = entry.Description, + Labels = entry.Labels + .Select(c => new UserArchiveDto.LabelDto { + Name = c.Name, + Color = c.Color + }) + .ToList(), + Category = new UserArchiveDto.CategoryDto { + Name = entry.Category.Name, + Color = entry.Category.Color + }, + })); + + dto.CountEntries(); + + var entriesSerialized = JsonSerializer.SerializeToUtf8Bytes(dto, jsonOptions); + + return File(entriesSerialized, + "application/json", + user.Username + "-time-tracker-archive-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json"); + } +} diff --git a/server/src/Endpoints/Internal/Account/GetRoute.cs b/server/src/Endpoints/Internal/Account/GetRoute.cs new file mode 100644 index 0000000..34a3c97 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/GetRoute.cs @@ -0,0 +1,30 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public GetAccountRoute(AppDbContext context) { + _context = context; + } + + /// + /// Get the logged on user's session data. + /// + /// + /// + [HttpGet("~/_/account")] + public override async Task> HandleAsync(CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user != default) { + return Ok(new LoggedInUserModel { + Id = LoggedInUser.Id, + Username = LoggedInUser.Username + }); + } + + await HttpContext.SignOutAsync(); + return Unauthorized(); + } +} diff --git a/server/src/Endpoints/Internal/Account/LoginPayload.cs b/server/src/Endpoints/Internal/Account/LoginPayload.cs new file mode 100644 index 0000000..807662c --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LoginPayload.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// +/// Payload for logging in a user. +/// +public class LoginPayload +{ + /// + /// Username of the user's account. + /// + public string Username { get; set; } + + /// + /// Password of the user's account. + /// + public string Password { get; set; } + + /// + /// Specify that the created session should be long lived and continually refreshed. + /// + public bool Persist { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/LoginRoute.cs b/server/src/Endpoints/Internal/Account/LoginRoute.cs new file mode 100644 index 0000000..5b41c61 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LoginRoute.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class LoginRoute : RouteBaseAsync + .WithRequest + .WithActionResult +{ + private readonly AppDbContext _context; + private readonly UserService _userService; + + /// + public LoginRoute(AppDbContext context, UserService userService) { + _context = context; + _userService = userService; + } + + /// + /// Login a user. + /// + /// + /// + /// + [AllowAnonymous] + [HttpPost("~/_/account/login")] + public override async Task HandleAsync(LoginPayload request, CancellationToken cancellationToken = default) { + if (!ModelState.IsValid) { + return BadRequest(ModelState); + } + + var user = _context.Users.SingleOrDefault(u => u.Username == request.Username); + if (user == default || !user.VerifyPassword(request.Password)) { + return BadRequest(new ErrorResult("Invalid username or password")); + } + + await _userService.LogInUser(HttpContext, user, request.Persist); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/LogoutRoute.cs b/server/src/Endpoints/Internal/Account/LogoutRoute.cs new file mode 100644 index 0000000..4a06f4a --- /dev/null +++ b/server/src/Endpoints/Internal/Account/LogoutRoute.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class LogoutRoute : RouteBaseAsync.WithoutRequest.WithActionResult +{ + private readonly UserService _userService; + + public LogoutRoute(UserService userService) { + _userService = userService; + } + + /// + /// Logout a user. + /// + /// + /// + [AllowAnonymous] + [HttpGet("~/_/account/logout")] + public override async Task HandleAsync(CancellationToken cancellationToken = default) { + await _userService.LogOutUser(HttpContext); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs new file mode 100644 index 0000000..88a3237 --- /dev/null +++ b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs @@ -0,0 +1,17 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +/// +/// Payload for updating an account. +/// +public class UpdatePayload +{ + /// + /// Username to set on the logged on user's account. + /// + public string Username { get; set; } + + /// + /// Password to set on the logged on user's account. + /// + public string Password { get; set; } +} diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs new file mode 100644 index 0000000..a997dcb --- /dev/null +++ b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs @@ -0,0 +1,51 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Account; + +public class UpdateAccountRoute : RouteBaseAsync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public UpdateAccountRoute(AppDbContext context) { + _context = context; + } + + /// + /// Update the logged on user's data. + /// + /// + /// + /// + [HttpPost("~/_/account/update")] + public override async Task HandleAsync(UpdatePayload request, CancellationToken cancellationToken = default) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + await HttpContext.SignOutAsync(); + return Unauthorized(); + } + + if (request.Password.IsNullOrWhiteSpace() && request.Username.IsNullOrWhiteSpace()) { + return BadRequest(new ErrorResult("Invalid request", "No data was submitted")); + } + + if (request.Password.HasValue() && request.Password.Length < 6) { + return BadRequest(new ErrorResult("Invalid request", + "The new password must contain at least 6 characters")); + } + + if (request.Password.HasValue()) { + user.HashAndSetPassword(request.Password); + } + + if (request.Username.HasValue() && !request.Username.IsValidEmailAddress()) { + return BadRequest(new ErrorResult("Invalid request", + "The new username does not look like a valid email address")); + } + + if (request.Username.HasValue()) { + user.Username = request.Username.Trim(); + } + + await _context.SaveChangesAsync(cancellationToken); + return Ok(); + } +} diff --git a/server/src/Endpoints/Internal/BaseRoute.cs b/server/src/Endpoints/Internal/BaseRoute.cs new file mode 100644 index 0000000..3e2c6af --- /dev/null +++ b/server/src/Endpoints/Internal/BaseRoute.cs @@ -0,0 +1,16 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +[Authorize] +[ApiController] +[ApiExplorerSettings(IgnoreApi = true)] +[ApiVersionNeutral] +public class BaseRoute : ControllerBase +{ + /// + /// User data for the currently logged on user. + /// + protected LoggedInUserModel LoggedInUser => new() { + Username = User.FindFirstValue(AppClaims.NAME), + Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(), + }; +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs new file mode 100644 index 0000000..3e086f6 --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs @@ -0,0 +1,59 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// +public class CreateResetRequestRoute : RouteBaseAsync.WithRequest.WithActionResult +{ + private readonly ILogger _logger; + private readonly ForgotPasswordService _forgotPasswordService; + private readonly AppDbContext _context; + + /// + public CreateResetRequestRoute(ILogger logger, ForgotPasswordService forgotPasswordService, AppDbContext context) { + _logger = logger; + _forgotPasswordService = forgotPasswordService; + _context = context; + } + + /// + /// Create a new password reset request. + /// + /// + /// + /// + [AllowAnonymous] + [HttpGet("~/_/forgot-password-requests/create")] + public override async Task HandleAsync(string username, CancellationToken cancellationToken = default) { + if (!username.IsValidEmailAddress()) { + _logger.LogInformation("Username is invalid, not doing request for password change"); + return BadRequest(new ErrorResult("Invalid email address", username + " looks like an invalid email address")); + } + + Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader); + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC"); + var offset = tz.BaseUtcOffset.Hours; + + // this is fine as long as the client is not connecting from Australia: Lord Howe Island + // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country + if (tz.IsDaylightSavingTime(DateTime.UtcNow)) { + offset++; + } + + _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offset + " hours"); + var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + _logger.LogInformation("Creating forgot password request with date time: " + requestDateTime.ToString("u")); + + try { + var user = _context.Users.SingleOrDefault(c => c.Username.Equals(username)); + if (user != default) { + await _forgotPasswordService.AddRequestAsync(user, tz, cancellationToken); + return Ok(); + } + + _logger.LogInformation("User was not found, not doing request for password change"); + return Ok(); + } catch (Exception e) { + _logger.LogError(e, "ForgotAction failed badly"); + return Ok(); + } + } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs new file mode 100644 index 0000000..f0fb59f --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs @@ -0,0 +1,14 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +public class FulfillResetRequestPayload +{ + /// + /// Id of the password reset request to fulfill + /// + public Guid Id { get; set; } + + /// + /// New password to set on the relevant account + /// + public string NewPassword { get; set; } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs new file mode 100644 index 0000000..e33a4fb --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs @@ -0,0 +1,34 @@ + +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// +public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest.WithActionResult +{ + private readonly ForgotPasswordService _forgotPasswordService; + + /// + public FulfillResetRequestRoute(ForgotPasswordService forgotPasswordService) { + _forgotPasswordService = forgotPasswordService; + } + + /// + /// Fulfill a password reset request. + /// + /// + /// + /// + [AllowAnonymous] + [HttpPost("~/_/forgot-password-requests/fulfill")] + public override async Task HandleAsync(FulfillResetRequestPayload request, CancellationToken cancellationToken = default) { + try { + var fulfilled = await _forgotPasswordService.FullFillRequestAsync(request.Id, request.NewPassword, cancellationToken); + return Ok(fulfilled); + } catch (Exception e) { + if (e is ForgotPasswordRequestNotFoundException or UserNotFoundException) { + return NotFound(); + } + + throw; + } + } +} diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs new file mode 100644 index 0000000..9984094 --- /dev/null +++ b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs @@ -0,0 +1,29 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests; + +/// +public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest.WithActionResult +{ + private readonly ForgotPasswordService _forgotPasswordService; + + /// + public IsResetRequestValidRoute(ForgotPasswordService forgotPasswordService) { + _forgotPasswordService = forgotPasswordService; + } + + /// + /// Check if a given password reset request is still valid. + /// + /// + /// + /// + [AllowAnonymous] + [HttpGet("~/_/forgot-password-requests/is-valid")] + public override async Task HandleAsync(Guid id, CancellationToken cancellationToken = default) { + var request = await _forgotPasswordService.GetRequestAsync(id, cancellationToken); + if (request == default) { + return NotFound(); + } + + return Ok(request.IsExpired == false); + } +} diff --git a/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs new file mode 100644 index 0000000..5fb8213 --- /dev/null +++ b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs @@ -0,0 +1,21 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Root; + +public class GetApplicationVersionRoute : RouteBaseSync.WithoutRequest.WithActionResult +{ + private readonly IWebHostEnvironment _environment; + + /// + public GetApplicationVersionRoute(IWebHostEnvironment environment) { + _environment = environment; + } + + /// + /// Get the running api version number. + /// + /// + [HttpGet("~/_/version")] + public override ActionResult Handle() { + var versionFilePath = Path.Combine(_environment.WebRootPath, "version.txt"); + return Ok(System.IO.File.ReadAllText(versionFilePath)); + } +} diff --git a/server/src/Endpoints/Internal/Root/LogRoute.cs b/server/src/Endpoints/Internal/Root/LogRoute.cs new file mode 100644 index 0000000..48b497a --- /dev/null +++ b/server/src/Endpoints/Internal/Root/LogRoute.cs @@ -0,0 +1,16 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal.Root; + +public class LogRoute : RouteBaseSync.WithRequest.WithoutResult +{ + private readonly ILogger _logger; + + public LogRoute(ILogger logger) { + _logger = logger; + } + + [AllowAnonymous] + [HttpPost("~/_/log")] + public override void Handle([FromBody] string request) { + _logger.LogInformation(request); + } +} diff --git a/server/src/Endpoints/Internal/RouteBaseAsync.cs b/server/src/Endpoints/Internal/RouteBaseAsync.cs new file mode 100644 index 0000000..1bb0af0 --- /dev/null +++ b/server/src/Endpoints/Internal/RouteBaseAsync.cs @@ -0,0 +1,73 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +/// +/// A base class for an endpoint that accepts parameters. +/// +public static class RouteBaseAsync +{ + public static class WithRequest + { + public abstract class WithResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/server/src/Endpoints/Internal/RouteBaseSync.cs b/server/src/Endpoints/Internal/RouteBaseSync.cs new file mode 100644 index 0000000..173999d --- /dev/null +++ b/server/src/Endpoints/Internal/RouteBaseSync.cs @@ -0,0 +1,53 @@ +namespace IOL.GreatOffice.Api.Endpoints.Internal; + +/// +/// A base class for an endpoint that accepts parameters. +/// +public static class RouteBaseSync +{ + public static class WithRequest + { + public abstract class WithResult : BaseRoute + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + } + + public static class WithoutRequest + { + public abstract class WithResult : BaseRoute + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + } +} diff --git a/server/src/Endpoints/V1/ApiSpecV1.cs b/server/src/Endpoints/V1/ApiSpecV1.cs new file mode 100644 index 0000000..e4f9cc9 --- /dev/null +++ b/server/src/Endpoints/V1/ApiSpecV1.cs @@ -0,0 +1,18 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +public static class ApiSpecV1 +{ + private const int MAJOR = 1; + private const int MINOR = 0; + public const string VERSION_STRING = "1.0"; + + public static ApiSpecDocument Document => new() { + Version = new ApiVersion(MAJOR, MINOR), + VersionName = VERSION_STRING, + SwaggerPath = $"/swagger/{VERSION_STRING}/swagger.json", + OpenApiInfo = new OpenApiInfo { + Title = AppConstants.API_NAME, + Version = VERSION_STRING + } + }; +} diff --git a/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs new file mode 100644 index 0000000..e8abbf8 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs @@ -0,0 +1,52 @@ +using System.Text; + +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class CreateTokenRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CreateTokenRoute(AppDbContext context, IConfiguration configuration, ILogger logger) { + _context = context; + _configuration = configuration; + _logger = logger; + } + + /// + /// Create a new api token with the provided claims. + /// + /// The claims to set on the api token + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpPost("~/v{version:apiVersion}/api-tokens/create")] + [ProducesResponseType(200, Type = typeof(string))] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + public override ActionResult Handle(ApiAccessToken.ApiAccessTokenDto request) { + var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id); + if (user == default) { + return NotFound(new ErrorResult("User does not exist")); + } + + var token_entropy = _configuration.GetValue("TOKEN_ENTROPY"); + if (token_entropy.IsNullOrWhiteSpace()) { + _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled"); + return NotFound(); + } + + var access_token = new ApiAccessToken() { + Id = Guid.NewGuid(), + User = user, + ExpiryDate = request.ExpiryDate.ToUniversalTime(), + AllowCreate = request.AllowCreate, + AllowRead = request.AllowRead, + AllowDelete = request.AllowDelete, + AllowUpdate = request.AllowUpdate + }; + + _context.AccessTokens.Add(access_token); + _context.SaveChanges(); + return Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(access_token.Id.ToString().EncryptWithAes(token_entropy)))); + } +} diff --git a/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs new file mode 100644 index 0000000..a90b4c0 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs @@ -0,0 +1,33 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class DeleteTokenRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + private readonly ILogger _logger; + + public DeleteTokenRoute(AppDbContext context, ILogger logger) { + _context = context; + _logger = logger; + } + + /// + /// Delete an api token, rendering it unusable + /// + /// Id of the token to delete + /// Nothing + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpDelete("~/v{version:apiVersion}/api-tokens/delete")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public override ActionResult Handle(Guid id) { + var token = _context.AccessTokens.SingleOrDefault(c => c.Id == id); + if (token == default) { + _logger.LogWarning("A deletion request of an already deleted (maybe) api token was received."); + return NotFound(); + } + + _context.AccessTokens.Remove(token); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs new file mode 100644 index 0000000..59fd077 --- /dev/null +++ b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs @@ -0,0 +1,22 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens; + +public class GetTokensRoute : RouteBaseSync.WithoutRequest.WithResult>> +{ + private readonly AppDbContext _context; + + public GetTokensRoute(AppDbContext context) { + _context = context; + } + + /// + /// Get all tokens, both active and inactive. + /// + /// A list of tokens + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [HttpGet("~/v{version:apiVersion}/api-tokens")] + [ProducesResponseType(200, Type = typeof(List))] + [ProducesResponseType(204)] + public override ActionResult> Handle() { + return Ok(_context.AccessTokens.Where(c => c.User.Id == LoggedInUser.Id).Select(c => c.AsDto)); + } +} diff --git a/server/src/Endpoints/V1/BaseRoute.cs b/server/src/Endpoints/V1/BaseRoute.cs new file mode 100644 index 0000000..e7d72ac --- /dev/null +++ b/server/src/Endpoints/V1/BaseRoute.cs @@ -0,0 +1,39 @@ +using System.Net.Http.Headers; + +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// +[ApiVersion(ApiSpecV1.VERSION_STRING)] +[Authorize(AuthenticationSchemes = AuthSchemes)] +[ApiController] +public class BaseRoute : ControllerBase +{ + private const string AuthSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + AppConstants.BASIC_AUTH_SCHEME; + + /// + /// User data for the currently logged on user. + /// + protected LoggedInUserModel LoggedInUser => new() { + Username = User.FindFirstValue(AppClaims.NAME), + Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(), + }; + + protected bool IsApiCall() { + if (!Request.Headers.ContainsKey("Authorization")) return false; + try { + var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]); + if (authHeader.Parameter == null) return false; + } catch { + return false; + } + + return true; + } + + protected bool HasApiPermission(string permission_key) { + var permission_claim = User.Claims.SingleOrDefault(c => c.Type == permission_key); + return permission_claim is { + Value: "True" + }; + } +} diff --git a/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs new file mode 100644 index 0000000..fac2b5e --- /dev/null +++ b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs @@ -0,0 +1,43 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class CreateCategoryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public CreateCategoryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Create a new time entry category. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [HttpPost("~/v{version:apiVersion}/categories/create")] + [ProducesResponseType(200, Type = typeof(TimeCategory.TimeCategoryDto))] + public override ActionResult Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) { + var duplicate = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .Any(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim()); + if (duplicate) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim()); + if (category != default) { + return Ok(category.AsDto); + } + } + + var newCategory = new TimeCategory(LoggedInUser.Id) { + Name = categoryTimeCategoryDto.Name.Trim(), + Color = categoryTimeCategoryDto.Color + }; + + _context.TimeCategories.Add(newCategory); + _context.SaveChanges(); + categoryTimeCategoryDto.Id = newCategory.Id; + return Ok(categoryTimeCategoryDto); + } +} diff --git a/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs new file mode 100644 index 0000000..3d438a0 --- /dev/null +++ b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs @@ -0,0 +1,38 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class DeleteCategoryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public DeleteCategoryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Delete a time entry category. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/categories/{id:guid}/delete")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + public override ActionResult Handle(Guid id) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + + if (category == default) { + return NotFound(); + } + + var entries = _context.TimeEntries + .Include(c => c.Category) + .Where(c => c.Category.Id == category.Id); + _context.TimeEntries.RemoveRange(entries); + _context.TimeCategories.Remove(category); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs new file mode 100644 index 0000000..a40a832 --- /dev/null +++ b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs @@ -0,0 +1,35 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +/// +public class GetCategoriesRoute : RouteBaseSync.WithoutRequest.WithActionResult> +{ + private readonly AppDbContext _context; + + /// + public GetCategoriesRoute(AppDbContext context) { + _context = context; + } + + /// + /// Get a minimal list of time entry categories. + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [ProducesResponseType(200, Type = typeof(List))] + [ProducesResponseType(204)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/categories")] + public override ActionResult> Handle() { + var categories = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .OrderByDescending(c => c.CreatedAt) + .Select(c => c.AsDto) + .ToList(); + + if (categories.Count == 0) { + return NoContent(); + } + + return Ok(categories); + } +} diff --git a/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs new file mode 100644 index 0000000..ca7dfdf --- /dev/null +++ b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs @@ -0,0 +1,39 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Categories; + +public class UpdateCategoryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public UpdateCategoryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Update a time entry category. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/categories/update")] + [ProducesResponseType(200)] + [ProducesResponseType(404)] + [ProducesResponseType(403)] + public override ActionResult Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) { + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == categoryTimeCategoryDto.Id); + if (category == default) { + return NotFound(); + } + + if (LoggedInUser.Id != category.UserId) { + return Forbid(); + } + + category.Name = categoryTimeCategoryDto.Name; + category.Color = categoryTimeCategoryDto.Color; + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs new file mode 100644 index 0000000..362e430 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs @@ -0,0 +1,65 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class CreateEntryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public CreateEntryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Create a time entry. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [ProducesResponseType(200)] + [ProducesResponseType(400, Type = typeof(ErrorResult))] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + [HttpPost("~/v{version:apiVersion}/entries/create")] + public override ActionResult Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) { + if (timeEntryTimeEntryDto.Stop == default) { + return BadRequest(new ErrorResult("Invalid form", "A stop date is required")); + } + + if (timeEntryTimeEntryDto.Start == default) { + return BadRequest(new ErrorResult("Invalid form", "A start date is required")); + } + + if (timeEntryTimeEntryDto.Category == default) { + return BadRequest(new ErrorResult("Invalid form", "A category is required")); + } + + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id); + if (category == default) { + return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}")); + } + + var entry = new TimeEntry(LoggedInUser.Id) { + Category = category, + Start = timeEntryTimeEntryDto.Start.ToUniversalTime(), + Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime(), + Description = timeEntryTimeEntryDto.Description, + }; + + if (timeEntryTimeEntryDto.Labels?.Count > 0) { + var labels = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .Where(c => timeEntryTimeEntryDto.Labels.Select(p => p.Id).Contains(c.Id)) + .ToList(); + if (labels.Count != timeEntryTimeEntryDto.Labels.Count) { + return NotFound(new ErrorResult("Not found", "Could not find all of the specified labels")); + } + + entry.Labels = labels; + } + + _context.TimeEntries.Add(entry); + _context.SaveChanges(); + return Ok(entry.AsDto); + } +} diff --git a/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs new file mode 100644 index 0000000..0850af0 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs @@ -0,0 +1,35 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// +public class DeleteEntryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public DeleteEntryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Delete a time entry. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/entries/{id:guid}/delete")] + [ProducesResponseType(404)] + [ProducesResponseType(200)] + public override ActionResult Handle(Guid id) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + if (entry == default) { + return NotFound(); + } + + _context.TimeEntries.Remove(entry); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs new file mode 100644 index 0000000..763ac8b --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs @@ -0,0 +1,60 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// +/// Query model for querying time entries. +/// +public class EntryQueryPayload +{ + /// + /// Duration to filter with. + /// + public TimeEntryQueryDuration Duration { get; set; } + + /// + /// List of categories to filter with. + /// + public List Categories { get; set; } + + /// + /// List of labels to filter with. + /// + public List Labels { get; set; } + + /// + /// Date range to filter with, only respected if Duration is set to TimeEntryQueryDuration.DATE_RANGE. + /// + /// + public QueryDateRange DateRange { get; set; } + + /// + /// Spesific date to filter with, only respected if Duration is set to TimeEntryQueryDuration.SPECIFIC_DATE. + /// + /// + public DateTime SpecificDate { get; set; } + + /// + /// Optional page number to show, goes well with PageSize. + /// + public int Page { get; set; } + + /// + /// Optional page size to show, goes well with Page. + /// + public int PageSize { get; set; } + + /// + /// Represents a date range. + /// + public class QueryDateRange + { + /// + /// Range start + /// + public DateTime From { get; set; } + + /// + /// Range end + /// + public DateTime To { get; set; } + } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs new file mode 100644 index 0000000..b1b07a3 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs @@ -0,0 +1,37 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +/// +/// Response given for a successful query. +/// +public class EntryQueryResponse +{ + /// + public EntryQueryResponse() { + Results = new List(); + } + + /// + /// List of entries. + /// + public List Results { get; set; } + + /// + /// Current page. + /// + public int Page { get; set; } + + /// + /// Current page size (amount of entries). + /// + public int PageSize { get; set; } + + /// + /// Total amount of entries in query. + /// + public int TotalSize { get; set; } + + /// + /// Total amount of page(s) in query. + /// + public int TotalPageCount { get; set; } +} diff --git a/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs new file mode 100644 index 0000000..c037b72 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs @@ -0,0 +1,186 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class EntryQueryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly ILogger _logger; + private readonly AppDbContext _context; + + public EntryQueryRoute(ILogger logger, AppDbContext context) { + _logger = logger; + _context = context; + } + + /// + /// Get a list of entries based on a given query. + /// + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpPost("~/v{version:apiVersion}/entries/query")] + [ProducesResponseType(204)] + [ProducesResponseType(400, Type = typeof(ErrorResult))] + [ProducesResponseType(200, Type = typeof(EntryQueryResponse))] + public override ActionResult Handle(EntryQueryPayload entryQuery) { + var result = new TimeQueryDto(); + + Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader); + var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC"); + var offsetInHours = tz.BaseUtcOffset.Hours; + + // this is fine as long as the client is not connecting from Australia: Lord Howe Island + // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country + if (tz.IsDaylightSavingTime(DateTime.UtcNow)) { + offsetInHours++; + } + + _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offsetInHours + " hours"); + var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz); + _logger.LogInformation("Querying data with date time: " + requestDateTime.ToString("u")); + + var skipCount = 0; + if (entryQuery.Page > 1) { + skipCount = entryQuery.PageSize * entryQuery.Page; + } + + result.Page = entryQuery.Page; + result.PageSize = entryQuery.PageSize; + + var baseQuery = _context.TimeEntries + .AsNoTracking() + .Include(c => c.Category) + .Include(c => c.Labels) + .Where(c => c.UserId == LoggedInUser.Id) + .ConditionalWhere(entryQuery.Categories?.Any() ?? false, c => entryQuery.Categories.Any(p => p.Id == c.Category.Id)) + .ConditionalWhere(entryQuery.Labels?.Any() ?? false, c => c.Labels.Any(l => entryQuery.Labels.Any(p => p.Id == l.Id))) + .OrderByDescending(c => c.Start); + + switch (entryQuery.Duration) { + case TimeEntryQueryDuration.TODAY: + var baseTodaysEntries = baseQuery + .Where(c => DateTime.Compare(c.Start.AddHours(offsetInHours).Date, DateTime.UtcNow.Date) == 0); + var baseTodaysEntriesCount = baseTodaysEntries.Count(); + + if (baseTodaysEntriesCount == 0) { + return NoContent(); + } + + result.TotalSize = baseTodaysEntriesCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseTodaysEntriesCount / entryQuery.PageSize)); + + var pagedTodaysEntries = baseTodaysEntries.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedTodaysEntries.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_WEEK: + var lastMonday = DateTime.UtcNow.StartOfWeek(DayOfWeek.Monday); + + var baseEntriesThisWeek = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Date >= lastMonday.Date && c.Start.AddHours(offsetInHours).Date <= DateTime.UtcNow.Date); + + var baseEntriesThisWeekCount = baseEntriesThisWeek.Count(); + + if (baseEntriesThisWeekCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisWeekCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisWeekCount / entryQuery.PageSize)); + + var pagedEntriesThisWeek = baseEntriesThisWeek.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisWeek.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_MONTH: + var baseEntriesThisMonth = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Month == DateTime.UtcNow.Month + && c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year); + var baseEntriesThisMonthCount = baseEntriesThisMonth.Count(); + if (baseEntriesThisMonthCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisMonthCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisMonthCount / entryQuery.PageSize)); + + var pagedEntriesThisMonth = baseEntriesThisMonth.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisMonth.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.THIS_YEAR: + var baseEntriesThisYear = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year); + + var baseEntriesThisYearCount = baseEntriesThisYear.Count(); + if (baseEntriesThisYearCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesThisYearCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisYearCount / entryQuery.PageSize)); + + var pagedEntriesThisYear = baseEntriesThisYear.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesThisYear.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.SPECIFIC_DATE: + var date = DateTime.SpecifyKind(entryQuery.SpecificDate, DateTimeKind.Utc); + var baseEntriesOnThisDate = baseQuery.Where(c => c.Start.AddHours(offsetInHours).Date == date.Date); + var baseEntriesOnThisDateCount = baseEntriesOnThisDate.Count(); + + if (baseEntriesOnThisDateCount == 0) { + return NoContent(); + } + + result.TotalSize = baseEntriesOnThisDateCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesOnThisDateCount / entryQuery.PageSize)); + + var pagedEntriesOnThisDate = baseEntriesOnThisDate.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedEntriesOnThisDate.Select(c => c.AsDto)); + break; + case TimeEntryQueryDuration.DATE_RANGE: + if (entryQuery.DateRange.From == default) { + return BadRequest(new ErrorResult("Invalid query", "From date cannot be empty")); + } + + var fromDate = DateTime.SpecifyKind(entryQuery.DateRange.From, DateTimeKind.Utc); + + if (entryQuery.DateRange.To == default) { + return BadRequest(new ErrorResult("Invalid query", "To date cannot be empty")); + } + + var toDate = DateTime.SpecifyKind(entryQuery.DateRange.To, DateTimeKind.Utc); + + if (DateTime.Compare(fromDate, toDate) > 0) { + return BadRequest(new ErrorResult("Invalid query", "To date cannot be less than From date")); + } + + var baseDateRangeEntries = baseQuery + .Where(c => c.Start.AddHours(offsetInHours).Date > fromDate && c.Start.AddHours(offsetInHours).Date <= toDate); + + var baseDateRangeEntriesCount = baseDateRangeEntries.Count(); + if (baseDateRangeEntriesCount == 0) { + return NoContent(); + } + + result.TotalSize = baseDateRangeEntriesCount; + result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseDateRangeEntriesCount / entryQuery.PageSize)); + + var pagedDateRangeEntries = baseDateRangeEntries.Skip(skipCount).Take(entryQuery.PageSize); + + result.Results.AddRange(pagedDateRangeEntries.Select(c => c.AsDto)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(entryQuery), "Unknown duration for query"); + } + + if (result.Results.Any() && result.Page == 0) { + result.Page = 1; + result.TotalPageCount = 1; + } + + return Ok(result); + } +} diff --git a/server/src/Endpoints/V1/Entries/GetEntryRoute.cs b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs new file mode 100644 index 0000000..87038db --- /dev/null +++ b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs @@ -0,0 +1,34 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class GetEntryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public GetEntryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Get a spesific time entry. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/entries/{id:guid}")] + [ProducesResponseType(404)] + [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))] + public override ActionResult Handle(Guid id) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .Include(c => c.Category) + .Include(c => c.Labels) + .SingleOrDefault(c => c.Id == id); + + if (entry == default) { + return NotFound(); + } + + return Ok(entry); + } +} diff --git a/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs new file mode 100644 index 0000000..ac233e0 --- /dev/null +++ b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs @@ -0,0 +1,66 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Entries; + +public class UpdateEntryRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + public UpdateEntryRoute(AppDbContext context) { + _context = context; + } + + /// + /// Update a time entry. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/entries/update")] + [ProducesResponseType(404, Type = typeof(ErrorResult))] + [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))] + public override ActionResult Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) { + var entry = _context.TimeEntries + .Where(c => c.UserId == LoggedInUser.Id) + .Include(c => c.Labels) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Id); + + if (entry == default) { + return NotFound(); + } + + var category = _context.TimeCategories + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id); + if (category == default) { + return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}")); + } + + entry.Start = timeEntryTimeEntryDto.Start.ToUniversalTime(); + entry.Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime(); + entry.Description = timeEntryTimeEntryDto.Description; + entry.Category = category; + + if (timeEntryTimeEntryDto.Labels?.Count > 0) { + var labels = new List(); + + foreach (var labelDto in timeEntryTimeEntryDto.Labels) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == labelDto.Id); + + if (label == default) { + continue; + } + + labels.Add(label); + } + + entry.Labels = labels; + } else { + entry.Labels = default; + } + + _context.SaveChanges(); + return Ok(entry.AsDto); + } +} diff --git a/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs new file mode 100644 index 0000000..31ef7d0 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs @@ -0,0 +1,46 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// +public class CreateLabelRoute : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public CreateLabelRoute(AppDbContext context) { + _context = context; + } + + /// + /// Create a time entry label. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)] + [HttpPost("~/v{version:apiVersion}/labels/create")] + public override ActionResult Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) { + var duplicate = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .Any(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim()); + if (duplicate) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim()); + + if (label != default) { + return Ok(label.AsDto); + } + } + + var newLabel = new TimeLabel(LoggedInUser.Id) { + Name = labelTimeLabelDto.Name.Trim(), + Color = labelTimeLabelDto.Color + }; + + _context.TimeLabels.Add(newLabel); + _context.SaveChanges(); + labelTimeLabelDto.Id = newLabel.Id; + return Ok(labelTimeLabelDto); + } +} diff --git a/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs new file mode 100644 index 0000000..d845a6f --- /dev/null +++ b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs @@ -0,0 +1,35 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// +public class DeleteLabelEndpoint : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public DeleteLabelEndpoint(AppDbContext context) { + _context = context; + } + + /// + /// Delete a time entry label. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)] + [HttpDelete("~/v{version:apiVersion}/labels/{id:guid}/delete")] + public override ActionResult Handle(Guid id) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == id); + + if (label == default) { + return NotFound(); + } + + _context.TimeLabels.Remove(label); + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/Labels/GetLabelRoute.cs b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs new file mode 100644 index 0000000..c9ccef3 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs @@ -0,0 +1,34 @@ + +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// +public class GetEndpoint : RouteBaseSync.WithoutRequest.WithActionResult> +{ + private readonly AppDbContext _context; + + /// + public GetEndpoint(AppDbContext context) { + _context = context; + } + + /// + /// Get a minimal list of time entry labels. + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)] + [HttpGet("~/v{version:apiVersion}/labels")] + public override ActionResult> Handle() { + var labels = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .OrderByDescending(c => c.CreatedAt) + .Select(c => c.AsDto) + .ToList(); + + if (labels.Count == 0) { + return NoContent(); + } + + return Ok(labels); + } +} diff --git a/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs new file mode 100644 index 0000000..0868671 --- /dev/null +++ b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs @@ -0,0 +1,38 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1.Labels; + +/// +public class UpdateLabelEndpoint : RouteBaseSync.WithRequest.WithActionResult +{ + private readonly AppDbContext _context; + + /// + public UpdateLabelEndpoint(AppDbContext context) { + _context = context; + } + + /// + /// Update a time entry label. + /// + /// + /// + [ApiVersion(ApiSpecV1.VERSION_STRING)] + [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)] + [HttpPost("~/v{version:apiVersion}/labels/update")] + public override ActionResult Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) { + var label = _context.TimeLabels + .Where(c => c.UserId == LoggedInUser.Id) + .SingleOrDefault(c => c.Id == labelTimeLabelDto.Id); + if (label == default) { + return NotFound(); + } + + if (LoggedInUser.Id != label.User.Id) { + return Forbid(); + } + + label.Name = labelTimeLabelDto.Name; + label.Color = labelTimeLabelDto.Color; + _context.SaveChanges(); + return Ok(); + } +} diff --git a/server/src/Endpoints/V1/RouteBaseAsync.cs b/server/src/Endpoints/V1/RouteBaseAsync.cs new file mode 100644 index 0000000..1d179f7 --- /dev/null +++ b/server/src/Endpoints/V1/RouteBaseAsync.cs @@ -0,0 +1,73 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// +/// A base class for an endpoint that accepts parameters. +/// +public static class RouteBaseAsync +{ + public static class WithRequest + { + public abstract class WithResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task> HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task HandleAsync( + TRequest request, + CancellationToken cancellationToken = default + ); + } + } + + public static class WithoutRequest + { + public abstract class WithResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task> HandleAsync( + CancellationToken cancellationToken = default + ); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract Task HandleAsync( + CancellationToken cancellationToken = default + ); + } + } +} diff --git a/server/src/Endpoints/V1/RouteBaseSync.cs b/server/src/Endpoints/V1/RouteBaseSync.cs new file mode 100644 index 0000000..cb27c14 --- /dev/null +++ b/server/src/Endpoints/V1/RouteBaseSync.cs @@ -0,0 +1,53 @@ +namespace IOL.GreatOffice.Api.Endpoints.V1; + +/// +/// A base class for an endpoint that accepts parameters. +/// +public static class RouteBaseSync +{ + public static class WithRequest + { + public abstract class WithResult : BaseRoute + { + public abstract TResponse Handle(TRequest request); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(TRequest request); + } + } + + public static class WithoutRequest + { + public abstract class WithResult : BaseRoute + { + public abstract TResponse Handle(); + } + + public abstract class WithoutResult : BaseRoute + { + public abstract void Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + + public abstract class WithActionResult : BaseRoute + { + public abstract ActionResult Handle(); + } + } +} -- cgit v1.3