summaryrefslogtreecommitdiffstats
path: root/server/src/Endpoints
diff options
context:
space:
mode:
Diffstat (limited to 'server/src/Endpoints')
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountRoute.cs44
-rw-r--r--server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs49
-rw-r--r--server/src/Endpoints/Internal/Account/GetArchiveRoute.cs62
-rw-r--r--server/src/Endpoints/Internal/Account/GetRoute.cs30
-rw-r--r--server/src/Endpoints/Internal/Account/LoginPayload.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/LoginRoute.cs37
-rw-r--r--server/src/Endpoints/Internal/Account/LogoutRoute.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs51
-rw-r--r--server/src/Endpoints/Internal/BaseRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs59
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs14
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs29
-rw-r--r--server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs21
-rw-r--r--server/src/Endpoints/Internal/Root/LogRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/RouteBaseAsync.cs73
-rw-r--r--server/src/Endpoints/Internal/RouteBaseSync.cs53
-rw-r--r--server/src/Endpoints/V1/ApiSpecV1.cs18
-rw-r--r--server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs52
-rw-r--r--server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs33
-rw-r--r--server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs22
-rw-r--r--server/src/Endpoints/V1/BaseRoute.cs39
-rw-r--r--server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs43
-rw-r--r--server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs38
-rw-r--r--server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs39
-rw-r--r--server/src/Endpoints/V1/Entries/CreateEntryRoute.cs65
-rw-r--r--server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryPayload.cs60
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryResponse.cs37
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryRoute.cs186
-rw-r--r--server/src/Endpoints/V1/Entries/GetEntryRoute.cs34
-rw-r--r--server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs66
-rw-r--r--server/src/Endpoints/V1/Labels/CreateLabelRoute.cs46
-rw-r--r--server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Labels/GetLabelRoute.cs34
-rw-r--r--server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs38
-rw-r--r--server/src/Endpoints/V1/RouteBaseAsync.cs73
-rw-r--r--server/src/Endpoints/V1/RouteBaseSync.cs53
43 files changed, 1798 insertions, 0 deletions
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;
+
+/// <summary>
+/// Payload for creating new user accounts.
+/// </summary>
+public class CreateAccountPayload
+{
+ /// <summary>
+ /// Username for the new account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password for the new account.
+ /// </summary>
+ 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;
+
+/// <inheritdoc />
+public class CreateAccountRoute : RouteBaseAsync.WithRequest<CreateAccountPayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateAccountRoute(UserService userService, AppDbContext context) {
+ _userService = userService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new user account.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/create")]
+ public override async Task<ActionResult> 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<string>.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;
+
+/// <inheritdoc />
+public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateInitialAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Create an initial user account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/create-initial")]
+ public override async Task<ActionResult> 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;
+
+ /// <inheritdoc />
+ public DeleteAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Delete the logged on user's account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpDelete("~/_/account/delete")]
+ public override async Task<ActionResult> 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<UserArchiveDto>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountArchiveRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a data archive with the currently logged on user's data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account/archive")]
+ public override async Task<ActionResult<UserArchiveDto>> 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<LoggedInUserModel>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get the logged on user's session data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account")]
+ public override async Task<ActionResult<LoggedInUserModel>> 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;
+
+/// <summary>
+/// Payload for logging in a user.
+/// </summary>
+public class LoginPayload
+{
+ /// <summary>
+ /// Username of the user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password of the user's account.
+ /// </summary>
+ public string Password { get; set; }
+
+ /// <summary>
+ /// Specify that the created session should be long lived and continually refreshed.
+ /// </summary>
+ 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<LoginPayload>
+ .WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public LoginRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Login a user.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/login")]
+ public override async Task<ActionResult> 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;
+ }
+
+ /// <summary>
+ /// Logout a user.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/logout")]
+ public override async Task<ActionResult> 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;
+
+/// <summary>
+/// Payload for updating an account.
+/// </summary>
+public class UpdatePayload
+{
+ /// <summary>
+ /// Username to set on the logged on user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password to set on the logged on user's account.
+ /// </summary>
+ 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<UpdatePayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public UpdateAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update the logged on user's data.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpPost("~/_/account/update")]
+ public override async Task<ActionResult> 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
+{
+ /// <summary>
+ /// User data for the currently logged on user.
+ /// </summary>
+ 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;
+
+/// <inheritdoc />
+public class CreateResetRequestRoute : RouteBaseAsync.WithRequest<string>.WithActionResult
+{
+ private readonly ILogger<CreateResetRequestRoute> _logger;
+ private readonly ForgotPasswordService _forgotPasswordService;
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, ForgotPasswordService forgotPasswordService, AppDbContext context) {
+ _logger = logger;
+ _forgotPasswordService = forgotPasswordService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new password reset request.
+ /// </summary>
+ /// <param name="username"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/create")]
+ public override async Task<ActionResult> 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
+{
+ /// <summary>
+ /// Id of the password reset request to fulfill
+ /// </summary>
+ public Guid Id { get; set; }
+
+ /// <summary>
+ /// New password to set on the relevant account
+ /// </summary>
+ 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;
+
+/// <inheritdoc />
+public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest<FulfillResetRequestPayload>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public FulfillResetRequestRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Fulfill a password reset request.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/forgot-password-requests/fulfill")]
+ public override async Task<ActionResult> 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;
+
+/// <inheritdoc />
+public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest<Guid>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public IsResetRequestValidRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Check if a given password reset request is still valid.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/is-valid")]
+ public override async Task<ActionResult> 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<string>
+{
+ private readonly IWebHostEnvironment _environment;
+
+ /// <inheritdoc />
+ public GetApplicationVersionRoute(IWebHostEnvironment environment) {
+ _environment = environment;
+ }
+
+ /// <summary>
+ /// Get the running api version number.
+ /// </summary>
+ /// <returns></returns>
+ [HttpGet("~/_/version")]
+ public override ActionResult<string> 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<string>.WithoutResult
+{
+ private readonly ILogger<LogRoute> _logger;
+
+ public LogRoute(ILogger<LogRoute> 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;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseAsync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> 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;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseSync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> 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<ApiAccessToken.ApiAccessTokenDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<CreateTokenRoute> _logger;
+
+ public CreateTokenRoute(AppDbContext context, IConfiguration configuration, ILogger<CreateTokenRoute> logger) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Create a new api token with the provided claims.
+ /// </summary>
+ /// <param name="request">The claims to set on the api token</param>
+ /// <returns></returns>
+ [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<string>("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<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly ILogger<DeleteTokenRoute> _logger;
+
+ public DeleteTokenRoute(AppDbContext context, ILogger<DeleteTokenRoute> logger) {
+ _context = context;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Delete an api token, rendering it unusable
+ /// </summary>
+ /// <param name="id">Id of the token to delete</param>
+ /// <returns>Nothing</returns>
+ [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<ActionResult<List<ApiAccessToken.ApiAccessTokenDto>>>
+{
+ private readonly AppDbContext _context;
+
+ public GetTokensRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get all tokens, both active and inactive.
+ /// </summary>
+ /// <returns>A list of tokens</returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [HttpGet("~/v{version:apiVersion}/api-tokens")]
+ [ProducesResponseType(200, Type = typeof(List<ApiAccessToken.ApiAccessTokenDto>))]
+ [ProducesResponseType(204)]
+ public override ActionResult<List<ApiAccessToken.ApiAccessTokenDto>> 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;
+
+/// <inheritdoc />
+[ApiVersion(ApiSpecV1.VERSION_STRING)]
+[Authorize(AuthenticationSchemes = AuthSchemes)]
+[ApiController]
+public class BaseRoute : ControllerBase
+{
+ private const string AuthSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + AppConstants.BASIC_AUTH_SCHEME;
+
+ /// <summary>
+ /// User data for the currently logged on user.
+ /// </summary>
+ 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<TimeCategory.TimeCategoryDto>.WithActionResult<TimeCategory.TimeCategoryDto>
+{
+ private readonly AppDbContext _context;
+
+ public CreateCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new time entry category.
+ /// </summary>
+ /// <param name="categoryTimeCategoryDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)]
+ [HttpPost("~/v{version:apiVersion}/categories/create")]
+ [ProducesResponseType(200, Type = typeof(TimeCategory.TimeCategoryDto))]
+ public override ActionResult<TimeCategory.TimeCategoryDto> 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<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ public DeleteCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry category.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [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;
+
+/// <inheritdoc />
+public class GetCategoriesRoute : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeCategory.TimeCategoryDto>>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetCategoriesRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a minimal list of time entry categories.
+ /// </summary>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [ProducesResponseType(200, Type = typeof(List<TimeCategory.TimeCategoryDto>))]
+ [ProducesResponseType(204)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpGet("~/v{version:apiVersion}/categories")]
+ public override ActionResult<List<TimeCategory.TimeCategoryDto>> 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<TimeCategory.TimeCategoryDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ public UpdateCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry category.
+ /// </summary>
+ /// <param name="categoryTimeCategoryDto"></param>
+ /// <returns></returns>
+ [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<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public CreateEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a time entry.
+ /// </summary>
+ /// <param name="timeEntryTimeEntryDto"></param>
+ /// <returns></returns>
+ [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<TimeEntry.TimeEntryDto> 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;
+
+/// <inheritdoc />
+public class DeleteEntryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public DeleteEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [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;
+
+/// <summary>
+/// Query model for querying time entries.
+/// </summary>
+public class EntryQueryPayload
+{
+ /// <summary>
+ /// Duration to filter with.
+ /// </summary>
+ public TimeEntryQueryDuration Duration { get; set; }
+
+ /// <summary>
+ /// List of categories to filter with.
+ /// </summary>
+ public List<TimeCategory.TimeCategoryDto> Categories { get; set; }
+
+ /// <summary>
+ /// List of labels to filter with.
+ /// </summary>
+ public List<TimeLabel.TimeLabelDto> Labels { get; set; }
+
+ /// <summary>
+ /// Date range to filter with, only respected if Duration is set to TimeEntryQueryDuration.DATE_RANGE.
+ /// </summary>
+ /// <see cref="TimeEntryQueryDuration"/>
+ public QueryDateRange DateRange { get; set; }
+
+ /// <summary>
+ /// Spesific date to filter with, only respected if Duration is set to TimeEntryQueryDuration.SPECIFIC_DATE.
+ /// </summary>
+ /// <see cref="TimeEntryQueryDuration"/>
+ public DateTime SpecificDate { get; set; }
+
+ /// <summary>
+ /// Optional page number to show, goes well with PageSize.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Optional page size to show, goes well with Page.
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Represents a date range.
+ /// </summary>
+ public class QueryDateRange
+ {
+ /// <summary>
+ /// Range start
+ /// </summary>
+ public DateTime From { get; set; }
+
+ /// <summary>
+ /// Range end
+ /// </summary>
+ 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;
+
+/// <summary>
+/// Response given for a successful query.
+/// </summary>
+public class EntryQueryResponse
+{
+ /// <inheritdoc cref="EntryQueryResponse"/>
+ public EntryQueryResponse() {
+ Results = new List<TimeEntry.TimeEntryDto>();
+ }
+
+ /// <summary>
+ /// List of entries.
+ /// </summary>
+ public List<TimeEntry.TimeEntryDto> Results { get; set; }
+
+ /// <summary>
+ /// Current page.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Current page size (amount of entries).
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Total amount of entries in query.
+ /// </summary>
+ public int TotalSize { get; set; }
+
+ /// <summary>
+ /// Total amount of page(s) in query.
+ /// </summary>
+ 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<EntryQueryPayload>.WithActionResult<EntryQueryResponse>
+{
+ private readonly ILogger<EntryQueryRoute> _logger;
+ private readonly AppDbContext _context;
+
+ public EntryQueryRoute(ILogger<EntryQueryRoute> logger, AppDbContext context) {
+ _logger = logger;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a list of entries based on a given query.
+ /// </summary>
+ /// <param name="entryQuery"></param>
+ /// <returns></returns>
+ /// <exception cref="ArgumentOutOfRangeException"></exception>
+ [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<EntryQueryResponse> 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<Guid>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public GetEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a spesific time entry.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [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<TimeEntry.TimeEntryDto> 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<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public UpdateEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry.
+ /// </summary>
+ /// <param name="timeEntryTimeEntryDto"></param>
+ /// <returns></returns>
+ [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<TimeEntry.TimeEntryDto> 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<TimeLabel>();
+
+ 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;
+
+/// <inheritdoc />
+public class CreateLabelRoute : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult<TimeLabel.TimeLabelDto>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateLabelRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a time entry label.
+ /// </summary>
+ /// <param name="labelTimeLabelDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)]
+ [HttpPost("~/v{version:apiVersion}/labels/create")]
+ public override ActionResult<TimeLabel.TimeLabelDto> 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;
+
+/// <inheritdoc />
+public class DeleteLabelEndpoint : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public DeleteLabelEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry label.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [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;
+
+/// <inheritdoc />
+public class GetEndpoint : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeLabel.TimeLabelDto>>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a minimal list of time entry labels.
+ /// </summary>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpGet("~/v{version:apiVersion}/labels")]
+ public override ActionResult<List<TimeLabel.TimeLabelDto>> 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;
+
+/// <inheritdoc />
+public class UpdateLabelEndpoint : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public UpdateLabelEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry label.
+ /// </summary>
+ /// <param name="labelTimeLabelDto"></param>
+ /// <returns></returns>
+ [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;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseAsync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> 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;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseSync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle();
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle();
+ }
+ }
+}