summaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
committerivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
commita640703f2da8815dc26ad1600a6f206be1624379 (patch)
treedbda195fb5783d16487e557e06471cf848b75427 /server
downloadgreatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz
greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip
feat: Initial after clean slate
Diffstat (limited to 'server')
-rw-r--r--server/.dockerignore10
-rw-r--r--server/.version1
-rw-r--r--server/.version-dev1
-rw-r--r--server/CHANGELOG.md68
-rw-r--r--server/Dockerfile16
-rwxr-xr-xserver/build_and_push.sh96
-rw-r--r--server/cliff.toml62
-rw-r--r--server/src/Data/AppDbContext.cs58
-rw-r--r--server/src/Data/Database/ApiAccessToken.cs31
-rw-r--r--server/src/Data/Database/Base.cs14
-rw-r--r--server/src/Data/Database/BaseWithOwner.cs24
-rw-r--r--server/src/Data/Database/ForgotPasswordRequest.cs23
-rw-r--r--server/src/Data/Database/GithubUserMapping.cs9
-rw-r--r--server/src/Data/Database/Tenant.cs10
-rw-r--r--server/src/Data/Database/TimeCategory.cs31
-rw-r--r--server/src/Data/Database/TimeEntry.cs45
-rw-r--r--server/src/Data/Database/TimeLabel.cs31
-rw-r--r--server/src/Data/Database/User.cs29
-rw-r--r--server/src/Data/Dtos/TimeQueryDto.cs34
-rw-r--r--server/src/Data/Dtos/UserArchiveDto.cs131
-rw-r--r--server/src/Data/Enums/TimeEntryQueryDuration.cs37
-rw-r--r--server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs21
-rw-r--r--server/src/Data/Exceptions/UserNotFoundException.cs19
-rw-r--r--server/src/Data/Models/ApiSpecDocument.cs9
-rw-r--r--server/src/Data/Models/AppPath.cs23
-rw-r--r--server/src/Data/Models/LoggedInUserModel.cs8
-rw-r--r--server/src/Data/Results/ErrorResult.cs12
-rw-r--r--server/src/Data/Static/AppClaims.cs8
-rw-r--r--server/src/Data/Static/AppConstants.cs11
-rw-r--r--server/src/Data/Static/AppEnvironmentVariables.cs27
-rw-r--r--server/src/Data/Static/AppHeaders.cs6
-rw-r--r--server/src/Data/Static/AppPaths.cs17
-rw-r--r--server/src/Data/Static/JsonSettings.cs11
-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
-rw-r--r--server/src/IOL.GreatOffice.Api.csproj48
-rw-r--r--server/src/Jobs/JobRegister.cs18
-rw-r--r--server/src/Jobs/TokenCleanupJob.cs21
-rw-r--r--server/src/Migrations/20210517202115_InitialMigration.Designer.cs238
-rw-r--r--server/src/Migrations/20210517202115_InitialMigration.cs162
-rw-r--r--server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs229
-rw-r--r--server/src/Migrations/20210522165932_RenameNoteToDescription.cs34
-rw-r--r--server/src/Migrations/20211002113037_V6Migration.Designer.cs233
-rw-r--r--server/src/Migrations/20211002113037_V6Migration.cs130
-rw-r--r--server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs270
-rw-r--r--server/src/Migrations/20220225143559_GithubUserMappings.cs43
-rw-r--r--server/src/Migrations/20220319135910_RenameCreated.Designer.cs270
-rw-r--r--server/src/Migrations/20220319135910_RenameCreated.cs65
-rw-r--r--server/src/Migrations/20220319144958_ModifiedAt.Designer.cs290
-rw-r--r--server/src/Migrations/20220319144958_ModifiedAt.cs66
-rw-r--r--server/src/Migrations/20220319203018_UserBase.Designer.cs322
-rw-r--r--server/src/Migrations/20220319203018_UserBase.cs140
-rw-r--r--server/src/Migrations/20220320115601_Update1.Designer.cs342
-rw-r--r--server/src/Migrations/20220320115601_Update1.cs139
-rw-r--r--server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs344
-rw-r--r--server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs57
-rw-r--r--server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs401
-rw-r--r--server/src/Migrations/20220529190359_ApiAccessTokens.cs48
-rw-r--r--server/src/Migrations/20220530174741_Tenants.Designer.cs710
-rw-r--r--server/src/Migrations/20220530174741_Tenants.cs481
-rw-r--r--server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs686
-rw-r--r--server/src/Migrations/20220530175322_RemoveUnusedNavs.cs78
-rw-r--r--server/src/Migrations/AppDbContextModelSnapshot.cs684
-rw-r--r--server/src/Program.cs261
-rw-r--r--server/src/Properties/launchSettings.json14
-rw-r--r--server/src/Services/ForgotPasswordService.cs115
-rw-r--r--server/src/Services/MailService.cs52
-rw-r--r--server/src/Services/UserService.cs50
-rw-r--r--server/src/Utilities/BasicAuthenticationAttribute.cs39
-rw-r--r--server/src/Utilities/BasicAuthenticationHandler.cs79
-rw-r--r--server/src/Utilities/ConfigurationExtensions.cs37
-rw-r--r--server/src/Utilities/GithubAuthenticationHelpers.cs84
-rw-r--r--server/src/Utilities/QuartzJsonSerializer.cs16
-rw-r--r--server/src/Utilities/SwaggerDefaultValues.cs58
-rw-r--r--server/src/Utilities/SwaggerGenOptionsExtensions.cs43
-rw-r--r--server/src/appsettings.json22
-rw-r--r--server/src/wwwroot/version.txt1
118 files changed, 10151 insertions, 0 deletions
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..2b24da3
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,10 @@
+*/**/bin*
+*/**/obj*
+server-secrets.*
+.git
+*/**/node_modules/*
+src/web-app
+src/web-shared
+src/tests
+build_and_push.sh
+cloc.sh
diff --git a/server/.version b/server/.version
new file mode 100644
index 0000000..3f430af
--- /dev/null
+++ b/server/.version
@@ -0,0 +1 @@
+v18
diff --git a/server/.version-dev b/server/.version-dev
new file mode 100644
index 0000000..863c2f5
--- /dev/null
+++ b/server/.version-dev
@@ -0,0 +1 @@
+v35-server-dev
diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md
new file mode 100644
index 0000000..47e1f6c
--- /dev/null
+++ b/server/CHANGELOG.md
@@ -0,0 +1,68 @@
+# Changelog
+
+## [unreleased]
+
+### Bug Fixes
+
+- BUildscript
+- .
+- .
+- Incorrect paths
+- Inncorrect paths
+
+### Features
+
+- Add more configuration for seperated apps and evironments
+- Print env when starting server
+- Seperate projects app and profile app
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v8-frontpage
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v9-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v14-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v8-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v7-web-app-dev
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v13-web-app-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v20-web-app
+- Bump version
+- Update CHANGELOG.md for v19-web-app
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v35-server-dev
+
+### Refactor
+
+- Remove CANONICAL_NAME from app and expected environment variables
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v34-server-dev
+
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..adc4be3
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
+WORKDIR /source
+
+# Copy csproj and restore as distinct layers
+COPY src/*.csproj ./
+RUN dotnet restore
+
+# Copy everything else and build
+COPY src/ ./
+RUN dotnet publish -c Release -o out
+
+# Build runtime image
+FROM mcr.microsoft.com/dotnet/aspnet:6.0
+WORKDIR /app
+COPY --from=build-env /source/out .
+ENTRYPOINT ["dotnet", "IOL.GreatOffice.Api.dll"]
diff --git a/server/build_and_push.sh b/server/build_and_push.sh
new file mode 100755
index 0000000..5bcc0d0
--- /dev/null
+++ b/server/build_and_push.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-server-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT+1))-server"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+IMAGE_NAME="greatoffice-api"
+HUB_NAME="dr.ivar.systems/greatoffice-api"
+
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ..
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >| .version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >| .version
+ git add .version
+fi
+
+echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..."
+echo
+
+# Put version.txt inside of server
+pushd src/wwwroot
+echo "$NEW_VERSION" >version.txt
+git add version.txt
+popd
+
+git commit --quiet -m "chore(release): Bump version";
+
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION"
+ git cliff -r ../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md
+ git add CHANGELOG.md
+ git commit --quiet -m "$commit_msg";
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+
+# Build docker image
+echo "Building docker image"
+echo
+docker build -t $IMAGE_NAME:$NEW_VERSION .
+
+docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev
+fi
+if [ ${1-prod} == "prod" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest
+fi
+
+# Optionally push images to docker registry
+echo "Press CTRL+C to exit or press ENTER to push docker image to registry"
+read -n 1
+docker push $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker push $HUB_NAME:latest-dev
+fi
+
+if [ ${1-prod} == "prod" ]; then
+ docker push $HUB_NAME:latest
+fi
diff --git a/server/cliff.toml b/server/cliff.toml
new file mode 100644
index 0000000..955a72b
--- /dev/null
+++ b/server/cliff.toml
@@ -0,0 +1,62 @@
+# configuration file for git-cliff (0.1.0)
+
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/docs/#introduction
+body = """
+{% if version %}\
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+ ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
+ {% endfor %}
+{% endfor %}\n
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = true
+# regex for preprocessing the commit messages
+commit_preprocessors = [
+ { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" },
+ { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" },
+]
+# regex for parsing and grouping commits
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactor" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore", group = "Miscellaneous Tasks" },
+]
+# filter out the commits that are not matched by commit parsers
+filter_commits = true
+# glob pattern for matching git tags
+tag_pattern = "v.*"
+# regex for skipping tags
+skip_tags = "v0.1.0-beta.1"
+# regex for ignoring tags
+ignore_tags = ""
+# sort the tags chronologically
+date_order = true
+# sort the commits inside sections by oldest/newest order
+sort_commits = "newest"
diff --git a/server/src/Data/AppDbContext.cs b/server/src/Data/AppDbContext.cs
new file mode 100644
index 0000000..3f949dd
--- /dev/null
+++ b/server/src/Data/AppDbContext.cs
@@ -0,0 +1,58 @@
+namespace IOL.GreatOffice.Api.Data;
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
+ public DbSet<User> Users { get; set; }
+ public DbSet<ForgotPasswordRequest> ForgotPasswordRequests { get; set; }
+ public DbSet<TimeLabel> TimeLabels { get; set; }
+ public DbSet<TimeEntry> TimeEntries { get; set; }
+ public DbSet<TimeCategory> TimeCategories { get; set; }
+ public DbSet<GithubUserMapping> GithubUserMappings { get; set; }
+ public DbSet<ApiAccessToken> AccessTokens { get; set; }
+ public DbSet<Tenant> Tenants { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) {
+ modelBuilder.Entity<User>(e => {
+ e.ToTable("users");
+ });
+
+ modelBuilder.Entity<ForgotPasswordRequest>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity<TimeCategory>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity<TimeLabel>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity<TimeEntry>(e => {
+ e.HasOne(c => c.User);
+ e.HasOne(c => c.Category);
+ e.HasMany(c => c.Labels);
+ e.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity<GithubUserMapping>(e => {
+ e.HasOne(c => c.User);
+ e.HasKey(c => c.GithubId);
+ e.ToTable("github_user_mappings");
+ });
+
+ modelBuilder.Entity<ApiAccessToken>(e => {
+ e.ToTable("api_access_tokens");
+ });
+
+ modelBuilder.Entity<Tenant>(e => {
+ e.ToTable("tenants");
+ });
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/server/src/Data/Database/ApiAccessToken.cs b/server/src/Data/Database/ApiAccessToken.cs
new file mode 100644
index 0000000..3eff5f3
--- /dev/null
+++ b/server/src/Data/Database/ApiAccessToken.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class ApiAccessToken : Base
+{
+ public User User { get; set; }
+ public DateTime ExpiryDate { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
+ public bool HasExpired => ExpiryDate < DateTime.UtcNow;
+ public ApiAccessTokenDto AsDto => new(this);
+
+ public class ApiAccessTokenDto
+ {
+ public ApiAccessTokenDto(ApiAccessToken source) {
+ ExpiryDate = source.ExpiryDate;
+ AllowRead = source.AllowRead;
+ AllowCreate = source.AllowCreate;
+ AllowUpdate = source.AllowUpdate;
+ AllowDelete = source.AllowDelete;
+ }
+
+ public DateTime ExpiryDate { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
+ public bool HasExpired => ExpiryDate < DateTime.UtcNow;
+ }
+}
diff --git a/server/src/Data/Database/Base.cs b/server/src/Data/Database/Base.cs
new file mode 100644
index 0000000..2439668
--- /dev/null
+++ b/server/src/Data/Database/Base.cs
@@ -0,0 +1,14 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Base
+{
+ protected Base() {
+ Id = Guid.NewGuid();
+ CreatedAt = DateTime.UtcNow;
+ }
+
+ public Guid Id { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? ModifiedAt { get; private set; }
+ public void Modified() => ModifiedAt = DateTime.UtcNow;
+}
diff --git a/server/src/Data/Database/BaseWithOwner.cs b/server/src/Data/Database/BaseWithOwner.cs
new file mode 100644
index 0000000..eb4438d
--- /dev/null
+++ b/server/src/Data/Database/BaseWithOwner.cs
@@ -0,0 +1,24 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+/// <summary>
+/// Base class for all entities.
+/// </summary>
+public class BaseWithOwner : Base
+{
+ protected BaseWithOwner() { }
+
+ protected BaseWithOwner(Guid userId) {
+ UserId = userId;
+ }
+
+ public Guid UserId { get; init; }
+ public User User { get; init; }
+ public Guid TenantId { get; set; }
+ public Tenant Tenant { get; init; }
+ public Guid ModifiedById { get; init; }
+ public User ModifiedBy { get; set; }
+ public Guid CreatedById { get; init; }
+ public User CreatedBy { get; set; }
+ public Guid DeletedById { get; init; }
+ public User DeletedBy { get; set; }
+}
diff --git a/server/src/Data/Database/ForgotPasswordRequest.cs b/server/src/Data/Database/ForgotPasswordRequest.cs
new file mode 100644
index 0000000..164f09d
--- /dev/null
+++ b/server/src/Data/Database/ForgotPasswordRequest.cs
@@ -0,0 +1,23 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class ForgotPasswordRequest
+{
+ public ForgotPasswordRequest() { }
+
+ public ForgotPasswordRequest(User user) {
+ CreatedAt = DateTime.UtcNow;
+ Id = Guid.NewGuid();
+ User = user;
+ }
+
+ public Guid Id { get; set; }
+ public Guid UserId { get; set; }
+ public User User { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ [NotMapped]
+ public DateTime ExpirationDate => CreatedAt.AddMinutes(15);
+
+ [NotMapped]
+ public bool IsExpired => DateTime.Compare(ExpirationDate, DateTime.UtcNow) < 0;
+}
diff --git a/server/src/Data/Database/GithubUserMapping.cs b/server/src/Data/Database/GithubUserMapping.cs
new file mode 100644
index 0000000..dbdb2b7
--- /dev/null
+++ b/server/src/Data/Database/GithubUserMapping.cs
@@ -0,0 +1,9 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class GithubUserMapping
+{
+ public User User { get; set; }
+ public string GithubId { get; set; }
+ public string Email { get; set; }
+ public string RefreshToken { get; set; }
+}
diff --git a/server/src/Data/Database/Tenant.cs b/server/src/Data/Database/Tenant.cs
new file mode 100644
index 0000000..3028d13
--- /dev/null
+++ b/server/src/Data/Database/Tenant.cs
@@ -0,0 +1,10 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Tenant : BaseWithOwner
+{
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string ContactEmail { get; set; }
+ public Guid MasterUserId { get; set; }
+ public string MasterUserPassword { get; set; }
+}
diff --git a/server/src/Data/Database/TimeCategory.cs b/server/src/Data/Database/TimeCategory.cs
new file mode 100644
index 0000000..69c6957
--- /dev/null
+++ b/server/src/Data/Database/TimeCategory.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeCategory : BaseWithOwner
+{
+ public TimeCategory() { }
+ public TimeCategory(Guid userId) : base(userId) { }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ public TimeCategoryDto AsDto => new(this);
+
+ public class TimeCategoryDto
+ {
+ public TimeCategoryDto() { }
+
+ public TimeCategoryDto(TimeCategory sourceEntry = default) {
+ if (sourceEntry == default) {
+ return;
+ }
+
+ Id = sourceEntry.Id;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Name = sourceEntry.Name;
+ Color = sourceEntry.Color;
+ }
+
+ public Guid Id { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/TimeEntry.cs b/server/src/Data/Database/TimeEntry.cs
new file mode 100644
index 0000000..46c62e1
--- /dev/null
+++ b/server/src/Data/Database/TimeEntry.cs
@@ -0,0 +1,45 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeEntry : BaseWithOwner
+{
+ public TimeEntry() { }
+ public TimeEntry(Guid userId) : base(userId) { }
+ public DateTime Start { get; set; }
+ public DateTime Stop { get; set; }
+ public string Description { get; set; }
+ public ICollection<TimeLabel> Labels { get; set; }
+ public TimeCategory Category { get; set; }
+ public TimeEntryDto AsDto => new(this);
+
+ public class TimeEntryDto
+ {
+ public TimeEntryDto() { }
+
+ public TimeEntryDto(TimeEntry sourceEntry = default) {
+ if (sourceEntry == default) {
+ return;
+ }
+
+ Id = sourceEntry.Id;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Stop = sourceEntry.Stop;
+ Start = sourceEntry.Start;
+ Description = sourceEntry.Description;
+ if (sourceEntry.Labels != default) {
+ Labels = sourceEntry.Labels
+ .Select(t => t.AsDto)
+ .ToList();
+ }
+
+ Category = sourceEntry.Category.AsDto;
+ }
+
+ public Guid? Id { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public DateTime Start { get; set; }
+ public DateTime Stop { get; set; }
+ public string Description { get; set; }
+ public List<TimeLabel.TimeLabelDto> Labels { get; set; }
+ public TimeCategory.TimeCategoryDto Category { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/TimeLabel.cs b/server/src/Data/Database/TimeLabel.cs
new file mode 100644
index 0000000..55e20b0
--- /dev/null
+++ b/server/src/Data/Database/TimeLabel.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeLabel : BaseWithOwner
+{
+ public TimeLabel() { }
+ public TimeLabel(Guid userId) : base(userId) { }
+ public string Name { get; set; }
+ public string Color { get; set; }
+
+ [NotMapped]
+ public TimeLabelDto AsDto => new(this);
+
+ public class TimeLabelDto
+ {
+ public TimeLabelDto() { }
+
+ public TimeLabelDto(TimeLabel sourceEntry) {
+ Id = sourceEntry.Id;
+ CreatedAt = sourceEntry.CreatedAt;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Name = sourceEntry.Name;
+ Color = sourceEntry.Color;
+ }
+
+ public Guid Id { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/User.cs b/server/src/Data/Database/User.cs
new file mode 100644
index 0000000..c5063f6
--- /dev/null
+++ b/server/src/Data/Database/User.cs
@@ -0,0 +1,29 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class User : Base
+{
+ public User() { }
+
+ public User(string username) {
+ Username = username;
+ }
+
+ public string Username { get; set; }
+ public string Password { get; set; }
+
+
+ public void HashAndSetPassword(string password) {
+ Password = PasswordHelper.HashPassword(password);
+ }
+
+ public bool VerifyPassword(string password) {
+ return PasswordHelper.Verify(password, Password);
+ }
+
+ public IEnumerable<Claim> DefaultClaims() {
+ return new Claim[] {
+ new(AppClaims.USER_ID, Id.ToString()),
+ new(AppClaims.NAME, Username),
+ };
+ }
+}
diff --git a/server/src/Data/Dtos/TimeQueryDto.cs b/server/src/Data/Dtos/TimeQueryDto.cs
new file mode 100644
index 0000000..f734cb1
--- /dev/null
+++ b/server/src/Data/Dtos/TimeQueryDto.cs
@@ -0,0 +1,34 @@
+
+namespace IOL.GreatOffice.Api.Data.Dtos;
+
+public class TimeQueryDto
+{
+ public TimeQueryDto() {
+ Results = new List<TimeEntry.TimeEntryDto>();
+ }
+
+ /// <summary>
+ /// List of entries.
+ /// </summary>
+ public List<TimeEntry.TimeEntryDto> Results { get; set; }
+
+ /// <summary>
+ /// Curren page.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Maximum count of entries in a page.
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Total count of entries.
+ /// </summary>
+ public int TotalSize { get; set; }
+
+ /// <summary>
+ /// Total count of pages.
+ /// </summary>
+ public int TotalPageCount { get; set; }
+}
diff --git a/server/src/Data/Dtos/UserArchiveDto.cs b/server/src/Data/Dtos/UserArchiveDto.cs
new file mode 100644
index 0000000..63b1470
--- /dev/null
+++ b/server/src/Data/Dtos/UserArchiveDto.cs
@@ -0,0 +1,131 @@
+
+namespace IOL.GreatOffice.Api.Data.Dtos;
+
+/// <summary>
+/// Represents a user archive as it is provided to users.
+/// </summary>
+public class UserArchiveDto
+{
+ /// <inheritdoc cref="UserArchiveDto"/>
+ public UserArchiveDto(User user) {
+ Meta = new MetaDto {
+ GeneratedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
+ };
+ User = new UserDto(user);
+ Entries = new List<EntryDto>();
+ }
+
+ /// <summary>
+ /// Metadata for the user archive.
+ /// </summary>
+ public MetaDto Meta { get; }
+
+ /// <summary>
+ /// Relevant user data for the archive.
+ /// </summary>
+ public UserDto User { get; }
+
+ /// <summary>
+ /// List of entries that the user has created.
+ /// </summary>
+ public List<EntryDto> Entries { get; }
+
+ public void CountEntries() {
+ Meta.EntryCount = Entries.Count;
+ }
+
+ /// <summary>
+ /// Represents a time entry in the data archive.
+ /// </summary>
+ public class EntryDto
+ {
+ public string CreatedAt { get; init; }
+
+ [JsonIgnore]
+ public DateTime StartDateTime { get; init; }
+
+ /// <summary>
+ /// ISO 8601 string of the UTC date the time entry started.
+ /// </summary>
+ public string Start => StartDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+
+ [JsonIgnore]
+ public DateTime StopDateTime { get; init; }
+
+ /// <summary>
+ /// ISO 8601 string of the UTC date the time entry stopped.
+ /// </summary>
+ public string Stop => StopDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+
+ /// <summary>
+ /// Total amount of minutes elapsed from start to stop on this time entry.
+ /// </summary>
+ public double Minutes => StopDateTime.Subtract(StartDateTime).TotalMinutes;
+
+ public string Description { get; init; }
+
+ /// <summary>
+ /// Archive spesific category for this time entry.
+ /// </summary>
+ public CategoryDto Category { get; init; }
+
+ /// <summary>
+ /// Archive spesific list of labels for this time entry.
+ /// </summary>
+ public List<LabelDto> Labels { get; init; }
+ }
+
+ /// <summary>
+ /// Time entry category as it is written to the user archive.
+ /// </summary>
+ public class CategoryDto
+ {
+ public string Name { get; init; }
+ public string Color { get; init; }
+ }
+
+ /// <summary>
+ /// Time entry label as it is written to the user archive.
+ /// </summary>
+ public class LabelDto
+ {
+ public string Name { get; init; }
+ public string Color { get; init; }
+ }
+
+
+ /// <summary>
+ /// Represents the user who this archive's data is based on.
+ /// </summary>
+ public class UserDto
+ {
+ /// <inheritdoc cref="UserDto"/>
+ public UserDto(User user) {
+ Username = user.Username;
+ CreatedAt = user.CreatedAt;
+ }
+
+ /// <summary>
+ /// UTC date this user was created.
+ /// </summary>
+ public DateTime CreatedAt { get; }
+
+ public string Username { get; }
+ }
+
+ /// <summary>
+ /// Represents the meta object which contains metdata for this archive.
+ /// </summary>
+ public class MetaDto
+ {
+ /// <summary>
+ /// ISO 8601 UTC date string for when this archive was created.
+ /// </summary>
+ public string GeneratedAt { get; init; }
+
+ /// <summary>
+ /// Amount of entries in the archive.
+ /// </summary>
+ public int EntryCount { get; set; }
+ }
+}
diff --git a/server/src/Data/Enums/TimeEntryQueryDuration.cs b/server/src/Data/Enums/TimeEntryQueryDuration.cs
new file mode 100644
index 0000000..af70ca6
--- /dev/null
+++ b/server/src/Data/Enums/TimeEntryQueryDuration.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Data.Enums;
+
+/// <summary>
+/// Specify a duration filter for time entry queries.
+/// </summary>
+public enum TimeEntryQueryDuration
+{
+ /// <summary>
+ /// Only query entries created today.
+ /// </summary>
+ TODAY = 0,
+
+ /// <summary>
+ /// Only query entries created this week.
+ /// </summary>
+ THIS_WEEK = 1,
+
+ /// <summary>
+ /// Only query entries created this month.
+ /// </summary>
+ THIS_MONTH = 2,
+
+ /// <summary>
+ /// Only query entries created this year.
+ /// </summary>
+ THIS_YEAR = 3,
+
+ /// <summary>
+ /// Only query entries created at a spesific date.
+ /// </summary>
+ SPECIFIC_DATE = 4,
+
+ /// <summary>
+ /// Only query entries created between two dates.
+ /// </summary>
+ DATE_RANGE = 5,
+}
diff --git a/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs
new file mode 100644
index 0000000..02474b4
--- /dev/null
+++ b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs
@@ -0,0 +1,21 @@
+namespace IOL.GreatOffice.Api.Data.Exceptions;
+
+[Serializable]
+public class ForgotPasswordRequestNotFoundException : Exception
+{
+ //
+ // For guidelines regarding the creation of new exception types, see
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp
+ // and
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp
+ //
+
+ public ForgotPasswordRequestNotFoundException() { }
+ public ForgotPasswordRequestNotFoundException(string message) : base(message) { }
+ public ForgotPasswordRequestNotFoundException(string message, Exception inner) : base(message, inner) { }
+
+ protected ForgotPasswordRequestNotFoundException(
+ SerializationInfo info,
+ StreamingContext context
+ ) : base(info, context) { }
+}
diff --git a/server/src/Data/Exceptions/UserNotFoundException.cs b/server/src/Data/Exceptions/UserNotFoundException.cs
new file mode 100644
index 0000000..06b57a9
--- /dev/null
+++ b/server/src/Data/Exceptions/UserNotFoundException.cs
@@ -0,0 +1,19 @@
+namespace IOL.GreatOffice.Api.Data.Exceptions;
+
+[Serializable]
+public class UserNotFoundException : Exception
+{
+ // For guidelines regarding the creation of new exception types, see
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp
+ // and
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp
+
+ public UserNotFoundException() { }
+ public UserNotFoundException(string message) : base(message) { }
+ public UserNotFoundException(string message, Exception inner) : base(message, inner) { }
+
+ protected UserNotFoundException(
+ SerializationInfo info,
+ StreamingContext context
+ ) : base(info, context) { }
+}
diff --git a/server/src/Data/Models/ApiSpecDocument.cs b/server/src/Data/Models/ApiSpecDocument.cs
new file mode 100644
index 0000000..1c7d936
--- /dev/null
+++ b/server/src/Data/Models/ApiSpecDocument.cs
@@ -0,0 +1,9 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public class ApiSpecDocument
+{
+ public string VersionName { get; set; }
+ public string SwaggerPath { get; set; }
+ public ApiVersion Version { get; set; }
+ public OpenApiInfo OpenApiInfo { get; set; }
+}
diff --git a/server/src/Data/Models/AppPath.cs b/server/src/Data/Models/AppPath.cs
new file mode 100644
index 0000000..e47e48c
--- /dev/null
+++ b/server/src/Data/Models/AppPath.cs
@@ -0,0 +1,23 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public sealed record AppPath
+{
+ public string HostPath { get; init; }
+ public string WebPath { get; init; }
+
+ public string GetHostPathForFilename(string filename, string fallback = "") {
+ if (filename.IsNullOrWhiteSpace()) {
+ return fallback;
+ }
+
+ return Path.Combine(HostPath, filename);
+ }
+
+ public string GetWebPathForFilename(string filename, string fallback = "") {
+ if (filename.IsNullOrWhiteSpace()) {
+ return fallback;
+ }
+
+ return Path.Combine(WebPath, filename);
+ }
+}
diff --git a/server/src/Data/Models/LoggedInUserModel.cs b/server/src/Data/Models/LoggedInUserModel.cs
new file mode 100644
index 0000000..4a5bef9
--- /dev/null
+++ b/server/src/Data/Models/LoggedInUserModel.cs
@@ -0,0 +1,8 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public class LoggedInUserModel
+{
+ public LoggedInUserModel() { }
+ public Guid Id { get; set; }
+ public string Username { get; set; }
+}
diff --git a/server/src/Data/Results/ErrorResult.cs b/server/src/Data/Results/ErrorResult.cs
new file mode 100644
index 0000000..fd2fd6a
--- /dev/null
+++ b/server/src/Data/Results/ErrorResult.cs
@@ -0,0 +1,12 @@
+namespace IOL.GreatOffice.Api.Data.Results;
+
+public class ErrorResult
+{
+ public ErrorResult(string title = default, string text = default) {
+ Title = title;
+ Text = text;
+ }
+
+ public string Title { get; set; }
+ public string Text { get; set; }
+}
diff --git a/server/src/Data/Static/AppClaims.cs b/server/src/Data/Static/AppClaims.cs
new file mode 100644
index 0000000..8b6d3a8
--- /dev/null
+++ b/server/src/Data/Static/AppClaims.cs
@@ -0,0 +1,8 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppClaims
+{
+ public const string USER_ID = "user_id";
+ public const string NAME = "name";
+ public const string GITHUB_ACCESS_TOKEN = "";
+}
diff --git a/server/src/Data/Static/AppConstants.cs b/server/src/Data/Static/AppConstants.cs
new file mode 100644
index 0000000..61e5cd5
--- /dev/null
+++ b/server/src/Data/Static/AppConstants.cs
@@ -0,0 +1,11 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppConstants
+{
+ public const string API_NAME = "Great Office API";
+ public const string BASIC_AUTH_SCHEME = "BasicAuthenticationScheme";
+ public const string TOKEN_ALLOW_READ = "TOKEN_ALLOW_READ";
+ public const string TOKEN_ALLOW_CREATE = "TOKEN_ALLOW_CREATE";
+ public const string TOKEN_ALLOW_UPDATE = "TOKEN_ALLOW_UPDATE";
+ public const string TOKEN_ALLOW_DELETE = "TOKEN_ALLOW_DELETE";
+}
diff --git a/server/src/Data/Static/AppEnvironmentVariables.cs b/server/src/Data/Static/AppEnvironmentVariables.cs
new file mode 100644
index 0000000..a734146
--- /dev/null
+++ b/server/src/Data/Static/AppEnvironmentVariables.cs
@@ -0,0 +1,27 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppEnvironmentVariables
+{
+ public const string DB_HOST = "DB_HOST";
+ public const string DB_PORT = "DB_PORT";
+ public const string DB_USER = "DB_USER";
+ public const string DB_PASSWORD = "DB_PASSWORD";
+ public const string DB_NAME = "DB_NAME";
+ public const string QUARTZ_DB_HOST = "QUARTZ_DB_HOST";
+ public const string QUARTZ_DB_PORT = "QUARTZ_DB_PORT";
+ public const string QUARTZ_DB_USER = "QUARTZ_DB_USER";
+ public const string QUARTZ_DB_PASSWORD = "QUARTZ_DB_PASSWORD";
+ public const string QUARTZ_DB_NAME = "QUARTZ_DB_NAME";
+ public const string SEQ_API_KEY = "SEQ_API_KEY";
+ public const string SEQ_API_URL = "SEQ_API_URL";
+ public const string SMTP_HOST = "SMTP_HOST";
+ public const string SMTP_PORT = "SMTP_PORT";
+ public const string SMTP_USER = "SMTP_USER";
+ public const string SMTP_PASSWORD = "SMTP_PASSWORD";
+ public const string EMAIL_FROM_ADDRESS = "EMAIL_FROM_ADDRESS";
+ public const string EMAIL_FROM_DISPLAY_NAME = "EMAIL_FROM_DISPLAY_NAME";
+ public const string ACCOUNTS_URL = "ACCOUNTS_URL";
+ public const string GITHUB_CLIENT_ID = "GH_CLIENT_ID";
+ public const string GITHUB_CLIENT_SECRET = "GH_CLIENT_SECRET";
+ public const string APP_AES_KEY = "APP_AES_KEY";
+}
diff --git a/server/src/Data/Static/AppHeaders.cs b/server/src/Data/Static/AppHeaders.cs
new file mode 100644
index 0000000..41a3085
--- /dev/null
+++ b/server/src/Data/Static/AppHeaders.cs
@@ -0,0 +1,6 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppHeaders
+{
+ public const string BROWSER_TIME_ZONE = "X-TimeZone";
+}
diff --git a/server/src/Data/Static/AppPaths.cs b/server/src/Data/Static/AppPaths.cs
new file mode 100644
index 0000000..a24f5af
--- /dev/null
+++ b/server/src/Data/Static/AppPaths.cs
@@ -0,0 +1,17 @@
+
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppPaths
+{
+ public static AppPath AppData => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData")
+ };
+
+ public static AppPath DataProtectionKeys => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData", "dp-keys")
+ };
+
+ public static AppPath Frontend => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "Frontend")
+ };
+}
diff --git a/server/src/Data/Static/JsonSettings.cs b/server/src/Data/Static/JsonSettings.cs
new file mode 100644
index 0000000..a163c11
--- /dev/null
+++ b/server/src/Data/Static/JsonSettings.cs
@@ -0,0 +1,11 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class JsonSettings
+{
+ public static Action<JsonOptions> Default { get; } = options => {
+ options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
+ options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
+ options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ };
+}
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();
+ }
+ }
+}
diff --git a/server/src/IOL.GreatOffice.Api.csproj b/server/src/IOL.GreatOffice.Api.csproj
new file mode 100644
index 0000000..0bd2c48
--- /dev/null
+++ b/server/src/IOL.GreatOffice.Api.csproj
@@ -0,0 +1,48 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
+ <UserSecretsId>ed5ff3e5-46e2-4d7e-8272-7081f5abfee4</UserSecretsId>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <ImplicitUsings>true</ImplicitUsings>
+ <Nullable>disable</Nullable>
+ <NoWarn>CS1591</NoWarn>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" />
+ <PackageReference Include="Duende.IdentityServer" Version="6.1.0" />
+ <PackageReference Include="Duende.IdentityServer.EntityFramework.Storage" Version="6.1.0" />
+ <PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
+ <PackageReference Include="IOL.Helpers" Version="3.0.0" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" />
+ <PackageReference Include="Quartz.Extensions.Hosting" Version="3.4.0" />
+ <PackageReference Include="Serilog.AspNetCore" Version="5.0.0" />
+ <PackageReference Include="Serilog.Expressions" Version="3.4.0" />
+ <PackageReference Include="Serilog.Sinks.Seq" Version="5.1.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.3.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.3.1" />
+ </ItemGroup>
+
+ <ItemGroup Condition="'$(Configuration)' == 'Release'">
+ <Content Remove="AppData" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Content Include="..\..\README.md">
+ <Link>README.md</Link>
+ </Content>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="wwwroot" />
+ </ItemGroup>
+</Project>
diff --git a/server/src/Jobs/JobRegister.cs b/server/src/Jobs/JobRegister.cs
new file mode 100644
index 0000000..72c2cc7
--- /dev/null
+++ b/server/src/Jobs/JobRegister.cs
@@ -0,0 +1,18 @@
+using Quartz;
+
+namespace IOL.GreatOffice.Api.Jobs;
+
+public static class JobRegister
+{
+ public static readonly JobKey TokenCleanupKey = new("TokenCleanupJob");
+
+ public static IServiceCollectionQuartzConfigurator RegisterJobs(this IServiceCollectionQuartzConfigurator configurator) {
+ configurator.AddJob<TokenCleanupJob>(TokenCleanupKey);
+ configurator.AddTrigger(options => {
+ options.ForJob(TokenCleanupKey)
+ .WithIdentity(TokenCleanupKey.Name + "-trigger")
+ .WithCronSchedule(CronScheduleBuilder.DailyAtHourAndMinute(1, 0));
+ });
+ return configurator;
+ }
+}
diff --git a/server/src/Jobs/TokenCleanupJob.cs b/server/src/Jobs/TokenCleanupJob.cs
new file mode 100644
index 0000000..22b60d1
--- /dev/null
+++ b/server/src/Jobs/TokenCleanupJob.cs
@@ -0,0 +1,21 @@
+using Quartz;
+
+namespace IOL.GreatOffice.Api.Jobs;
+
+public class TokenCleanupJob : IJob
+{
+ private readonly ILogger<TokenCleanupJob> _logger;
+ private readonly AppDbContext _context;
+
+ public TokenCleanupJob(ILogger<TokenCleanupJob> logger, AppDbContext context) {
+ _logger = logger;
+ _context = context;
+ }
+
+ public Task Execute(IJobExecutionContext context) {
+ var staleTokens = _context.AccessTokens.Where(c => c.HasExpired);
+ _logger.LogInformation("Removing {0} stale tokens", staleTokens.Count());
+ _context.AccessTokens.RemoveRange();
+ return Task.CompletedTask;
+ }
+}
diff --git a/server/src/Migrations/20210517202115_InitialMigration.Designer.cs b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs
new file mode 100644
index 0000000..b6a01ff
--- /dev/null
+++ b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs
@@ -0,0 +1,238 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20210517202115_InitialMigration")]
+ partial class InitialMigration
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Relational:MaxIdentifierLength", 63)
+ .HasAnnotation("ProductVersion", "5.0.6")
+ .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("note");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"),
+ Created = new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868),
+ Password = "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==",
+ Username = "admin@ivarlovlie.no"
+ });
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20210517202115_InitialMigration.cs b/server/src/Migrations/20210517202115_InitialMigration.cs
new file mode 100644
index 0000000..8bfaf61
--- /dev/null
+++ b/server/src/Migrations/20210517202115_InitialMigration.cs
@@ -0,0 +1,162 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class InitialMigration : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "time_categories",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ color = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_categories", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_labels",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ color = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_labels", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ username = table.Column<string>(type: "text", nullable: true),
+ password = table.Column<string>(type: "text", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_entries",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ start = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ stop = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ note = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ category_id = table.Column<Guid>(type: "uuid", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entries", x => x.id);
+ table.ForeignKey(
+ name: "fk_time_entries_time_categories_category_id",
+ column: x => x.category_id,
+ principalTable: "time_categories",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "forgot_password_requests",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_forgot_password_requests", x => x.id);
+ table.ForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_entry_time_label",
+ columns: table => new
+ {
+ entries_id = table.Column<Guid>(type: "uuid", nullable: false),
+ labels_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id });
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_entries_entries_id",
+ column: x => x.entries_id,
+ principalTable: "time_entries",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_labels_labels_id",
+ column: x => x.labels_id,
+ principalTable: "time_labels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.InsertData(
+ table: "users",
+ columns: new[] { "id", "created", "password", "username" },
+ values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_forgot_password_requests_user_id",
+ table: "forgot_password_requests",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_category_id",
+ table: "time_entries",
+ column: "category_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entry_time_label_labels_id",
+ table: "time_entry_time_label",
+ column: "labels_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "forgot_password_requests");
+
+ migrationBuilder.DropTable(
+ name: "time_entry_time_label");
+
+ migrationBuilder.DropTable(
+ name: "users");
+
+ migrationBuilder.DropTable(
+ name: "time_entries");
+
+ migrationBuilder.DropTable(
+ name: "time_labels");
+
+ migrationBuilder.DropTable(
+ name: "time_categories");
+ }
+ }
+}
diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs
new file mode 100644
index 0000000..368e6b3
--- /dev/null
+++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs
@@ -0,0 +1,229 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20210522165932_RenameNoteToDescription")]
+ partial class RenameNoteToDescription
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Relational:MaxIdentifierLength", 63)
+ .HasAnnotation("ProductVersion", "5.0.6")
+ .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs
new file mode 100644
index 0000000..e5bae54
--- /dev/null
+++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs
@@ -0,0 +1,34 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RenameNoteToDescription : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DeleteData(
+ table: "users",
+ keyColumn: "id",
+ keyValue: new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"));
+
+ migrationBuilder.RenameColumn(
+ name: "note",
+ table: "time_entries",
+ newName: "description");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "description",
+ table: "time_entries",
+ newName: "note");
+
+ migrationBuilder.InsertData(
+ table: "users",
+ columns: new[] { "id", "created", "password", "username" },
+ values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" });
+ }
+ }
+}
diff --git a/server/src/Migrations/20211002113037_V6Migration.Designer.cs b/server/src/Migrations/20211002113037_V6Migration.Designer.cs
new file mode 100644
index 0000000..59e6112
--- /dev/null
+++ b/server/src/Migrations/20211002113037_V6Migration.Designer.cs
@@ -0,0 +1,233 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20211002113037_V6Migration")]
+ partial class V6Migration
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.0-rc.1.21452.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20211002113037_V6Migration.cs b/server/src/Migrations/20211002113037_V6Migration.cs
new file mode 100644
index 0000000..c7ac971
--- /dev/null
+++ b/server/src/Migrations/20211002113037_V6Migration.cs
@@ -0,0 +1,130 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class V6Migration : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("SET TimeZone='UTC'");
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_labels",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "stop",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "start",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_categories",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("SET TimeZone='UTC'");
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_labels",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "stop",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "start",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_categories",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "forgot_password_requests",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs
new file mode 100644
index 0000000..2b95f9d
--- /dev/null
+++ b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs
@@ -0,0 +1,270 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220225143559_GithubUserMappings")]
+ partial class GithubUserMappings
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.cs b/server/src/Migrations/20220225143559_GithubUserMappings.cs
new file mode 100644
index 0000000..fc30c7a
--- /dev/null
+++ b/server/src/Migrations/20220225143559_GithubUserMappings.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class GithubUserMappings : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "github_user_mappings",
+ columns: table => new
+ {
+ github_id = table.Column<string>(type: "text", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ email = table.Column<string>(type: "text", nullable: true),
+ refresh_token = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_github_user_mappings", x => x.github_id);
+ table.ForeignKey(
+ name: "fk_github_user_mappings_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_github_user_mappings_user_id",
+ table: "github_user_mappings",
+ column: "user_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "github_user_mappings");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319135910_RenameCreated.Designer.cs b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs
new file mode 100644
index 0000000..3d57f1a
--- /dev/null
+++ b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs
@@ -0,0 +1,270 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319135910_RenameCreated")]
+ partial class RenameCreated
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319135910_RenameCreated.cs b/server/src/Migrations/20220319135910_RenameCreated.cs
new file mode 100644
index 0000000..6571e50
--- /dev/null
+++ b/server/src/Migrations/20220319135910_RenameCreated.cs
@@ -0,0 +1,65 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RenameCreated : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "users",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_labels",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_entries",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_categories",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "forgot_password_requests",
+ newName: "created_at");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "users",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_labels",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_entries",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_categories",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "forgot_password_requests",
+ newName: "created");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs
new file mode 100644
index 0000000..f75400e
--- /dev/null
+++ b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs
@@ -0,0 +1,290 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319144958_ModifiedAt")]
+ partial class ModifiedAt
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319144958_ModifiedAt.cs b/server/src/Migrations/20220319144958_ModifiedAt.cs
new file mode 100644
index 0000000..028473d
--- /dev/null
+++ b/server/src/Migrations/20220319144958_ModifiedAt.cs
@@ -0,0 +1,66 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class ModifiedAt : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_labels",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_categories",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "forgot_password_requests");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319203018_UserBase.Designer.cs b/server/src/Migrations/20220319203018_UserBase.Designer.cs
new file mode 100644
index 0000000..6c7a76f
--- /dev/null
+++ b/server/src/Migrations/20220319203018_UserBase.Designer.cs
@@ -0,0 +1,322 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319203018_UserBase")]
+ partial class UserBase
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.Property<string>("password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319203018_UserBase.cs b/server/src/Migrations/20220319203018_UserBase.cs
new file mode 100644
index 0000000..14d3f4b
--- /dev/null
+++ b/server/src/Migrations/20220319203018_UserBase.cs
@@ -0,0 +1,140 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class UserBase : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_user_id",
+ table: "time_labels",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_user_id",
+ table: "time_entries",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_user_id",
+ table: "time_categories",
+ column: "user_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_user_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_user_id",
+ table: "time_categories");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320115601_Update1.Designer.cs b/server/src/Migrations/20220320115601_Update1.Designer.cs
new file mode 100644
index 0000000..c7463fb
--- /dev/null
+++ b/server/src/Migrations/20220320115601_Update1.Designer.cs
@@ -0,0 +1,342 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220320115601_Update1")]
+ partial class Update1
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.Property<string>("password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320115601_Update1.cs b/server/src/Migrations/20220320115601_Update1.cs
new file mode 100644
index 0000000..8b06fb7
--- /dev/null
+++ b/server/src/Migrations/20220320115601_Update1.cs
@@ -0,0 +1,139 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class Update1 : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs
new file mode 100644
index 0000000..3a18463
--- /dev/null
+++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs
@@ -0,0 +1,344 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220320132220_UpdatedForgotPasswordRequests")]
+ partial class UpdatedForgotPasswordRequests
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs
new file mode 100644
index 0000000..df7a195
--- /dev/null
+++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs
@@ -0,0 +1,57 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class UpdatedForgotPasswordRequests : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "forgot_password_requests",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "forgot_password_requests",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs
new file mode 100644
index 0000000..74f9b40
--- /dev/null
+++ b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs
@@ -0,0 +1,401 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220529190359_ApiAccessTokens")]
+ partial class ApiAccessTokens
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.cs
new file mode 100644
index 0000000..dc44bee
--- /dev/null
+++ b/server/src/Migrations/20220529190359_ApiAccessTokens.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class ApiAccessTokens : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "api_access_tokens",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ expiry_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ allow_read = table.Column<bool>(type: "boolean", nullable: false),
+ allow_create = table.Column<bool>(type: "boolean", nullable: false),
+ allow_update = table.Column<bool>(type: "boolean", nullable: false),
+ allow_delete = table.Column<bool>(type: "boolean", nullable: false),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_api_access_tokens", x => x.id);
+ table.ForeignKey(
+ name: "fk_api_access_tokens_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_api_access_tokens_user_id",
+ table: "api_access_tokens",
+ column: "user_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "api_access_tokens");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530174741_Tenants.Designer.cs b/server/src/Migrations/20220530174741_Tenants.Designer.cs
new file mode 100644
index 0000000..678c52d
--- /dev/null
+++ b/server/src/Migrations/20220530174741_Tenants.Designer.cs
@@ -0,0 +1,710 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220530174741_Tenants")]
+ partial class Tenants
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530174741_Tenants.cs b/server/src/Migrations/20220530174741_Tenants.cs
new file mode 100644
index 0000000..ea02ddd
--- /dev/null
+++ b/server/src/Migrations/20220530174741_Tenants.cs
@@ -0,0 +1,481 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class Tenants : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateTable(
+ name: "tenants",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ description = table.Column<string>(type: "text", nullable: true),
+ contact_email = table.Column<string>(type: "text", nullable: true),
+ master_user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ master_user_password = table.Column<string>(type: "text", nullable: true),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
+ tenant_id1 = table.Column<Guid>(type: "uuid", nullable: true),
+ modified_by_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created_by_id = table.Column<Guid>(type: "uuid", nullable: false),
+ deleted_by_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_tenants", x => x.id);
+ table.ForeignKey(
+ name: "fk_tenants_tenants_tenant_id1",
+ column: x => x.tenant_id1,
+ principalTable: "tenants",
+ principalColumn: "id");
+ table.ForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ column: x => x.created_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ column: x => x.deleted_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ column: x => x.modified_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_created_by_id",
+ table: "time_labels",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_tenant_id",
+ table: "time_labels",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_created_by_id",
+ table: "time_entries",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_tenant_id",
+ table: "time_entries",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_created_by_id",
+ table: "time_categories",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_tenant_id",
+ table: "time_categories",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_created_by_id",
+ table: "tenants",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_deleted_by_id",
+ table: "tenants",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_modified_by_id",
+ table: "tenants",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_tenant_id1",
+ table: "tenants",
+ column: "tenant_id1");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_user_id",
+ table: "tenants",
+ column: "user_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_tenants_tenant_id",
+ table: "time_entries",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_tenants_tenant_id",
+ table: "time_labels",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_tenants_tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_tenants_tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropTable(
+ name: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_tenant_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_categories");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs
new file mode 100644
index 0000000..8fd6b40
--- /dev/null
+++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs
@@ -0,0 +1,686 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220530175322_RemoveUnusedNavs")]
+ partial class RemoveUnusedNavs
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TimeEntryId")
+ .HasColumnType("uuid")
+ .HasColumnName("time_entry_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs
new file mode 100644
index 0000000..36b3cf1
--- /dev/null
+++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs
@@ -0,0 +1,78 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RemoveUnusedNavs : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "time_entry_time_label");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "time_entry_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_time_entry_id",
+ table: "time_labels",
+ column: "time_entry_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_time_entries_time_entry_id",
+ table: "time_labels",
+ column: "time_entry_id",
+ principalTable: "time_entries",
+ principalColumn: "id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_time_entries_time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.CreateTable(
+ name: "time_entry_time_label",
+ columns: table => new
+ {
+ entries_id = table.Column<Guid>(type: "uuid", nullable: false),
+ labels_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id });
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_entries_entries_id",
+ column: x => x.entries_id,
+ principalTable: "time_entries",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_labels_labels_id",
+ column: x => x.labels_id,
+ principalTable: "time_labels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entry_time_label_labels_id",
+ table: "time_entry_time_label",
+ column: "labels_id");
+ }
+ }
+}
diff --git a/server/src/Migrations/AppDbContextModelSnapshot.cs b/server/src/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..e040cfb
--- /dev/null
+++ b/server/src/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,684 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TimeEntryId")
+ .HasColumnType("uuid")
+ .HasColumnName("time_entry_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Program.cs b/server/src/Program.cs
new file mode 100644
index 0000000..b449117
--- /dev/null
+++ b/server/src/Program.cs
@@ -0,0 +1,261 @@
+global using System;
+global using System.Linq;
+global using System.IO;
+global using System.Net.Mail;
+global using System.Net;
+global using System.Threading;
+global using System.Threading.Tasks;
+global using System.Collections.Generic;
+global using System.Runtime.Serialization;
+global using System.ComponentModel.DataAnnotations.Schema;
+global using System.Security.Claims;
+global using System.Text.Json;
+global using System.Text.Json.Serialization;
+global using IOL.GreatOffice.Api.Data.Database;
+global using IOL.GreatOffice.Api.Data.Exceptions;
+global using IOL.GreatOffice.Api.Data.Dtos;
+global using IOL.GreatOffice.Api.Data.Enums;
+global using IOL.GreatOffice.Api.Data.Models;
+global using IOL.GreatOffice.Api.Data.Results;
+global using IOL.Helpers;
+global using Microsoft.OpenApi.Models;
+global using Microsoft.AspNetCore.Authentication.Cookies;
+global using Microsoft.AspNetCore.Builder;
+global using Microsoft.AspNetCore.DataProtection;
+global using Microsoft.AspNetCore.Hosting;
+global using Microsoft.AspNetCore.Authorization;
+global using Microsoft.AspNetCore.Http;
+global using Microsoft.AspNetCore.Authentication;
+global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.EntityFrameworkCore;
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Hosting;
+global using Microsoft.Extensions.Logging;
+global using Serilog;
+global using IOL.GreatOffice.Api.Data;
+global using IOL.GreatOffice.Api.Data.Static;
+global using IOL.GreatOffice.Api.Services;
+global using IOL.GreatOffice.Api.Utilities;
+using System.Diagnostics;
+using System.Reflection;
+using IOL.GreatOffice.Api.Endpoints.V1;
+using IOL.GreatOffice.Api.Jobs;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Mvc.Versioning;
+using Quartz;
+
+namespace IOL.GreatOffice.Api;
+
+public static class Program
+{
+ public static WebApplicationBuilder CreateAppBuilder(string[] args) {
+ var builder = WebApplication.CreateBuilder(args);
+
+ var seqUrl = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_URL);
+ var seqKey = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_KEY);
+ var logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .ReadFrom.Configuration(builder.Configuration)
+ .WriteTo.Console();
+
+ if (!builder.Environment.IsDevelopment() && seqUrl.HasValue() && seqKey.HasValue()) {
+ logger.WriteTo.Seq(seqUrl, apiKey: seqKey);
+ }
+
+ Log.Logger = logger.CreateLogger();
+ Log.Information("Starting web host"
+ + JsonSerializer.Serialize(new {
+ DateTime.UtcNow,
+ PID = Environment.ProcessId,
+ DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST),
+ DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT),
+ DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_USER),
+ DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME),
+ DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD).Obfuscate() ?? "!!!Empty!!!",
+ QUARTZ_DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST),
+ QUARTZ_DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT),
+ QUARTZ_DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER),
+ QUARTZ_DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME),
+ QUARTZ_DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD).Obfuscate()
+ ?? "!!!Empty!!!",
+ },
+ new JsonSerializerOptions() {
+ WriteIndented = true
+ }));
+
+ builder.Host.UseSerilog(Log.Logger);
+ builder.WebHost.ConfigureKestrel(kestrel => {
+ kestrel.AddServerHeader = false;
+ });
+
+ if (builder.Environment.IsDevelopment()) {
+ builder.Services.AddCors();
+ }
+
+ if (builder.Environment.IsProduction()) {
+ builder.Services.Configure<ForwardedHeadersOptions>(options => {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
+ });
+ }
+
+ builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(AppPaths.DataProtectionKeys.HostPath));
+
+ builder.Services.Configure(JsonSettings.Default);
+ builder.Services.AddQuartz(options => {
+ options.UsePersistentStore(o => {
+ o.UsePostgres(builder.Configuration.GetQuartzDatabaseConnectionString());
+ o.UseSerializer<QuartzJsonSerializer>();
+ });
+ options.UseMicrosoftDependencyInjectionJobFactory();
+ options.RegisterJobs();
+ });
+ builder.Services.AddQuartzHostedService(options => {
+ options.WaitForJobsToComplete = true;
+ });
+
+ builder.Services.AddAuthentication(options => {
+ options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ })
+ .AddCookie(options => {
+ options.Cookie.Name = "go_session";
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
+ options.Cookie.IsEssential = true;
+ options.SlidingExpiration = true;
+ options.Events.OnRedirectToAccessDenied =
+ options.Events.OnRedirectToLogin = c => {
+ c.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.FromResult<object>(null);
+ };
+ })
+ .AddGitHub(options => {
+ options.ClientSecret = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_SECRET);
+ options.ClientId = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_ID);
+ options.SaveTokens = true;
+ options.CorrelationCookie.Name = "gh_correlation";
+ options.CorrelationCookie.SameSite = SameSiteMode.Lax;
+ options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
+ options.CorrelationCookie.HttpOnly = true;
+ options.Events.OnCreatingTicket = context => GithubAuthenticationHelpers.HandleGithubTicketCreation(context, builder.Configuration);
+ })
+ .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AppConstants.BASIC_AUTH_SCHEME, default);
+
+
+ builder.Services.AddDbContext<AppDbContext>(options => {
+ options.UseNpgsql(builder.Configuration.GetAppDatabaseConnectionString(),
+ npgsqlDbContextOptionsBuilder => {
+ npgsqlDbContextOptionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
+ npgsqlDbContextOptionsBuilder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), default);
+ })
+ .UseSnakeCaseNamingConvention();
+ if (builder.Environment.IsDevelopment()) {
+ options.EnableSensitiveDataLogging();
+ }
+ });
+
+ builder.Services.AddApiVersioning(options => {
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ options.ReportApiVersions = true;
+ options.AssumeDefaultVersionWhenUnspecified = false;
+ });
+ builder.Services.AddVersionedApiExplorer(options => {
+ options.SubstituteApiVersionInUrl = true;
+ });
+ builder.Services.AddSwaggerGen(options => {
+ options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, Assembly.GetExecutingAssembly().GetName().Name + ".xml"));
+ options.UseApiEndpoints();
+ options.OperationFilter<SwaggerDefaultValues>();
+ options.SwaggerDoc(ApiSpecV1.Document.VersionName, ApiSpecV1.Document.OpenApiInfo);
+ options.AddSecurityDefinition("Basic",
+ new OpenApiSecurityScheme {
+ Name = "Authorization",
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "Basic",
+ BearerFormat = "Basic",
+ In = ParameterLocation.Header,
+ Description =
+ "Enter your token in the text input below.\r\n\r\nExample: \"Basic 12345abcdef\"",
+ });
+
+ options.AddSecurityRequirement(new OpenApiSecurityRequirement {
+ {
+ new OpenApiSecurityScheme {
+ Reference = new OpenApiReference {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Basic"
+ }
+ },
+ Array.Empty<string>()
+ }
+ });
+ });
+
+ builder.Services.AddScoped<MailService>();
+ builder.Services.AddScoped<ForgotPasswordService>();
+ builder.Services.AddScoped<UserService>();
+ builder.Services.AddLogging();
+ builder.Services.AddHttpClient();
+ builder.Services
+ .AddControllers()
+ .AddJsonOptions(JsonSettings.Default);
+
+
+ return builder;
+ }
+
+ public static WebApplication CreateWebApplication(WebApplicationBuilder builder) {
+ var app = builder.Build();
+
+ if (app.Environment.IsDevelopment()) {
+ app.UseDeveloperExceptionPage();
+ app.UseCors(cors => {
+ cors.AllowAnyMethod();
+ cors.AllowAnyHeader();
+ cors.WithOrigins("http://localhost:3000", "http://localhost:3002", "http://localhost:3001");
+ cors.AllowCredentials();
+ });
+ }
+
+ if (app.Environment.IsProduction()) {
+ app.UseForwardedHeaders();
+ }
+
+ app.UseDefaultFiles()
+ .UseStaticFiles()
+ .UseRouting()
+ .UseSerilogRequestLogging()
+ .UseStatusCodePages()
+ .UseAuthentication()
+ .UseAuthorization()
+ .UseSwagger()
+ .UseSwaggerUI(options => {
+ options.SwaggerEndpoint(ApiSpecV1.Document.SwaggerPath, ApiSpecV1.Document.VersionName);
+ options.DocumentTitle = AppConstants.API_NAME;
+ })
+ .UseEndpoints(endpoints => {
+ endpoints.MapControllers();
+ });
+ return app;
+ }
+
+ public static int Main(string[] args) {
+ try {
+ CreateWebApplication(CreateAppBuilder(args)).Run();
+ return 0;
+ } catch (Exception ex) {
+ // This is subject to change in future .net versions, see https://github.com/dotnet/runtime/issues/60600.
+ if (ex.GetType().Name.Equals("StopTheHostException", StringComparison.Ordinal)) {
+ throw;
+ }
+
+ Log.Fatal(ex, "Unhandled exception");
+ return 1;
+ } finally {
+ Log.Information("Shut down complete, flusing logs...");
+ Log.CloseAndFlush();
+ }
+ }
+}
diff --git a/server/src/Properties/launchSettings.json b/server/src/Properties/launchSettings.json
new file mode 100644
index 0000000..6403d71
--- /dev/null
+++ b/server/src/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "IOL.GreatOffice": {
+ "commandName": "Project",
+ "dotnetRunMessages": "true",
+ "launchBrowser": false,
+ "applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/server/src/Services/ForgotPasswordService.cs b/server/src/Services/ForgotPasswordService.cs
new file mode 100644
index 0000000..de38b29
--- /dev/null
+++ b/server/src/Services/ForgotPasswordService.cs
@@ -0,0 +1,115 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class ForgotPasswordService
+{
+ private readonly AppDbContext _context;
+ private readonly MailService _mailService;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<ForgotPasswordService> _logger;
+
+
+ public ForgotPasswordService(
+ AppDbContext context,
+ IConfiguration configuration,
+ ILogger<ForgotPasswordService> logger,
+ MailService mailService
+ ) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger;
+ _mailService = mailService;
+ }
+
+ public async Task<ForgotPasswordRequest> GetRequestAsync(Guid id, CancellationToken cancellationToken = default) {
+ var request = await _context.ForgotPasswordRequests
+ .Include(c => c.User)
+ .SingleOrDefaultAsync(c => c.Id == id, cancellationToken);
+ if (request == default) {
+ return default;
+ }
+
+ _logger.LogInformation($"Found forgot password request for user: {request.User.Username}, expires at {request.ExpirationDate} (in {request.ExpirationDate.Subtract(DateTime.UtcNow).Minutes} minutes).");
+ return request;
+ }
+
+ public async Task<bool> FullFillRequestAsync(Guid id, string newPassword, CancellationToken cancellationToken = default) {
+ var request = await GetRequestAsync(id, cancellationToken);
+ if (request == default) {
+ throw new ForgotPasswordRequestNotFoundException("Request with id: " + id + " was not found");
+ }
+
+ var user = _context.Users.SingleOrDefault(c => c.Id == request.User.Id);
+ if (user == default) {
+ throw new UserNotFoundException("User with id: " + request.User.Id + " was not found");
+ }
+
+ user.HashAndSetPassword(newPassword);
+ _context.Users.Update(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Fullfilled forgot password request for user: {request.User.Username}");
+ await DeleteRequestsForUserAsync(user.Id, cancellationToken);
+ return true;
+ }
+
+
+ public async Task AddRequestAsync(User user, TimeZoneInfo requestTz, CancellationToken cancellationToken = default) {
+ await DeleteRequestsForUserAsync(user.Id, cancellationToken);
+ var request = new ForgotPasswordRequest(user);
+ _context.ForgotPasswordRequests.Add(request);
+ await _context.SaveChangesAsync(cancellationToken);
+ var accountsUrl = _configuration.GetValue<string>(AppEnvironmentVariables.ACCOUNTS_URL);
+ var emailFromAddress = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_ADDRESS);
+ var emailFromDisplayName = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_DISPLAY_NAME);
+ var zonedExpirationDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(request.ExpirationDate, requestTz.Id);
+ var message = new MailMessage {
+ From = new MailAddress(emailFromAddress, emailFromDisplayName),
+ To = {
+ new MailAddress(user.Username)
+ },
+ Subject = "Time Tracker - Forgot password request",
+ Body = @$"
+Hi {user.Username}
+
+Go to the following link to set a new password.
+
+{accountsUrl}/#/reset-password?id={request.Id}
+
+The link expires at {zonedExpirationDate:yyyy-MM-dd hh:mm}.
+If you did not request a password reset, no action is required.
+"
+ };
+
+#pragma warning disable 4014
+ Task.Run(() => {
+#pragma warning restore 4014
+ _mailService.SendMail(message);
+ _logger.LogInformation($"Added forgot password request for user: {request.User.Username}, expires in {request.ExpirationDate.Subtract(DateTime.UtcNow)}.");
+ },
+ cancellationToken);
+ }
+
+ public async Task DeleteRequestsForUserAsync(Guid userId, CancellationToken cancellationToken = default) {
+ var requestsToRemove = _context.ForgotPasswordRequests.Where(c => c.UserId == userId).ToList();
+ if (!requestsToRemove.Any()) return;
+ _context.ForgotPasswordRequests.RemoveRange(requestsToRemove);
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Deleted {requestsToRemove.Count} forgot password requests for user: {userId}.");
+ }
+
+
+ public async Task DeleteStaleRequestsAsync(CancellationToken cancellationToken = default) {
+ var deleteCount = 0;
+ foreach (var request in _context.ForgotPasswordRequests) {
+ if (!request.IsExpired) {
+ continue;
+ }
+
+ _context.ForgotPasswordRequests.Remove(request);
+ deleteCount++;
+ _logger.LogInformation($"Marking forgot password request with id: {request.Id} for deletion, expiration date was {request.ExpirationDate}.");
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Deleted {deleteCount} stale forgot password requests.");
+ }
+}
diff --git a/server/src/Services/MailService.cs b/server/src/Services/MailService.cs
new file mode 100644
index 0000000..b271de4
--- /dev/null
+++ b/server/src/Services/MailService.cs
@@ -0,0 +1,52 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class MailService
+{
+ private readonly ILogger<MailService> _logger;
+ private static string _emailHost;
+ private static int _emailPort;
+ private static string _emailUser;
+ private static string _emailPassword;
+
+ /// <summary>
+ /// Provides methods to send email.
+ /// </summary>
+ /// <param name="configuration"></param>
+ /// <param name="logger"></param>
+ public MailService(IConfiguration configuration, ILogger<MailService> logger) {
+ _logger = logger;
+ _emailHost = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_HOST);
+ _emailPort = configuration.GetValue<int>(AppEnvironmentVariables.SMTP_PORT);
+ _emailUser = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_USER);
+ _emailPassword = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_PASSWORD);
+ }
+
+ /// <summary>
+ /// Send an email.
+ /// </summary>
+ /// <param name="message"></param>
+ public void SendMail(MailMessage message) {
+ using var smtpClient = new SmtpClient {
+ Host = _emailHost,
+ EnableSsl = _emailPort == 587,
+ Port = _emailPort,
+ Credentials = new NetworkCredential {
+ UserName = _emailUser,
+ Password = _emailPassword,
+ }
+ };
+ var configurationString = JsonSerializer.Serialize(new {
+ Host = smtpClient.Host,
+ EnableSsl = smtpClient.EnableSsl,
+ Port = smtpClient.Port,
+ UserName = _emailUser.HasValue() ? "**REDACTED**" : "**MISSING**",
+ Password = _emailPassword.HasValue() ? "**REDACTED**" : "**MISSING**",
+ },
+ new JsonSerializerOptions {
+ WriteIndented = true
+ });
+ _logger.LogDebug("SmtpClient was instansiated with the following configuration\n" + configurationString);
+
+ smtpClient.Send(message);
+ }
+}
diff --git a/server/src/Services/UserService.cs b/server/src/Services/UserService.cs
new file mode 100644
index 0000000..9b531de
--- /dev/null
+++ b/server/src/Services/UserService.cs
@@ -0,0 +1,50 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class UserService
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <summary>
+ /// Provides methods to perform common operations on user data.
+ /// </summary>
+ /// <param name="forgotPasswordService"></param>
+ public UserService(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Log in a user.
+ /// </summary>
+ /// <param name="httpContext"></param>
+ /// <param name="user"></param>
+ /// <param name="persist"></param>
+ public async Task LogInUser(HttpContext httpContext, User user, bool persist = false) {
+ var claims = new List<Claim> {
+ new(AppClaims.USER_ID, user.Id.ToString()),
+ new(AppClaims.NAME, user.Username),
+ };
+
+ var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
+ var principal = new ClaimsPrincipal(identity);
+ var authenticationProperties = new AuthenticationProperties {
+ AllowRefresh = true,
+ IssuedUtc = DateTimeOffset.UtcNow,
+ };
+
+ if (persist) {
+ authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(6);
+ authenticationProperties.IsPersistent = true;
+ }
+
+ await httpContext.SignInAsync(principal, authenticationProperties);
+ await _forgotPasswordService.DeleteRequestsForUserAsync(user.Id);
+ }
+
+ /// <summary>
+ /// Log out a user.
+ /// </summary>
+ /// <param name="httpContext"></param>
+ public async Task LogOutUser(HttpContext httpContext) {
+ await httpContext.SignOutAsync();
+ }
+}
diff --git a/server/src/Utilities/BasicAuthenticationAttribute.cs b/server/src/Utilities/BasicAuthenticationAttribute.cs
new file mode 100644
index 0000000..0bfd007
--- /dev/null
+++ b/server/src/Utilities/BasicAuthenticationAttribute.cs
@@ -0,0 +1,39 @@
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class BasicAuthenticationAttribute : TypeFilterAttribute
+{
+ public BasicAuthenticationAttribute(string claimPermission) : base(typeof(BasicAuthenticationFilter)) {
+ Arguments = new object[] {
+ new Claim(claimPermission, "True")
+ };
+ }
+}
+
+public class BasicAuthenticationFilter : IAuthorizationFilter
+{
+ private readonly Claim _claim;
+
+ public BasicAuthenticationFilter(Claim claim) {
+ _claim = claim;
+ }
+
+ public void OnAuthorization(AuthorizationFilterContext context) {
+ if (!context.HttpContext.Request.Headers.ContainsKey("Authorization")) return;
+ try {
+ var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]);
+ if (authHeader.Parameter is null) {
+ context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME);
+ }
+
+ var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == _claim.Type && c.Value == _claim.Value);
+ if (!hasClaim) {
+ context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME);
+ }
+ } catch {
+ // ignore
+ }
+ }
+}
diff --git a/server/src/Utilities/BasicAuthenticationHandler.cs b/server/src/Utilities/BasicAuthenticationHandler.cs
new file mode 100644
index 0000000..2b9d9ef
--- /dev/null
+++ b/server/src/Utilities/BasicAuthenticationHandler.cs
@@ -0,0 +1,79 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.Extensions.Options;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
+{
+ private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<BasicAuthenticationHandler> _logger;
+
+ public BasicAuthenticationHandler(
+ IOptionsMonitor<AuthenticationSchemeOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock,
+ AppDbContext context,
+ IConfiguration configuration
+ ) :
+ base(options, logger, encoder, clock) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger.CreateLogger<BasicAuthenticationHandler>();
+ }
+
+ protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
+ var endpoint = Context.GetEndpoint();
+ if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ if (!Request.Headers.ContainsKey("Authorization"))
+ return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header"));
+
+ try {
+ 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 Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
+ var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
+ if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
+ var decrypted_string = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(token_entropy);
+ var token_is_guid = Guid.TryParse(decrypted_string, out var token_id);
+
+ if (!token_is_guid) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
+ var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id);
+ if (token == default) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Not Found"));
+ }
+
+ if (token.HasExpired) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Expired"));
+ }
+
+ var permissions = new List<Claim>() {
+ new(AppConstants.TOKEN_ALLOW_READ, token.AllowRead.ToString()),
+ new(AppConstants.TOKEN_ALLOW_UPDATE, token.AllowUpdate.ToString()),
+ new(AppConstants.TOKEN_ALLOW_CREATE, token.AllowCreate.ToString()),
+ new(AppConstants.TOKEN_ALLOW_DELETE, token.AllowDelete.ToString()),
+ };
+ var claims = token.User.DefaultClaims().Concat(permissions);
+ var identity = new ClaimsIdentity(claims, AppConstants.BASIC_AUTH_SCHEME);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, AppConstants.BASIC_AUTH_SCHEME);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ } catch (Exception e) {
+ _logger.LogError(e, $"An exception occured when challenging {AppConstants.BASIC_AUTH_SCHEME}");
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+ }
+}
diff --git a/server/src/Utilities/ConfigurationExtensions.cs b/server/src/Utilities/ConfigurationExtensions.cs
new file mode 100644
index 0000000..772059a
--- /dev/null
+++ b/server/src/Utilities/ConfigurationExtensions.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class ConfigurationExtensions
+{
+ public static string GetAppDatabaseConnectionString(this IConfiguration configuration) {
+ var host = configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST);
+ var port = configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT);
+ var database = configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME);
+ var user = configuration.GetValue<string>(AppEnvironmentVariables.DB_USER);
+ var password = configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD);
+
+ if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ }
+
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ public static string GetQuartzDatabaseConnectionString(this IConfiguration Configuration) {
+ var host = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST);
+ var port = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT);
+ var database = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME);
+ var user = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER);
+ var password = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD);
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ public static string GetVersion(this IConfiguration configuration) {
+ var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt");
+ if (File.Exists(versionFilePath)) {
+ var versionText = File.ReadAllText(versionFilePath);
+ return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT");
+ }
+
+ return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT");
+ }
+}
diff --git a/server/src/Utilities/GithubAuthenticationHelpers.cs b/server/src/Utilities/GithubAuthenticationHelpers.cs
new file mode 100644
index 0000000..cf0cabb
--- /dev/null
+++ b/server/src/Utilities/GithubAuthenticationHelpers.cs
@@ -0,0 +1,84 @@
+using Microsoft.AspNetCore.Authentication.OAuth;
+using Npgsql;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class GithubAuthenticationHelpers
+{
+ public static async Task HandleGithubTicketCreation(OAuthCreatingTicketContext context, IConfiguration configuration) {
+ var githubId = context.Identity?.FindFirst(p => p.Type == ClaimTypes.NameIdentifier)?.Value;
+ var githubUsername = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Name)?.Value;
+ var githubEmail = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Email)?.Value;
+
+ if (githubId.IsNullOrWhiteSpace() || githubUsername.IsNullOrWhiteSpace() || context.Identity == default) {
+ return;
+ }
+
+ var claims = context.Identity.Claims.ToList();
+ foreach (var claim in claims) {
+ context.Identity.RemoveClaim(claim);
+ }
+
+ var connstring = configuration.GetAppDatabaseConnectionString();
+ var connection = new NpgsqlConnection(connstring);
+
+ Log.Information($"Getting user mappings for github user: {githubId}");
+ var getMappedUserQuery = @$"SELECT u.id,u.username FROM github_user_mappings INNER JOIN users u on u.id = github_user_mappings.user_id WHERE github_id='{githubId}'";
+ await connection.OpenAsync();
+ await using var getMappedUserCommand = new NpgsqlCommand(getMappedUserQuery, connection);
+ await using var reader = await getMappedUserCommand.ExecuteReaderAsync();
+ var handled = false;
+ while (await reader.ReadAsync()) {
+ try {
+ var userId = reader.GetGuid(0);
+ var username = reader.GetString(1);
+ context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString()));
+ context.Identity.AddClaim(new Claim(AppClaims.NAME, username));
+ if (context.AccessToken != default) {
+ context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? ""));
+ }
+
+ Log.Information($"Found mapping for github id {githubId} mapped to user id {userId}");
+ handled = true;
+ } catch (Exception e) {
+ Log.Error(e, "An exception occured when handling github user mappings");
+ handled = false;
+ }
+ }
+
+ await connection.CloseAsync();
+
+ if (!handled) {
+ var userId = Guid.NewGuid();
+
+ var insertUserQuery = $@"INSERT INTO users VALUES ('{userId}', '{githubUsername}', '', '{DateTime.UtcNow}')";
+ await connection.OpenAsync();
+ await using var insertUserCommand = new NpgsqlCommand(insertUserQuery, connection);
+ await insertUserCommand.ExecuteNonQueryAsync();
+ await connection.CloseAsync();
+
+ var refreshTokenEncryptionKey = configuration.GetValue<string>(AppEnvironmentVariables.APP_AES_KEY);
+ string insertMappingQuery;
+
+ if (context.RefreshToken.HasValue() && refreshTokenEncryptionKey.HasValue()) {
+ var encryptedRefreshToken = context.RefreshToken.EncryptWithAes(refreshTokenEncryptionKey);
+ insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '{encryptedRefreshToken}')";
+ } else {
+ insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '')";
+ }
+
+ await connection.OpenAsync();
+ await using var insertMappingCommand = new NpgsqlCommand(insertMappingQuery, connection);
+ await insertMappingCommand.ExecuteNonQueryAsync();
+ await connection.CloseAsync();
+
+ context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString()));
+ context.Identity.AddClaim(new Claim(AppClaims.NAME, githubUsername));
+ if (context.AccessToken != default) {
+ context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? ""));
+ }
+
+ Log.Information($"Created mapping for github id {githubId} mapped to user id {userId}");
+ }
+ }
+}
diff --git a/server/src/Utilities/QuartzJsonSerializer.cs b/server/src/Utilities/QuartzJsonSerializer.cs
new file mode 100644
index 0000000..164a189
--- /dev/null
+++ b/server/src/Utilities/QuartzJsonSerializer.cs
@@ -0,0 +1,16 @@
+using Quartz.Spi;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class QuartzJsonSerializer : IObjectSerializer
+{
+ public void Initialize() { }
+
+ public byte[] Serialize<T>(T obj) where T : class {
+ return JsonSerializer.SerializeToUtf8Bytes(obj);
+ }
+
+ public T DeSerialize<T>(byte[] data) where T : class {
+ return JsonSerializer.Deserialize<T>(data);
+ }
+}
diff --git a/server/src/Utilities/SwaggerDefaultValues.cs b/server/src/Utilities/SwaggerDefaultValues.cs
new file mode 100644
index 0000000..4b5c764
--- /dev/null
+++ b/server/src/Utilities/SwaggerDefaultValues.cs
@@ -0,0 +1,58 @@
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+/// <summary>
+/// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter.
+/// </summary>
+/// <remarks>This <see cref="IOperationFilter"/> is only required due to bugs in the <see cref="SwaggerGenerator"/>.
+/// Once they are fixed and published, this class can be removed.</remarks>
+public class SwaggerDefaultValues : IOperationFilter
+{
+ /// <summary>
+ /// Applies the filter to the specified operation using the given context.
+ /// </summary>
+ /// <param name="operation">The operation to apply the filter to.</param>
+ /// <param name="context">The current operation filter context.</param>
+ public void Apply(OpenApiOperation operation, OperationFilterContext context) {
+ var apiDescription = context.ApiDescription;
+
+ operation.Deprecated |= apiDescription.IsDeprecated();
+
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077
+ foreach (var responseType in context.ApiDescription.SupportedResponseTypes) {
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387
+ var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
+ var response = operation.Responses[responseKey];
+
+ foreach (var contentType in response.Content.Keys) {
+ if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) {
+ response.Content.Remove(contentType);
+ }
+ }
+ }
+
+ if (operation.Parameters == null) {
+ return;
+ }
+
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413
+ foreach (var parameter in operation.Parameters) {
+ var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
+
+ if (parameter.Description == null) {
+ parameter.Description = description.ModelMetadata.Description;
+ }
+
+ if (parameter.Schema.Default == null && description.DefaultValue != null) {
+ // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330
+ var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata.ModelType);
+ parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
+ }
+
+ parameter.Required |= description.IsRequired;
+ }
+ }
+}
diff --git a/server/src/Utilities/SwaggerGenOptionsExtensions.cs b/server/src/Utilities/SwaggerGenOptionsExtensions.cs
new file mode 100644
index 0000000..a2dcf7a
--- /dev/null
+++ b/server/src/Utilities/SwaggerGenOptionsExtensions.cs
@@ -0,0 +1,43 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Swashbuckle.AspNetCore.SwaggerGen;
+using BaseRoute = IOL.GreatOffice.Api.Endpoints.V1.BaseRoute;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class SwaggerGenOptionsExtensions
+{
+ /// <summary>
+ /// Updates Swagger document to support ApiEndpoints.<br/><br/>
+ /// For controllers inherited from <see cref="BaseRoute"/>:<br/>
+ /// - Replaces action Tag with <c>[namespace]</c><br/>
+ /// </summary>
+ public static void UseApiEndpoints(this SwaggerGenOptions options) {
+ options.TagActionsBy(EndpointNamespaceOrDefault);
+ }
+
+ private static IList<string?> EndpointNamespaceOrDefault(ApiDescription api) {
+ if (api.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) {
+ throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}");
+ }
+
+ if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(BaseRoute))) {
+ return new[] {
+ actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last()
+ };
+ }
+
+ return new[] {
+ actionDescriptor.ControllerName
+ };
+ }
+
+ public static IEnumerable<Type> GetBaseTypesAndThis(this Type type) {
+ Type? current = type;
+ while (current != null) {
+ yield return current;
+ current = current.BaseType;
+ }
+ }
+}
diff --git a/server/src/appsettings.json b/server/src/appsettings.json
new file mode 100644
index 0000000..8727fd7
--- /dev/null
+++ b/server/src/appsettings.json
@@ -0,0 +1,22 @@
+{
+ "AllowedHosts": "*",
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "Microsoft.EntityFrameworkCore": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Filter": [
+ {
+ "Name": "ByExcluding",
+ "Args": {
+ "expression": "@mt = 'An unhandled exception has occurred while executing the request.'"
+ }
+ }
+ ]
+ }
+}
diff --git a/server/src/wwwroot/version.txt b/server/src/wwwroot/version.txt
new file mode 100644
index 0000000..863c2f5
--- /dev/null
+++ b/server/src/wwwroot/version.txt
@@ -0,0 +1 @@
+v35-server-dev