aboutsummaryrefslogtreecommitdiffstats
path: root/code
diff options
context:
space:
mode:
Diffstat (limited to 'code')
-rw-r--r--code/api/.build.yaml23
-rw-r--r--code/api/.dockerignore10
-rw-r--r--code/api/.version1
-rw-r--r--code/api/.version-dev1
-rw-r--r--code/api/CHANGELOG.md123
-rw-r--r--code/api/Dockerfile16
-rwxr-xr-xcode/api/build_and_push.sh93
-rw-r--r--code/api/cliff.toml62
-rw-r--r--code/api/src/Data/AppDbContext.cs51
-rw-r--r--code/api/src/Data/Database/ApiAccessToken.cs31
-rw-r--r--code/api/src/Data/Database/Base.cs15
-rw-r--r--code/api/src/Data/Database/BaseWithOwner.cs19
-rw-r--r--code/api/src/Data/Database/Customer.cs6
-rw-r--r--code/api/src/Data/Database/CustomerContact.cs12
-rw-r--r--code/api/src/Data/Database/CustomerEvent.cs7
-rw-r--r--code/api/src/Data/Database/ForgotPasswordRequest.cs23
-rw-r--r--code/api/src/Data/Database/Project.cs7
-rw-r--r--code/api/src/Data/Database/Tenant.cs11
-rw-r--r--code/api/src/Data/Database/TimeCategory.cs31
-rw-r--r--code/api/src/Data/Database/TimeEntry.cs45
-rw-r--r--code/api/src/Data/Database/TimeLabel.cs31
-rw-r--r--code/api/src/Data/Database/Todo.cs13
-rw-r--r--code/api/src/Data/Database/TodoComment.cs7
-rw-r--r--code/api/src/Data/Database/TodoLabel.cs8
-rw-r--r--code/api/src/Data/Database/TodoProject.cs16
-rw-r--r--code/api/src/Data/Database/TodoProjectAccessControl.cs11
-rw-r--r--code/api/src/Data/Database/TodoStatus.cs45
-rw-r--r--code/api/src/Data/Database/User.cs37
-rw-r--r--code/api/src/Data/Dtos/TimeQueryDto.cs34
-rw-r--r--code/api/src/Data/Dtos/UserArchiveDto.cs131
-rw-r--r--code/api/src/Data/Enums/TimeEntryQueryDuration.cs37
-rw-r--r--code/api/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs21
-rw-r--r--code/api/src/Data/Exceptions/UserNotFoundException.cs19
-rw-r--r--code/api/src/Data/Models/ApiSpecDocument.cs9
-rw-r--r--code/api/src/Data/Models/AppPath.cs23
-rw-r--r--code/api/src/Data/Models/LoggedInUserModel.cs7
-rw-r--r--code/api/src/Data/Results/ErrorResult.cs12
-rw-r--r--code/api/src/Data/Static/AppClaims.cs8
-rw-r--r--code/api/src/Data/Static/AppConfiguration.cs58
-rw-r--r--code/api/src/Data/Static/AppConstants.cs12
-rw-r--r--code/api/src/Data/Static/AppDateTime.cs16
-rw-r--r--code/api/src/Data/Static/AppEnvironmentVariables.cs21
-rw-r--r--code/api/src/Data/Static/AppHeaders.cs7
-rw-r--r--code/api/src/Data/Static/AppPaths.cs17
-rw-r--r--code/api/src/Data/Static/JsonSettings.cs11
-rw-r--r--code/api/src/Endpoints/Internal/Account/CreateAccountPayload.cs17
-rw-r--r--code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs44
-rw-r--r--code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs34
-rw-r--r--code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs49
-rw-r--r--code/api/src/Endpoints/Internal/Account/GetArchiveRoute.cs62
-rw-r--r--code/api/src/Endpoints/Internal/Account/GetRoute.cs31
-rw-r--r--code/api/src/Endpoints/Internal/Account/LoginPayload.cs22
-rw-r--r--code/api/src/Endpoints/Internal/Account/LoginRoute.cs37
-rw-r--r--code/api/src/Endpoints/Internal/Account/LogoutRoute.cs22
-rw-r--r--code/api/src/Endpoints/Internal/Account/UpdateAccountPayload.cs17
-rw-r--r--code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs51
-rw-r--r--code/api/src/Endpoints/Internal/BaseRoute.cs16
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs59
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs14
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs34
-rw-r--r--code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs29
-rw-r--r--code/api/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs21
-rw-r--r--code/api/src/Endpoints/Internal/Root/LogRoute.cs16
-rw-r--r--code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs17
-rw-r--r--code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs15
-rw-r--r--code/api/src/Endpoints/Internal/Root/ValidSessionRoute.cs10
-rw-r--r--code/api/src/Endpoints/Internal/RouteBaseAsync.cs73
-rw-r--r--code/api/src/Endpoints/Internal/RouteBaseSync.cs53
-rw-r--r--code/api/src/Endpoints/V1/ApiSpecV1.cs18
-rw-r--r--code/api/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs57
-rw-r--r--code/api/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs33
-rw-r--r--code/api/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs22
-rw-r--r--code/api/src/Endpoints/V1/BaseRoute.cs39
-rw-r--r--code/api/src/Endpoints/V1/Categories/CreateCategoryRoute.cs43
-rw-r--r--code/api/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs38
-rw-r--r--code/api/src/Endpoints/V1/Categories/GetCategoriesRoute.cs35
-rw-r--r--code/api/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs39
-rw-r--r--code/api/src/Endpoints/V1/Entries/CreateEntryRoute.cs65
-rw-r--r--code/api/src/Endpoints/V1/Entries/DeleteEntryRoute.cs35
-rw-r--r--code/api/src/Endpoints/V1/Entries/EntryQueryPayload.cs60
-rw-r--r--code/api/src/Endpoints/V1/Entries/EntryQueryResponse.cs37
-rw-r--r--code/api/src/Endpoints/V1/Entries/EntryQueryRoute.cs186
-rw-r--r--code/api/src/Endpoints/V1/Entries/GetEntryRoute.cs34
-rw-r--r--code/api/src/Endpoints/V1/Entries/UpdateEntryRoute.cs66
-rw-r--r--code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs46
-rw-r--r--code/api/src/Endpoints/V1/Labels/DeleteLabelRoute.cs35
-rw-r--r--code/api/src/Endpoints/V1/Labels/GetLabelRoute.cs34
-rw-r--r--code/api/src/Endpoints/V1/Labels/UpdateLabelRoute.cs38
-rw-r--r--code/api/src/Endpoints/V1/RouteBaseAsync.cs73
-rw-r--r--code/api/src/Endpoints/V1/RouteBaseSync.cs53
-rw-r--r--code/api/src/IOL.GreatOffice.Api.csproj56
-rw-r--r--code/api/src/Jobs/JobRegister.cs18
-rw-r--r--code/api/src/Jobs/TokenCleanupJob.cs22
-rw-r--r--code/api/src/Jobs/VaultTokenRenewalJob.cs15
-rw-r--r--code/api/src/Migrations/20210517202115_InitialMigration.Designer.cs238
-rw-r--r--code/api/src/Migrations/20210517202115_InitialMigration.cs162
-rw-r--r--code/api/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs229
-rw-r--r--code/api/src/Migrations/20210522165932_RenameNoteToDescription.cs34
-rw-r--r--code/api/src/Migrations/20211002113037_V6Migration.Designer.cs233
-rw-r--r--code/api/src/Migrations/20211002113037_V6Migration.cs130
-rw-r--r--code/api/src/Migrations/20220225143559_GithubUserMappings.Designer.cs270
-rw-r--r--code/api/src/Migrations/20220225143559_GithubUserMappings.cs43
-rw-r--r--code/api/src/Migrations/20220319135910_RenameCreated.Designer.cs270
-rw-r--r--code/api/src/Migrations/20220319135910_RenameCreated.cs65
-rw-r--r--code/api/src/Migrations/20220319144958_ModifiedAt.Designer.cs290
-rw-r--r--code/api/src/Migrations/20220319144958_ModifiedAt.cs66
-rw-r--r--code/api/src/Migrations/20220319203018_UserBase.Designer.cs322
-rw-r--r--code/api/src/Migrations/20220319203018_UserBase.cs140
-rw-r--r--code/api/src/Migrations/20220320115601_Update1.Designer.cs342
-rw-r--r--code/api/src/Migrations/20220320115601_Update1.cs139
-rw-r--r--code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs344
-rw-r--r--code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs57
-rw-r--r--code/api/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs401
-rw-r--r--code/api/src/Migrations/20220529190359_ApiAccessTokens.cs48
-rw-r--r--code/api/src/Migrations/20220530174741_Tenants.Designer.cs710
-rw-r--r--code/api/src/Migrations/20220530174741_Tenants.cs481
-rw-r--r--code/api/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs686
-rw-r--r--code/api/src/Migrations/20220530175322_RemoveUnusedNavs.cs78
-rw-r--r--code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.Designer.cs656
-rw-r--r--code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.cs649
-rw-r--r--code/api/src/Migrations/20220606232346_FleshOutNewModules.Designer.cs510
-rw-r--r--code/api/src/Migrations/20220606232346_FleshOutNewModules.cs630
-rw-r--r--code/api/src/Migrations/20220616170311_DataProtectionKeys.Designer.cs533
-rw-r--r--code/api/src/Migrations/20220616170311_DataProtectionKeys.cs33
-rw-r--r--code/api/src/Migrations/20220819203816_RemoveGithubUsers.Designer.cs496
-rw-r--r--code/api/src/Migrations/20220819203816_RemoveGithubUsers.cs43
-rw-r--r--code/api/src/Migrations/AppDbContextModelSnapshot.cs494
-rw-r--r--code/api/src/Program.cs236
-rw-r--r--code/api/src/Properties/launchSettings.json14
-rw-r--r--code/api/src/Services/MailService.cs49
-rw-r--r--code/api/src/Services/PasswordResetService.cs115
-rw-r--r--code/api/src/Services/UserService.cs50
-rw-r--r--code/api/src/Services/VaultService.cs158
-rw-r--r--code/api/src/Utilities/BasicAuthenticationAttribute.cs39
-rw-r--r--code/api/src/Utilities/BasicAuthenticationHandler.cs79
-rw-r--r--code/api/src/Utilities/ConfigurationExtensions.cs88
-rw-r--r--code/api/src/Utilities/GithubAuthenticationHelpers.cs84
-rw-r--r--code/api/src/Utilities/QuartzJsonSerializer.cs16
-rw-r--r--code/api/src/Utilities/SwaggerDefaultValues.cs58
-rw-r--r--code/api/src/Utilities/SwaggerGenOptionsExtensions.cs43
-rw-r--r--code/api/src/appsettings.json22
-rw-r--r--code/api/src/wwwroot/version.txt1
-rw-r--r--code/app/.gitignore8
-rw-r--r--code/app/.npmrc1
-rw-r--r--code/app/.typesafe-i18n.json5
-rw-r--r--code/app/package.json46
-rw-r--r--code/app/playwright.config.ts10
-rw-r--r--code/app/pnpm-lock.yaml2299
-rw-r--r--code/app/postcss.config.cjs13
-rw-r--r--code/app/src/actions/pwKey.js12
-rw-r--r--code/app/src/actions/pwKey.js.map1
-rw-r--r--code/app/src/actions/pwKey.ts10
-rw-r--r--code/app/src/app.d.ts9
-rw-r--r--code/app/src/app.html14
-rw-r--r--code/app/src/app.pcss34
-rw-r--r--code/app/src/global.d.ts11
-rw-r--r--code/app/src/lib/api/internal-fetch.ts170
-rw-r--r--code/app/src/lib/api/root.ts6
-rw-r--r--code/app/src/lib/api/time-entry.ts83
-rw-r--r--code/app/src/lib/api/user.ts47
-rw-r--r--code/app/src/lib/colors.ts47
-rw-r--r--code/app/src/lib/components/alert.svelte268
-rw-r--r--code/app/src/lib/components/button.svelte103
-rw-r--r--code/app/src/lib/components/checkbox.svelte24
-rw-r--r--code/app/src/lib/components/icons/adjustments.svelte14
-rw-r--r--code/app/src/lib/components/icons/bars-3-center-left.svelte15
-rw-r--r--code/app/src/lib/components/icons/calendar.svelte14
-rw-r--r--code/app/src/lib/components/icons/check-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/chevron-up-down.svelte13
-rw-r--r--code/app/src/lib/components/icons/database.svelte14
-rw-r--r--code/app/src/lib/components/icons/exclamation-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/exclamation-triangle.svelte13
-rw-r--r--code/app/src/lib/components/icons/folder-open.svelte14
-rw-r--r--code/app/src/lib/components/icons/home.svelte14
-rw-r--r--code/app/src/lib/components/icons/index.ts41
-rw-r--r--code/app/src/lib/components/icons/information-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/magnifying-glass.svelte13
-rw-r--r--code/app/src/lib/components/icons/megaphone.svelte14
-rw-r--r--code/app/src/lib/components/icons/menu.svelte14
-rw-r--r--code/app/src/lib/components/icons/queue-list.svelte14
-rw-r--r--code/app/src/lib/components/icons/spinner.svelte20
-rw-r--r--code/app/src/lib/components/icons/x-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/x-mark.svelte11
-rw-r--r--code/app/src/lib/components/icons/x.svelte14
-rw-r--r--code/app/src/lib/components/index.ts15
-rw-r--r--code/app/src/lib/components/input.svelte103
-rw-r--r--code/app/src/lib/components/locale-switcher.svelte55
-rw-r--r--code/app/src/lib/components/switch.svelte143
-rw-r--r--code/app/src/lib/configuration.ts60
-rw-r--r--code/app/src/lib/helpers.ts497
-rw-r--r--code/app/src/lib/i18n/en/app/index.ts5
-rw-r--r--code/app/src/lib/i18n/en/index.ts50
-rw-r--r--code/app/src/lib/i18n/formatters.ts13
-rw-r--r--code/app/src/lib/i18n/i18n-svelte.ts12
-rw-r--r--code/app/src/lib/i18n/i18n-types.ts359
-rw-r--r--code/app/src/lib/i18n/i18n-util.async.ts42
-rw-r--r--code/app/src/lib/i18n/i18n-util.sync.ts35
-rw-r--r--code/app/src/lib/i18n/i18n-util.ts39
-rw-r--r--code/app/src/lib/i18n/nb/app/index.ts8
-rw-r--r--code/app/src/lib/i18n/nb/index.ts50
-rw-r--r--code/app/src/lib/logger.ts86
-rw-r--r--code/app/src/lib/models/CreateAccountPayload.ts4
-rw-r--r--code/app/src/lib/models/ErrorResult.ts4
-rw-r--r--code/app/src/lib/models/IInternalFetchRequest.ts6
-rw-r--r--code/app/src/lib/models/IInternalFetchResponse.ts6
-rw-r--r--code/app/src/lib/models/ISession.ts8
-rw-r--r--code/app/src/lib/models/IValidationResult.ts31
-rw-r--r--code/app/src/lib/models/LoginPayload.ts5
-rw-r--r--code/app/src/lib/models/TimeCategoryDto.ts9
-rw-r--r--code/app/src/lib/models/TimeEntryDto.ts13
-rw-r--r--code/app/src/lib/models/TimeEntryQuery.ts27
-rw-r--r--code/app/src/lib/models/TimeLabelDto.ts8
-rw-r--r--code/app/src/lib/models/TimeQueryDto.ts29
-rw-r--r--code/app/src/lib/models/UnwrappedEntryDateTime.ts9
-rw-r--r--code/app/src/lib/models/UpdateProfilePayload.ts4
-rw-r--r--code/app/src/lib/persistent-store.ts102
-rw-r--r--code/app/src/lib/session.ts69
-rw-r--r--code/app/src/routes/(main)/(app)/+layout.svelte297
-rw-r--r--code/app/src/routes/(main)/(app)/home/+page.svelte1
-rw-r--r--code/app/src/routes/(main)/(app)/org/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/profile/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/projects/+page.svelte5
-rw-r--r--code/app/src/routes/(main)/(app)/settings/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/tickets/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/todo/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/wiki/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(public)/+layout.svelte18
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/+page.svelte82
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js11
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map1
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte132
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/+page.svelte133
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/index.ts19
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts12
-rw-r--r--code/app/src/routes/(main)/(public)/sign-up/+page.svelte82
-rw-r--r--code/app/src/routes/(main)/+layout.server.ts34
-rw-r--r--code/app/src/routes/(main)/+layout.svelte29
-rw-r--r--code/app/src/routes/(main)/+layout.ts15
-rw-r--r--code/app/src/routes/(main)/+page.svelte1
-rw-r--r--code/app/src/routes/book/+layout.svelte64
-rw-r--r--code/app/src/routes/book/+page.svelte1
-rw-r--r--code/app/src/routes/book/alerts/+page.svelte70
-rw-r--r--code/app/src/routes/book/buttons/+page.svelte23
-rw-r--r--code/app/src/routes/book/inputs/+page.svelte48
-rw-r--r--code/app/src/routes/book/toggles/+page.svelte27
-rw-r--r--code/app/static/favicon.icobin0 -> 1406 bytes
-rw-r--r--code/app/svelte.config.js24
-rw-r--r--code/app/tailwind.config.cjs135
-rw-r--r--code/app/tsconfig.json11
-rw-r--r--code/app/vite.config.js14
-rw-r--r--code/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs23
-rw-r--r--code/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs6
-rw-r--r--code/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs48
-rw-r--r--code/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj21
255 files changed, 20865 insertions, 0 deletions
diff --git a/code/api/.build.yaml b/code/api/.build.yaml
new file mode 100644
index 0000000..412cd17
--- /dev/null
+++ b/code/api/.build.yaml
@@ -0,0 +1,23 @@
+image: ubuntu/lts
+packages:
+ - docker.io
+secrets:
+ - ea28f7fe-b300-4b79-addf-d487ed6eb1ef
+ - b6c0403d-10a9-4238-89cc-5402dc0c9fe5
+sources:
+ - git@git.ivar.systems:greatoffice
+tasks:
+ - setup: |
+ echo "export IMAGE_NAME=greatoffice/server
+ export HUB_NAME=dr.ivar.systems/greatoffice/server
+ export CURRENT_VERSION=$(cat ~/greatoffice/server/.version)
+ export CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+ export NEW_VERSION=v$(CURRENT_VERSION_INT+1)-server" >> .buildenv
+ - build: |
+ sudo docker build -t $IMAGE_NAME:$NEW_VERSION ~/greatoffice/server
+ - publish: |
+ cat ~/.dockerpassword | sudo docker login dr.ivar.systems -u builder --password-stdin
+ sudo docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION
+ sudo docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest
+ sudo docker push -a
+ complete-build() \ No newline at end of file
diff --git a/code/api/.dockerignore b/code/api/.dockerignore
new file mode 100644
index 0000000..2b24da3
--- /dev/null
+++ b/code/api/.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/code/api/.version b/code/api/.version
new file mode 100644
index 0000000..f20f9eb
--- /dev/null
+++ b/code/api/.version
@@ -0,0 +1 @@
+v22-server
diff --git a/code/api/.version-dev b/code/api/.version-dev
new file mode 100644
index 0000000..9cf68da
--- /dev/null
+++ b/code/api/.version-dev
@@ -0,0 +1 @@
+v47-server-dev
diff --git a/code/api/CHANGELOG.md b/code/api/CHANGELOG.md
new file mode 100644
index 0000000..88710c3
--- /dev/null
+++ b/code/api/CHANGELOG.md
@@ -0,0 +1,123 @@
+# Changelog
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v41-server-dev
+
+### Refactor
+
+- Implement caching in VaultService and use VaultService instead of IOptions
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v40-server-dev
+
+### Refactor
+
+- Use Vault to get configuration
+
+## [unreleased]
+
+### Features
+
+- !WIP start implementation of svelte-query
+
+### Miscellaneous Tasks
+
+- Bump version
+- Remove logging of quartz db host
+- Update CHANGELOG.md for v6-projects-dev
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v39-server-dev
+
+### Refactor
+
+- Use Vault to get configuration
+- Small changes on button style
+- Add a small box-shadow
+
+## [unreleased]
+
+### Bug Fixes
+
+- !WIP flickering dropdown on multi dropdowns with new focus strategy
+- Fix route matching when deciding which tab is open
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v22-server
+- Bump version
+- Update CHANGELOG.md for v15-portal-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v14-portal-dev
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v38-server-dev
+
+### Refactor
+
+- Dont loose focus on search input when navigating results
+- Make optional base fields nullable
+- Remove text
+- Rename accounts to portal
+- Rename accounts to portal
+- Rename accounts to portal
+- Rename noResultsText
+
+## [unreleased]
+
+### Bug Fixes
+
+- !WIP flickering dropdown on multi dropdowns with new focus strategy
+- Fix route matching when deciding which tab is open
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v15-portal-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v14-portal-dev
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v37-server-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v22-server
+
+### Refactor
+
+- Dont loose focus on search input when navigating results
+- Make optional base fields nullable
+- Remove text
+- Rename accounts to portal
+- Rename accounts to portal
+- Rename accounts to portal
+- Rename noResultsText
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v37-server-dev
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v21-server
+
diff --git a/code/api/Dockerfile b/code/api/Dockerfile
new file mode 100644
index 0000000..adc4be3
--- /dev/null
+++ b/code/api/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/code/api/build_and_push.sh b/code/api/build_and_push.sh
new file mode 100755
index 0000000..dd88916
--- /dev/null
+++ b/code/api/build_and_push.sh
@@ -0,0 +1,93 @@
+#!/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/server"
+HUB_NAME="dr.ivar.systems/greatoffice/server"
+
+# 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
+ 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 buildx build --platform linux/amd64 -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/code/api/cliff.toml b/code/api/cliff.toml
new file mode 100644
index 0000000..7299951
--- /dev/null
+++ b/code/api/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.ivar.systems/greatoffice/commit/${2})" },
+ { pattern = "https://git.ivar.systems/greatoffice/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/code/api/src/Data/AppDbContext.cs b/code/api/src/Data/AppDbContext.cs
new file mode 100644
index 0000000..c970429
--- /dev/null
+++ b/code/api/src/Data/AppDbContext.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
+
+namespace IOL.GreatOffice.Api.Data;
+
+public class AppDbContext : DbContext, IDataProtectionKeyContext
+{
+ 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<ApiAccessToken> AccessTokens { get; set; }
+ public DbSet<Tenant> Tenants { get; set; }
+ public DbSet<DataProtectionKey> DataProtectionKeys { 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.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity<TimeLabel>(e => {
+ e.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity<TimeEntry>(e => {
+ e.HasOne(c => c.Category);
+ e.HasMany(c => c.Labels);
+ e.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity<ApiAccessToken>(e => {
+ e.ToTable("api_access_tokens");
+ });
+
+ modelBuilder.Entity<Tenant>(e => {
+ e.ToTable("tenants");
+ });
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/code/api/src/Data/Database/ApiAccessToken.cs b/code/api/src/Data/Database/ApiAccessToken.cs
new file mode 100644
index 0000000..9582869
--- /dev/null
+++ b/code/api/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 < AppDateTime.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 < AppDateTime.UtcNow;
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Data/Database/Base.cs b/code/api/src/Data/Database/Base.cs
new file mode 100644
index 0000000..ae9efa2
--- /dev/null
+++ b/code/api/src/Data/Database/Base.cs
@@ -0,0 +1,15 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Base
+{
+ protected Base() {
+ Id = Guid.NewGuid();
+ CreatedAt = AppDateTime.UtcNow;
+ }
+
+ public Guid Id { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? ModifiedAt { get; private set; }
+ public bool Deleted { get; set; }
+ public void Modified() => ModifiedAt = AppDateTime.UtcNow;
+} \ No newline at end of file
diff --git a/code/api/src/Data/Database/BaseWithOwner.cs b/code/api/src/Data/Database/BaseWithOwner.cs
new file mode 100644
index 0000000..1eb99f4
--- /dev/null
+++ b/code/api/src/Data/Database/BaseWithOwner.cs
@@ -0,0 +1,19 @@
+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; set; }
+ public Guid? TenantId { get; init; }
+ public Guid? ModifiedById { get; init; }
+ public Guid? CreatedById { get; init; }
+ public Guid? DeletedById { get; init; }
+} \ No newline at end of file
diff --git a/code/api/src/Data/Database/Customer.cs b/code/api/src/Data/Database/Customer.cs
new file mode 100644
index 0000000..c6b06a4
--- /dev/null
+++ b/code/api/src/Data/Database/Customer.cs
@@ -0,0 +1,6 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Customer : BaseWithOwner
+{
+ public string Name { get; set; }
+} \ No newline at end of file
diff --git a/code/api/src/Data/Database/CustomerContact.cs b/code/api/src/Data/Database/CustomerContact.cs
new file mode 100644
index 0000000..f5a951d
--- /dev/null
+++ b/code/api/src/Data/Database/CustomerContact.cs
@@ -0,0 +1,12 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class CustomerContact : BaseWithOwner
+{
+ public Customer Customer { get; set; }
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string Email { get; set; }
+ public string Phone { get; set; }
+ public string WorkTitle { get; set; }
+ public string Note { get; set; }
+}
diff --git a/code/api/src/Data/Database/CustomerEvent.cs b/code/api/src/Data/Database/CustomerEvent.cs
new file mode 100644
index 0000000..da3e3ed
--- /dev/null
+++ b/code/api/src/Data/Database/CustomerEvent.cs
@@ -0,0 +1,7 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class CustomerEvent : BaseWithOwner
+{
+ public Customer Customer { get; set; }
+ public string Name { get; set; }
+}
diff --git a/code/api/src/Data/Database/ForgotPasswordRequest.cs b/code/api/src/Data/Database/ForgotPasswordRequest.cs
new file mode 100644
index 0000000..1510a35
--- /dev/null
+++ b/code/api/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 = AppDateTime.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, AppDateTime.UtcNow) < 0;
+}
diff --git a/code/api/src/Data/Database/Project.cs b/code/api/src/Data/Database/Project.cs
new file mode 100644
index 0000000..7e694ee
--- /dev/null
+++ b/code/api/src/Data/Database/Project.cs
@@ -0,0 +1,7 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Project : BaseWithOwner
+{
+ public string Name { get; set; }
+ public Guid? CustomerId { get; set; }
+}
diff --git a/code/api/src/Data/Database/Tenant.cs b/code/api/src/Data/Database/Tenant.cs
new file mode 100644
index 0000000..b185c7a
--- /dev/null
+++ b/code/api/src/Data/Database/Tenant.cs
@@ -0,0 +1,11 @@
+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; }
+ public ICollection<User> Users { get; set; }
+}
diff --git a/code/api/src/Data/Database/TimeCategory.cs b/code/api/src/Data/Database/TimeCategory.cs
new file mode 100644
index 0000000..69c6957
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Database/TimeEntry.cs b/code/api/src/Data/Database/TimeEntry.cs
new file mode 100644
index 0000000..46c62e1
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Database/TimeLabel.cs b/code/api/src/Data/Database/TimeLabel.cs
new file mode 100644
index 0000000..55e20b0
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Database/Todo.cs b/code/api/src/Data/Database/Todo.cs
new file mode 100644
index 0000000..5fe3c9a
--- /dev/null
+++ b/code/api/src/Data/Database/Todo.cs
@@ -0,0 +1,13 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Todo : BaseWithOwner
+{
+ public int PublicId { get; set; }
+ public TodoStatus Status { get; set; }
+ public TodoProject Project { get; set; }
+ public Guid? AssignedUserId { get; set; }
+ public string Title { get; set; }
+ public string Description { get; set; }
+ public ICollection<TodoLabel> Labels { get; set; }
+ public ICollection<TodoComment> Comments { get; set; }
+}
diff --git a/code/api/src/Data/Database/TodoComment.cs b/code/api/src/Data/Database/TodoComment.cs
new file mode 100644
index 0000000..44dcbed
--- /dev/null
+++ b/code/api/src/Data/Database/TodoComment.cs
@@ -0,0 +1,7 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TodoComment : BaseWithOwner
+{
+ public string Value { get; set; }
+ public Todo Todo { get; set; }
+}
diff --git a/code/api/src/Data/Database/TodoLabel.cs b/code/api/src/Data/Database/TodoLabel.cs
new file mode 100644
index 0000000..7753ade
--- /dev/null
+++ b/code/api/src/Data/Database/TodoLabel.cs
@@ -0,0 +1,8 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TodoLabel : BaseWithOwner
+{
+ public string Name { get; set; }
+ public string Color { get; set; }
+ public Todo Todo { get; set; }
+}
diff --git a/code/api/src/Data/Database/TodoProject.cs b/code/api/src/Data/Database/TodoProject.cs
new file mode 100644
index 0000000..0a4a7be
--- /dev/null
+++ b/code/api/src/Data/Database/TodoProject.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TodoProject : BaseWithOwner
+{
+ public string Name { get; set; }
+ public TodoVisibility Visibility { get; set; }
+ public Guid? ProjectId { get; set; }
+}
+
+public enum TodoVisibility
+{
+ PRIVATE = 0,
+ UNLISTED = 1,
+ TENANT_WIDE = 2,
+ PUBLIC = 3,
+}
diff --git a/code/api/src/Data/Database/TodoProjectAccessControl.cs b/code/api/src/Data/Database/TodoProjectAccessControl.cs
new file mode 100644
index 0000000..964f831
--- /dev/null
+++ b/code/api/src/Data/Database/TodoProjectAccessControl.cs
@@ -0,0 +1,11 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TodoProjectAccessControl
+{
+ public TodoProject Project { get; set; }
+ public Guid? UserId { get; set; }
+ public bool Browse { get; set; }
+ public bool Submit { get; set; }
+ public bool Comment { get; set; }
+ public bool Edit { get; set; }
+}
diff --git a/code/api/src/Data/Database/TodoStatus.cs b/code/api/src/Data/Database/TodoStatus.cs
new file mode 100644
index 0000000..416212d
--- /dev/null
+++ b/code/api/src/Data/Database/TodoStatus.cs
@@ -0,0 +1,45 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TodoStatus : BaseWithOwner
+{
+ public string Name { get; set; }
+ public string Color { get; set; }
+ public Todo Todo { get; set; }
+
+ public static List<TodoStatus> GetDefaultStatusSetForTenant(Guid tenantId) {
+ return new List<TodoStatus>() {
+ new() {
+ Name = "Reported",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Resolved",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Fixed",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Implemented",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Won't fix",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "By design",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Invalid",
+ TenantId = tenantId
+ },
+ new() {
+ Name = "Duplicate",
+ TenantId = tenantId
+ }
+ };
+ }
+}
diff --git a/code/api/src/Data/Database/User.cs b/code/api/src/Data/Database/User.cs
new file mode 100644
index 0000000..9db5d35
--- /dev/null
+++ b/code/api/src/Data/Database/User.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class User : Base
+{
+ public User() { }
+
+ public User(string username) {
+ Username = username;
+ }
+
+ public string FirstName { get; set; }
+ public string LastName { get; set; }
+ public string Email { get; set; }
+ public string Username { get; set; }
+ public string Password { get; set; }
+ public ICollection<Tenant> Tenants { get; set; }
+
+ public string DisplayName() {
+ if (FirstName.HasValue() && LastName.HasValue()) return FirstName + " " + LastName;
+ return FirstName.HasValue() ? FirstName : Email;
+ }
+
+ 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),
+ };
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Data/Dtos/TimeQueryDto.cs b/code/api/src/Data/Dtos/TimeQueryDto.cs
new file mode 100644
index 0000000..f734cb1
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Dtos/UserArchiveDto.cs b/code/api/src/Data/Dtos/UserArchiveDto.cs
new file mode 100644
index 0000000..42e0600
--- /dev/null
+++ b/code/api/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 = AppDateTime.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/code/api/src/Data/Enums/TimeEntryQueryDuration.cs b/code/api/src/Data/Enums/TimeEntryQueryDuration.cs
new file mode 100644
index 0000000..af70ca6
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs b/code/api/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs
new file mode 100644
index 0000000..02474b4
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Exceptions/UserNotFoundException.cs b/code/api/src/Data/Exceptions/UserNotFoundException.cs
new file mode 100644
index 0000000..06b57a9
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Models/ApiSpecDocument.cs b/code/api/src/Data/Models/ApiSpecDocument.cs
new file mode 100644
index 0000000..1c7d936
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Models/AppPath.cs b/code/api/src/Data/Models/AppPath.cs
new file mode 100644
index 0000000..e47e48c
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Models/LoggedInUserModel.cs b/code/api/src/Data/Models/LoggedInUserModel.cs
new file mode 100644
index 0000000..d802b77
--- /dev/null
+++ b/code/api/src/Data/Models/LoggedInUserModel.cs
@@ -0,0 +1,7 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public class LoggedInUserModel
+{
+ public Guid Id { get; set; }
+ public string Username { get; set; }
+}
diff --git a/code/api/src/Data/Results/ErrorResult.cs b/code/api/src/Data/Results/ErrorResult.cs
new file mode 100644
index 0000000..fd2fd6a
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Static/AppClaims.cs b/code/api/src/Data/Static/AppClaims.cs
new file mode 100644
index 0000000..8b6d3a8
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Static/AppConfiguration.cs b/code/api/src/Data/Static/AppConfiguration.cs
new file mode 100644
index 0000000..4ee7a8e
--- /dev/null
+++ b/code/api/src/Data/Static/AppConfiguration.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography.X509Certificates;
+
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public class AppConfiguration
+{
+ public string DB_HOST { get; set; }
+ public string DB_PORT { get; set; }
+ public string DB_USER { get; set; }
+ public string DB_PASSWORD { get; set; }
+ public string DB_NAME { get; set; }
+ public string QUARTZ_DB_HOST { get; set; }
+ public string QUARTZ_DB_PORT { get; set; }
+ public string QUARTZ_DB_USER { get; set; }
+ public string QUARTZ_DB_PASSWORD { get; set; }
+ public string QUARTZ_DB_NAME { get; set; }
+ public string SEQ_API_KEY { get; set; }
+ public string SEQ_API_URL { get; set; }
+ public string SMTP_HOST { get; set; }
+ public string SMTP_PORT { get; set; }
+ public string SMTP_USER { get; set; }
+ public string SMTP_PASSWORD { get; set; }
+ public string EMAIL_FROM_ADDRESS { get; set; }
+ public string EMAIL_FROM_DISPLAY_NAME { get; set; }
+ public string PORTAL_URL { get; set; }
+ public string GITHUB_CLIENT_ID { get; set; }
+ public string GITHUB_CLIENT_SECRET { get; set; }
+ public string APP_AES_KEY { get; set; }
+ public string APP_CERT { get; set; }
+
+ public X509Certificate2 CERT1() => new (Convert.FromBase64String(APP_CERT));
+
+ public object GetPublicVersion() {
+ return new {
+ DB_HOST,
+ DB_PORT,
+ DB_USER,
+ DB_PASSWORD = DB_PASSWORD.Obfuscate() ?? "",
+ QUARTZ_DB_HOST,
+ QUARTZ_DB_PORT,
+ QUARTZ_DB_USER,
+ QUARTZ_DB_PASSWORD = QUARTZ_DB_PASSWORD.Obfuscate() ?? "",
+ SEQ_API_KEY = SEQ_API_KEY.Obfuscate() ?? "",
+ SEQ_API_URL,
+ SMTP_HOST,
+ SMTP_PORT,
+ SMTP_USER = SMTP_USER.Obfuscate() ?? "",
+ SMTP_PASSWORD = SMTP_PASSWORD.Obfuscate() ?? "",
+ EMAIL_FROM_ADDRESS,
+ EMAIL_FROM_DISPLAY_NAME,
+ PORTAL_URL,
+ GITHUB_CLIENT_ID = GITHUB_CLIENT_ID.Obfuscate() ?? "",
+ GITHUB_CLIENT_SECRET = GITHUB_CLIENT_SECRET.Obfuscate() ?? "",
+ APP_AES_KEY = APP_AES_KEY.Obfuscate() ?? "",
+ CERT1 = CERT1().PublicKey.Oid.FriendlyName
+ };
+ }
+}
diff --git a/code/api/src/Data/Static/AppConstants.cs b/code/api/src/Data/Static/AppConstants.cs
new file mode 100644
index 0000000..461317b
--- /dev/null
+++ b/code/api/src/Data/Static/AppConstants.cs
@@ -0,0 +1,12 @@
+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";
+ public const string VAULT_CACHE_KEY = "VAULT_CACHE_KEY";
+}
diff --git a/code/api/src/Data/Static/AppDateTime.cs b/code/api/src/Data/Static/AppDateTime.cs
new file mode 100644
index 0000000..880d2a8
--- /dev/null
+++ b/code/api/src/Data/Static/AppDateTime.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppDateTime
+{
+ private static DateTime? dateTime;
+
+ public static DateTime UtcNow => dateTime ?? DateTime.UtcNow;
+
+ public static void Set(DateTime setDateTime) {
+ dateTime = setDateTime;
+ }
+
+ public static void Reset() {
+ dateTime = null;
+ }
+}
diff --git a/code/api/src/Data/Static/AppEnvironmentVariables.cs b/code/api/src/Data/Static/AppEnvironmentVariables.cs
new file mode 100644
index 0000000..c3f821d
--- /dev/null
+++ b/code/api/src/Data/Static/AppEnvironmentVariables.cs
@@ -0,0 +1,21 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppEnvironmentVariables
+{
+ /// <summary>
+ /// An access token that can be used to access the Hashicorp Vault instance that is available at VAULT_URL
+ /// </summary>
+ public const string VAULT_TOKEN = "VAULT_TOKEN";
+ /// <summary>
+ /// An url pointing to the Hashicorp Vault instance the app should use
+ /// </summary>
+ public const string VAULT_URL = "VAULT_URL";
+ /// <summary>
+ /// The duration of which to keep a local cached version of the configuration
+ /// </summary>
+ public const string VAULT_CACHE_TTL = "VAULT_CACHE_TTL";
+ /// <summary>
+ /// The vault key name for the main configuration json object, described by <see cref="AppConfiguration"/>
+ /// </summary>
+ public const string MAIN_CONFIG_SHEET = "MAIN_CONFIG_SHEET";
+}
diff --git a/code/api/src/Data/Static/AppHeaders.cs b/code/api/src/Data/Static/AppHeaders.cs
new file mode 100644
index 0000000..7912418
--- /dev/null
+++ b/code/api/src/Data/Static/AppHeaders.cs
@@ -0,0 +1,7 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppHeaders
+{
+ public const string BROWSER_TIME_ZONE = "X-TimeZone";
+ public const string VAULT_TOKEN = "X-Vault-Token";
+}
diff --git a/code/api/src/Data/Static/AppPaths.cs b/code/api/src/Data/Static/AppPaths.cs
new file mode 100644
index 0000000..a24f5af
--- /dev/null
+++ b/code/api/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/code/api/src/Data/Static/JsonSettings.cs b/code/api/src/Data/Static/JsonSettings.cs
new file mode 100644
index 0000000..a163c11
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/CreateAccountPayload.cs b/code/api/src/Endpoints/Internal/Account/CreateAccountPayload.cs
new file mode 100644
index 0000000..dc73e68
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/CreateAccountRoute.cs
new file mode 100644
index 0000000..954fbf5
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
new file mode 100644
index 0000000..13fbdf4
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
new file mode 100644
index 0000000..2149e15
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/GetArchiveRoute.cs b/code/api/src/Endpoints/Internal/Account/GetArchiveRoute.cs
new file mode 100644
index 0000000..f1b70f3
--- /dev/null
+++ b/code/api/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.UserId == 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-" + AppDateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json");
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/Account/GetRoute.cs b/code/api/src/Endpoints/Internal/Account/GetRoute.cs
new file mode 100644
index 0000000..1aa7ecb
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Account/GetRoute.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult<LoggedInUserModel>
+{
+ private readonly AppDbContext _context;
+
+ 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
+ .Select(x => new {x.Username, x.Id})
+ .SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user != default) {
+ return Ok(new LoggedInUserModel {
+ Id = LoggedInUser.Id,
+ Username = LoggedInUser.Username
+ });
+ }
+
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/Account/LoginPayload.cs b/code/api/src/Endpoints/Internal/Account/LoginPayload.cs
new file mode 100644
index 0000000..807662c
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/LoginRoute.cs b/code/api/src/Endpoints/Internal/Account/LoginRoute.cs
new file mode 100644
index 0000000..5b41c61
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/LogoutRoute.cs b/code/api/src/Endpoints/Internal/Account/LogoutRoute.cs
new file mode 100644
index 0000000..4a06f4a
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/UpdateAccountPayload.cs b/code/api/src/Endpoints/Internal/Account/UpdateAccountPayload.cs
new file mode 100644
index 0000000..88a3237
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/code/api/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
new file mode 100644
index 0000000..a997dcb
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/BaseRoute.cs b/code/api/src/Endpoints/Internal/BaseRoute.cs
new file mode 100644
index 0000000..3e2c6af
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
new file mode 100644
index 0000000..8fbc9a0
--- /dev/null
+++ b/code/api/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 PasswordResetService _passwordResetService;
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, PasswordResetService passwordResetService, AppDbContext context) {
+ _logger = logger;
+ _passwordResetService = passwordResetService;
+ _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(AppDateTime.UtcNow)) {
+ offset++;
+ }
+
+ _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offset + " hours");
+ var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(AppDateTime.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 _passwordResetService.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/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs
new file mode 100644
index 0000000..f0fb59f
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
new file mode 100644
index 0000000..96f344a
--- /dev/null
+++ b/code/api/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 PasswordResetService _passwordResetService;
+
+ /// <inheritdoc />
+ public FulfillResetRequestRoute(PasswordResetService passwordResetService) {
+ _passwordResetService = passwordResetService;
+ }
+
+ /// <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 _passwordResetService.FullFillRequestAsync(request.Id, request.NewPassword, cancellationToken);
+ return Ok(fulfilled);
+ } catch (Exception e) {
+ if (e is ForgotPasswordRequestNotFoundException or UserNotFoundException) {
+ return NotFound();
+ }
+
+ throw;
+ }
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/code/api/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
new file mode 100644
index 0000000..c4dcd22
--- /dev/null
+++ b/code/api/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 PasswordResetService _passwordResetService;
+
+ /// <inheritdoc />
+ public IsResetRequestValidRoute(PasswordResetService passwordResetService) {
+ _passwordResetService = passwordResetService;
+ }
+
+ /// <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 _passwordResetService.GetRequestAsync(id, cancellationToken);
+ if (request == default) {
+ return NotFound();
+ }
+
+ return Ok(request.IsExpired == false);
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs b/code/api/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs
new file mode 100644
index 0000000..5fb8213
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Root/LogRoute.cs b/code/api/src/Endpoints/Internal/Root/LogRoute.cs
new file mode 100644
index 0000000..48b497a
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs b/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs
new file mode 100644
index 0000000..e0dcca3
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/ReadConfigurationRoute.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class ReadConfigurationRoute : RouteBaseSync.WithoutRequest.WithActionResult
+{
+ private readonly VaultService _vaultService;
+
+ public ReadConfigurationRoute(VaultService vaultService) {
+ _vaultService = vaultService;
+ }
+
+ [AllowAnonymous]
+ [HttpGet("~/_/configuration")]
+ public override ActionResult Handle() {
+ var config = _vaultService.GetCurrentAppConfiguration();
+ return Content(JsonSerializer.Serialize(config.GetPublicVersion()), "application/json");
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs b/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs
new file mode 100644
index 0000000..4b1beec
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/RefreshConfigurationRoute.cs
@@ -0,0 +1,15 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class RefreshConfigurationRoute : RouteBaseSync.WithoutRequest.WithoutResult
+{
+ private readonly VaultService _vaultService;
+
+ public RefreshConfigurationRoute(VaultService vaultService) {
+ _vaultService = vaultService;
+ }
+
+ [HttpGet("~/_/refresh-configuration")]
+ public override void Handle() {
+ _vaultService.RefreshCurrentAppConfiguration();
+ }
+}
diff --git a/code/api/src/Endpoints/Internal/Root/ValidSessionRoute.cs b/code/api/src/Endpoints/Internal/Root/ValidSessionRoute.cs
new file mode 100644
index 0000000..f377ff6
--- /dev/null
+++ b/code/api/src/Endpoints/Internal/Root/ValidSessionRoute.cs
@@ -0,0 +1,10 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class ValidSessionRoute : RouteBaseSync.WithoutRequest.WithActionResult
+{
+ [Authorize]
+ [HttpGet("~/_/valid-session")]
+ public override ActionResult Handle() {
+ return Ok();
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Endpoints/Internal/RouteBaseAsync.cs b/code/api/src/Endpoints/Internal/RouteBaseAsync.cs
new file mode 100644
index 0000000..1bb0af0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/Internal/RouteBaseSync.cs b/code/api/src/Endpoints/Internal/RouteBaseSync.cs
new file mode 100644
index 0000000..173999d
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/ApiSpecV1.cs b/code/api/src/Endpoints/V1/ApiSpecV1.cs
new file mode 100644
index 0000000..e4f9cc9
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs b/code/api/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs
new file mode 100644
index 0000000..2086619
--- /dev/null
+++ b/code/api/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs
@@ -0,0 +1,57 @@
+using System.Text;
+
+namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens;
+
+public class CreateTokenRoute : RouteBaseSync.WithRequest<ApiAccessToken.ApiAccessTokenDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly AppConfiguration _configuration;
+ private readonly ILogger<CreateTokenRoute> _logger;
+
+ public CreateTokenRoute(AppDbContext context, VaultService vaultService, ILogger<CreateTokenRoute> logger)
+ {
+ _context = context;
+ _configuration = vaultService.GetCurrentAppConfiguration();
+ _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.APP_AES_KEY;
+ if (token_entropy.IsNullOrWhiteSpace())
+ {
+ _logger.LogWarning("No token entropy is available, 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/code/api/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs b/code/api/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs
new file mode 100644
index 0000000..a90b4c0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs b/code/api/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs
new file mode 100644
index 0000000..59fd077
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/BaseRoute.cs b/code/api/src/Endpoints/V1/BaseRoute.cs
new file mode 100644
index 0000000..e7d72ac
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Categories/CreateCategoryRoute.cs b/code/api/src/Endpoints/V1/Categories/CreateCategoryRoute.cs
new file mode 100644
index 0000000..fac2b5e
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs b/code/api/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs
new file mode 100644
index 0000000..3d438a0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Categories/GetCategoriesRoute.cs b/code/api/src/Endpoints/V1/Categories/GetCategoriesRoute.cs
new file mode 100644
index 0000000..a40a832
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs b/code/api/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs
new file mode 100644
index 0000000..ca7dfdf
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/CreateEntryRoute.cs b/code/api/src/Endpoints/V1/Entries/CreateEntryRoute.cs
new file mode 100644
index 0000000..362e430
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/DeleteEntryRoute.cs b/code/api/src/Endpoints/V1/Entries/DeleteEntryRoute.cs
new file mode 100644
index 0000000..0850af0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/EntryQueryPayload.cs b/code/api/src/Endpoints/V1/Entries/EntryQueryPayload.cs
new file mode 100644
index 0000000..763ac8b
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/EntryQueryResponse.cs b/code/api/src/Endpoints/V1/Entries/EntryQueryResponse.cs
new file mode 100644
index 0000000..b1b07a3
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/EntryQueryRoute.cs b/code/api/src/Endpoints/V1/Entries/EntryQueryRoute.cs
new file mode 100644
index 0000000..d431ac5
--- /dev/null
+++ b/code/api/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(AppDateTime.UtcNow)) {
+ offsetInHours++;
+ }
+
+ _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offsetInHours + " hours");
+ var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(AppDateTime.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, AppDateTime.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 = AppDateTime.UtcNow.StartOfWeek(DayOfWeek.Monday);
+
+ var baseEntriesThisWeek = baseQuery
+ .Where(c => c.Start.AddHours(offsetInHours).Date >= lastMonday.Date && c.Start.AddHours(offsetInHours).Date <= AppDateTime.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 == AppDateTime.UtcNow.Month
+ && c.Start.AddHours(offsetInHours).Year == AppDateTime.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 == AppDateTime.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/code/api/src/Endpoints/V1/Entries/GetEntryRoute.cs b/code/api/src/Endpoints/V1/Entries/GetEntryRoute.cs
new file mode 100644
index 0000000..87038db
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Entries/UpdateEntryRoute.cs b/code/api/src/Endpoints/V1/Entries/UpdateEntryRoute.cs
new file mode 100644
index 0000000..ac233e0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs b/code/api/src/Endpoints/V1/Labels/CreateLabelRoute.cs
new file mode 100644
index 0000000..31ef7d0
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Labels/DeleteLabelRoute.cs b/code/api/src/Endpoints/V1/Labels/DeleteLabelRoute.cs
new file mode 100644
index 0000000..d845a6f
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Labels/GetLabelRoute.cs b/code/api/src/Endpoints/V1/Labels/GetLabelRoute.cs
new file mode 100644
index 0000000..c9ccef3
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/Labels/UpdateLabelRoute.cs b/code/api/src/Endpoints/V1/Labels/UpdateLabelRoute.cs
new file mode 100644
index 0000000..30d72ec
--- /dev/null
+++ b/code/api/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.UserId) {
+ return Forbid();
+ }
+
+ label.Name = labelTimeLabelDto.Name;
+ label.Color = labelTimeLabelDto.Color;
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/code/api/src/Endpoints/V1/RouteBaseAsync.cs b/code/api/src/Endpoints/V1/RouteBaseAsync.cs
new file mode 100644
index 0000000..1d179f7
--- /dev/null
+++ b/code/api/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/code/api/src/Endpoints/V1/RouteBaseSync.cs b/code/api/src/Endpoints/V1/RouteBaseSync.cs
new file mode 100644
index 0000000..cb27c14
--- /dev/null
+++ b/code/api/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/code/api/src/IOL.GreatOffice.Api.csproj b/code/api/src/IOL.GreatOffice.Api.csproj
new file mode 100644
index 0000000..0b3d37e
--- /dev/null
+++ b/code/api/src/IOL.GreatOffice.Api.csproj
@@ -0,0 +1,56 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <UserSecretsId>ed5ff3e5-46e2-4d7e-8272-7081f5abfee4</UserSecretsId>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <ImplicitUsings>true</ImplicitUsings>
+ <Nullable>disable</Nullable>
+ <NoWarn>CS1591</NoWarn>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Duende.IdentityServer" Version="6.1.2" />
+ <PackageReference Include="Duende.IdentityServer.EntityFramework.Storage" Version="6.1.2" />
+ <PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
+ <PackageReference Include="IOL.Helpers" Version="3.1.0" />
+ <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="6.0.7" />
+ <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.7">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.6" />
+ <PackageReference Include="Quartz.Extensions.Hosting" Version="3.4.0" />
+ <PackageReference Include="Serilog.AspNetCore" Version="6.0.1" />
+ <PackageReference Include="Serilog.Expressions" Version="3.4.0" />
+ <PackageReference Include="Serilog.Sinks.Seq" Version="5.1.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
+ <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.4.0" />
+ <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.4.0" />
+ </ItemGroup>
+
+ <ItemGroup Condition="'$(Configuration)' == 'Release'">
+ <Content Remove="AppData" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Content Include="..\..\README.md">
+ <Link>README.md</Link>
+ </Content>
+ <Content Include="..\build_and_push.sh">
+ <Link>build_and_push.sh</Link>
+ </Content>
+ <Content Include="..\CHANGELOG.md">
+ <Link>CHANGELOG.md</Link>
+ </Content>
+ <Content Include="..\cliff.toml">
+ <Link>cliff.toml</Link>
+ </Content>
+ <Content Include="..\Dockerfile">
+ <Link>Dockerfile</Link>
+ </Content>
+ </ItemGroup>
+
+</Project>
diff --git a/code/api/src/Jobs/JobRegister.cs b/code/api/src/Jobs/JobRegister.cs
new file mode 100644
index 0000000..72c2cc7
--- /dev/null
+++ b/code/api/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/code/api/src/Jobs/TokenCleanupJob.cs b/code/api/src/Jobs/TokenCleanupJob.cs
new file mode 100644
index 0000000..fce40c9
--- /dev/null
+++ b/code/api/src/Jobs/TokenCleanupJob.cs
@@ -0,0 +1,22 @@
+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.ExpiryDate < AppDateTime.UtcNow).ToList();
+ if (staleTokens.IsNullOrEmpty()) return Task.CompletedTask;
+ _logger.LogInformation("Removing {0} stale tokens", staleTokens.Count());
+ _context.AccessTokens.RemoveRange(staleTokens);
+ return Task.CompletedTask;
+ }
+}
diff --git a/code/api/src/Jobs/VaultTokenRenewalJob.cs b/code/api/src/Jobs/VaultTokenRenewalJob.cs
new file mode 100644
index 0000000..fffbf7c
--- /dev/null
+++ b/code/api/src/Jobs/VaultTokenRenewalJob.cs
@@ -0,0 +1,15 @@
+using Quartz;
+
+namespace IOL.GreatOffice.Api.Jobs;
+
+public class VaultTokenRenewalJob : IJob
+{
+ private readonly ILogger<VaultTokenRenewalJob> _logger;
+ public VaultTokenRenewalJob(ILogger<VaultTokenRenewalJob> logger) {
+ _logger = logger;
+ }
+
+ public Task Execute(IJobExecutionContext context) {
+ return Task.CompletedTask;
+ }
+}
diff --git a/code/api/src/Migrations/20210517202115_InitialMigration.Designer.cs b/code/api/src/Migrations/20210517202115_InitialMigration.Designer.cs
new file mode 100644
index 0000000..b6a01ff
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20210517202115_InitialMigration.cs b/code/api/src/Migrations/20210517202115_InitialMigration.cs
new file mode 100644
index 0000000..8bfaf61
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs b/code/api/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs
new file mode 100644
index 0000000..368e6b3
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20210522165932_RenameNoteToDescription.cs b/code/api/src/Migrations/20210522165932_RenameNoteToDescription.cs
new file mode 100644
index 0000000..e5bae54
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20211002113037_V6Migration.Designer.cs b/code/api/src/Migrations/20211002113037_V6Migration.Designer.cs
new file mode 100644
index 0000000..59e6112
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20211002113037_V6Migration.cs b/code/api/src/Migrations/20211002113037_V6Migration.cs
new file mode 100644
index 0000000..c7ac971
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220225143559_GithubUserMappings.Designer.cs b/code/api/src/Migrations/20220225143559_GithubUserMappings.Designer.cs
new file mode 100644
index 0000000..2b95f9d
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220225143559_GithubUserMappings.cs b/code/api/src/Migrations/20220225143559_GithubUserMappings.cs
new file mode 100644
index 0000000..fc30c7a
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319135910_RenameCreated.Designer.cs b/code/api/src/Migrations/20220319135910_RenameCreated.Designer.cs
new file mode 100644
index 0000000..3d57f1a
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319135910_RenameCreated.cs b/code/api/src/Migrations/20220319135910_RenameCreated.cs
new file mode 100644
index 0000000..6571e50
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319144958_ModifiedAt.Designer.cs b/code/api/src/Migrations/20220319144958_ModifiedAt.Designer.cs
new file mode 100644
index 0000000..f75400e
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319144958_ModifiedAt.cs b/code/api/src/Migrations/20220319144958_ModifiedAt.cs
new file mode 100644
index 0000000..028473d
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319203018_UserBase.Designer.cs b/code/api/src/Migrations/20220319203018_UserBase.Designer.cs
new file mode 100644
index 0000000..6c7a76f
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220319203018_UserBase.cs b/code/api/src/Migrations/20220319203018_UserBase.cs
new file mode 100644
index 0000000..14d3f4b
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220320115601_Update1.Designer.cs b/code/api/src/Migrations/20220320115601_Update1.Designer.cs
new file mode 100644
index 0000000..c7463fb
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220320115601_Update1.cs b/code/api/src/Migrations/20220320115601_Update1.cs
new file mode 100644
index 0000000..8b06fb7
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs b/code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs
new file mode 100644
index 0000000..3a18463
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs b/code/api/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs
new file mode 100644
index 0000000..df7a195
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs b/code/api/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs
new file mode 100644
index 0000000..74f9b40
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220529190359_ApiAccessTokens.cs b/code/api/src/Migrations/20220529190359_ApiAccessTokens.cs
new file mode 100644
index 0000000..dc44bee
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220530174741_Tenants.Designer.cs b/code/api/src/Migrations/20220530174741_Tenants.Designer.cs
new file mode 100644
index 0000000..678c52d
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220530174741_Tenants.cs b/code/api/src/Migrations/20220530174741_Tenants.cs
new file mode 100644
index 0000000..ea02ddd
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs b/code/api/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs
new file mode 100644
index 0000000..8fd6b40
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220530175322_RemoveUnusedNavs.cs b/code/api/src/Migrations/20220530175322_RemoveUnusedNavs.cs
new file mode 100644
index 0000000..36b3cf1
--- /dev/null
+++ b/code/api/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/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.Designer.cs b/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.Designer.cs
new file mode 100644
index 0000000..a05b0e4
--- /dev/null
+++ b/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.Designer.cs
@@ -0,0 +1,656 @@
+// <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("20220602214238_NullableOptionalBaseFields")]
+ partial class NullableOptionalBaseFields
+ {
+ 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")
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .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")
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .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")
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .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")
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .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/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.cs b/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.cs
new file mode 100644
index 0000000..eebab5c
--- /dev/null
+++ b/code/api/src/Migrations/20220602214238_NullableOptionalBaseFields.cs
@@ -0,0 +1,649 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class NullableOptionalBaseFields : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants");
+
+ 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.AlterColumn<Guid>(
+ name: "tenant_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "tenant_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "tenant_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "tenant_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_tenants_tenant_id",
+ table: "time_entries",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_tenants_tenant_id",
+ table: "time_labels",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants");
+
+ 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.AlterColumn<Guid>(
+ name: "tenant_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: "modified_by_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: "deleted_by_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: "created_by_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: "tenant_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: "modified_by_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: "deleted_by_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: "created_by_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: "tenant_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "tenant_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "modified_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "deleted_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "created_by_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ 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);
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220606232346_FleshOutNewModules.Designer.cs b/code/api/src/Migrations/20220606232346_FleshOutNewModules.Designer.cs
new file mode 100644
index 0000000..69d4f7e
--- /dev/null
+++ b/code/api/src/Migrations/20220606232346_FleshOutNewModules.Designer.cs
@@ -0,0 +1,510 @@
+// <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("20220606232346_FleshOutNewModules")]
+ partial class FleshOutNewModules
+ {
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("FirstName")
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property<string>("LastName")
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ 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("TenantUser", b =>
+ {
+ b.Property<Guid>("TenantsId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenants_id");
+
+ b.Property<Guid>("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("TenantsId", "UsersId")
+ .HasName("pk_tenant_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_tenant_user_users_id");
+
+ b.ToTable("tenant_user", (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.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_tenants_tenants_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_users_users_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220606232346_FleshOutNewModules.cs b/code/api/src/Migrations/20220606232346_FleshOutNewModules.cs
new file mode 100644
index 0000000..49a36b8
--- /dev/null
+++ b/code/api/src/Migrations/20220606232346_FleshOutNewModules.cs
@@ -0,0 +1,630 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class FleshOutNewModules : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_tenants_tenant_id1",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_tenants_users_user_id",
+ table: "tenants");
+
+ 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_categories_users_user_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_entries_users_user_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.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ 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_labels_user_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_entries_user_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.DropIndex(
+ name: "ix_time_categories_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_tenants_created_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_tenants_deleted_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_tenants_modified_by_id",
+ table: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_tenants_tenant_id1",
+ table: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_tenants_user_id",
+ table: "tenants");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id1",
+ table: "tenants");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "users",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn<string>(
+ name: "email",
+ table: "users",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AddColumn<string>(
+ name: "first_name",
+ table: "users",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AddColumn<string>(
+ name: "last_name",
+ table: "users",
+ type: "text",
+ nullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "time_labels",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "time_entries",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "time_categories",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "tenants",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn<bool>(
+ name: "deleted",
+ table: "api_access_tokens",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.CreateTable(
+ name: "tenant_user",
+ columns: table => new
+ {
+ tenants_id = table.Column<Guid>(type: "uuid", nullable: false),
+ users_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_tenant_user", x => new { x.tenants_id, x.users_id });
+ table.ForeignKey(
+ name: "fk_tenant_user_tenants_tenants_id",
+ column: x => x.tenants_id,
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenant_user_users_users_id",
+ column: x => x.users_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenant_user_users_id",
+ table: "tenant_user",
+ column: "users_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "tenant_user");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "email",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "first_name",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "last_name",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "tenants");
+
+ migrationBuilder.DropColumn(
+ name: "deleted",
+ table: "api_access_tokens");
+
+ 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.AlterColumn<Guid>(
+ name: "user_id",
+ table: "tenants",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id1",
+ table: "tenants",
+ type: "uuid",
+ nullable: true);
+
+ 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_labels_user_id",
+ table: "time_labels",
+ column: "user_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_entries_user_id",
+ table: "time_entries",
+ column: "user_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_time_categories_user_id",
+ table: "time_categories",
+ column: "user_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_tenants_tenants_tenant_id1",
+ table: "tenants",
+ column: "tenant_id1",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ table: "tenants",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ table: "tenants",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ table: "tenants",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_tenants_users_user_id",
+ table: "tenants",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ 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_tenants_tenant_id",
+ table: "time_entries",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ 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_tenants_tenant_id",
+ table: "time_labels",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220616170311_DataProtectionKeys.Designer.cs b/code/api/src/Migrations/20220616170311_DataProtectionKeys.Designer.cs
new file mode 100644
index 0000000..b333f23
--- /dev/null
+++ b/code/api/src/Migrations/20220616170311_DataProtectionKeys.Designer.cs
@@ -0,0 +1,533 @@
+// <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("20220616170311_DataProtectionKeys")]
+ partial class DataProtectionKeys
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.6")
+ .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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("FirstName")
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property<string>("LastName")
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ 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("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+ b.Property<string>("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property<string>("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.Property<Guid>("TenantsId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenants_id");
+
+ b.Property<Guid>("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("TenantsId", "UsersId")
+ .HasName("pk_tenant_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_tenant_user_users_id");
+
+ b.ToTable("tenant_user", (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.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_tenants_tenants_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_users_users_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220616170311_DataProtectionKeys.cs b/code/api/src/Migrations/20220616170311_DataProtectionKeys.cs
new file mode 100644
index 0000000..bc3c673
--- /dev/null
+++ b/code/api/src/Migrations/20220616170311_DataProtectionKeys.cs
@@ -0,0 +1,33 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class DataProtectionKeys : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "data_protection_keys",
+ columns: table => new
+ {
+ id = table.Column<int>(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ friendly_name = table.Column<string>(type: "text", nullable: true),
+ xml = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_data_protection_keys", x => x.id);
+ });
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "data_protection_keys");
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220819203816_RemoveGithubUsers.Designer.cs b/code/api/src/Migrations/20220819203816_RemoveGithubUsers.Designer.cs
new file mode 100644
index 0000000..33b5cfd
--- /dev/null
+++ b/code/api/src/Migrations/20220819203816_RemoveGithubUsers.Designer.cs
@@ -0,0 +1,496 @@
+// <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("20220819203816_RemoveGithubUsers")]
+ partial class RemoveGithubUsers
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.7")
+ .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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("FirstName")
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property<string>("LastName")
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ 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("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+ b.Property<string>("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property<string>("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.Property<Guid>("TenantsId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenants_id");
+
+ b.Property<Guid>("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("TenantsId", "UsersId")
+ .HasName("pk_tenant_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_tenant_user_users_id");
+
+ b.ToTable("tenant_user", (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.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_tenants_tenants_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_users_users_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/code/api/src/Migrations/20220819203816_RemoveGithubUsers.cs b/code/api/src/Migrations/20220819203816_RemoveGithubUsers.cs
new file mode 100644
index 0000000..d301f67
--- /dev/null
+++ b/code/api/src/Migrations/20220819203816_RemoveGithubUsers.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RemoveGithubUsers : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "github_user_mappings");
+ }
+
+ protected override void Down(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");
+ }
+ }
+}
diff --git a/code/api/src/Migrations/AppDbContextModelSnapshot.cs b/code/api/src/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..cc4bf72
--- /dev/null
+++ b/code/api/src/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,494 @@
+// <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.7")
+ .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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ 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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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.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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ 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("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_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<bool>("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("FirstName")
+ .HasColumnType("text")
+ .HasColumnName("first_name");
+
+ b.Property<string>("LastName")
+ .HasColumnType("text")
+ .HasColumnName("last_name");
+
+ 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("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+ b.Property<string>("FriendlyName")
+ .HasColumnType("text")
+ .HasColumnName("friendly_name");
+
+ b.Property<string>("Xml")
+ .HasColumnType("text")
+ .HasColumnName("xml");
+
+ b.HasKey("Id")
+ .HasName("pk_data_protection_keys");
+
+ b.ToTable("data_protection_keys", (string)null);
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.Property<Guid>("TenantsId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenants_id");
+
+ b.Property<Guid>("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("TenantsId", "UsersId")
+ .HasName("pk_tenant_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_tenant_user_users_id");
+
+ b.ToTable("tenant_user", (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.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+ });
+
+ modelBuilder.Entity("TenantUser", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", null)
+ .WithMany()
+ .HasForeignKey("TenantsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_tenants_tenants_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenant_user_users_users_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/code/api/src/Program.cs b/code/api/src/Program.cs
new file mode 100644
index 0000000..9a6bc3f
--- /dev/null
+++ b/code/api/src/Program.cs
@@ -0,0 +1,236 @@
+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.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);
+ builder.Services.AddLogging();
+ builder.Services.AddHttpClient();
+ builder.Services.AddMemoryCache();
+ builder.Services.AddScoped<MailService>();
+ builder.Services.AddScoped<PasswordResetService>();
+ builder.Services.AddScoped<UserService>();
+ builder.Services.AddTransient<VaultService>();
+ var vaultService = builder.Services.BuildServiceProvider().GetRequiredService<VaultService>();
+ var configuration = vaultService.GetCurrentAppConfiguration();
+ var logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .ReadFrom.Configuration(builder.Configuration)
+ .WriteTo.Console();
+
+ if (!builder.Environment.IsDevelopment() && configuration.SEQ_API_KEY.HasValue() && configuration.SEQ_API_URL.HasValue()) {
+ logger.WriteTo.Seq(configuration.SEQ_API_URL, apiKey: configuration.SEQ_API_KEY);
+ }
+
+ Log.Logger = logger.CreateLogger();
+ Log.Information("Starting web host, "
+ + JsonSerializer.Serialize(configuration.GetPublicVersion(),
+ 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()
+ .ProtectKeysWithCertificate(configuration.CERT1())
+ .PersistKeysToDbContext<AppDbContext>();
+
+ builder.Services.Configure(JsonSettings.Default);
+ builder.Services.AddQuartz(options => {
+ options.UsePersistentStore(o => {
+ o.UsePostgres(builder.Configuration.GetQuartzDatabaseConnectionString(vaultService.GetCurrentAppConfiguration));
+ 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.HttpOnly = true;
+ options.Cookie.IsEssential = true;
+ options.SlidingExpiration = true;
+ options.Events.OnRedirectToAccessDenied =
+ options.Events.OnRedirectToLogin = c => {
+ c.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.FromResult<object>(null);
+ };
+ })
+ .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AppConstants.BASIC_AUTH_SCHEME, default);
+
+ builder.Services.AddDbContext<AppDbContext>(options => {
+ options.UseNpgsql(builder.Configuration.GetAppDatabaseConnectionString(vaultService.GetCurrentAppConfiguration),
+ 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
+ .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.SetIsOriginAllowed((origin) => true);
+ 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/code/api/src/Properties/launchSettings.json b/code/api/src/Properties/launchSettings.json
new file mode 100644
index 0000000..ebd333b
--- /dev/null
+++ b/code/api/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": "http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/code/api/src/Services/MailService.cs b/code/api/src/Services/MailService.cs
new file mode 100644
index 0000000..c08cb84
--- /dev/null
+++ b/code/api/src/Services/MailService.cs
@@ -0,0 +1,49 @@
+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;
+
+ public MailService(VaultService vaultService, ILogger<MailService> logger) {
+ var configuration = vaultService.GetCurrentAppConfiguration();
+ _logger = logger;
+ _emailHost = configuration.SMTP_HOST;
+ _emailPort = Convert.ToInt32(configuration.SMTP_PORT);
+ _emailUser = configuration.SMTP_USER;
+ _emailPassword = configuration.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 {
+ smtpClient.Host,
+ smtpClient.EnableSsl,
+ 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);
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Services/PasswordResetService.cs b/code/api/src/Services/PasswordResetService.cs
new file mode 100644
index 0000000..1b4f147
--- /dev/null
+++ b/code/api/src/Services/PasswordResetService.cs
@@ -0,0 +1,115 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class PasswordResetService
+{
+ private readonly AppDbContext _context;
+ private readonly MailService _mailService;
+ private readonly AppConfiguration _configuration;
+ private readonly ILogger<PasswordResetService> _logger;
+
+
+ public PasswordResetService(
+ AppDbContext context,
+ VaultService vaultService,
+ ILogger<PasswordResetService> logger,
+ MailService mailService
+ ) {
+ _context = context;
+ _configuration = vaultService.GetCurrentAppConfiguration();
+ _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 password reset request for user: {request.User.Username}, expires at {request.ExpirationDate} (in {request.ExpirationDate.Subtract(AppDateTime.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 password reset 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 portalUrl = _configuration.PORTAL_URL;
+ var emailFromAddress = _configuration.EMAIL_FROM_ADDRESS;
+ var emailFromDisplayName = _configuration.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 = "Reset password - Greatoffice",
+ Body = @$"
+Hi {user.Username}
+
+Go to the following link to set a new password.
+
+{portalUrl}/reset-password/{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 password reset request for user: {request.User.Username}, expires in {request.ExpirationDate.Subtract(AppDateTime.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} password reset requests for user: {userId}.");
+ }
+
+
+ public async Task DeleteStaleRequestsAsync(CancellationToken cancellationToken = default) {
+ var deleteCount = 0;
+ foreach (var request in _context.ForgotPasswordRequests.Where(c => c.IsExpired)) {
+ if (!request.IsExpired) {
+ continue;
+ }
+
+ _context.ForgotPasswordRequests.Remove(request);
+ deleteCount++;
+ _logger.LogInformation($"Marking password reset request with id: {request.Id} for deletion, expiration date was {request.ExpirationDate}.");
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Deleted {deleteCount} stale password reset requests.");
+ }
+} \ No newline at end of file
diff --git a/code/api/src/Services/UserService.cs b/code/api/src/Services/UserService.cs
new file mode 100644
index 0000000..6db663a
--- /dev/null
+++ b/code/api/src/Services/UserService.cs
@@ -0,0 +1,50 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class UserService
+{
+ private readonly PasswordResetService _passwordResetService;
+
+ /// <summary>
+ /// Provides methods to perform common operations on user data.
+ /// </summary>
+ /// <param name="passwordResetService"></param>
+ public UserService(PasswordResetService passwordResetService) {
+ _passwordResetService = passwordResetService;
+ }
+
+ /// <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 _passwordResetService.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/code/api/src/Services/VaultService.cs b/code/api/src/Services/VaultService.cs
new file mode 100644
index 0000000..732911a
--- /dev/null
+++ b/code/api/src/Services/VaultService.cs
@@ -0,0 +1,158 @@
+using Microsoft.Extensions.Caching.Memory;
+
+namespace IOL.GreatOffice.Api.Services;
+
+public class VaultService
+{
+ private readonly HttpClient _client;
+ private readonly IMemoryCache _cache;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<VaultService> _logger;
+ private int CACHE_TTL { get; set; }
+
+ public VaultService(HttpClient client, IConfiguration configuration, IMemoryCache cache, ILogger<VaultService> logger) {
+ var token = configuration.GetValue<string>(AppEnvironmentVariables.VAULT_TOKEN);
+ var vaultUrl = configuration.GetValue<string>(AppEnvironmentVariables.VAULT_URL);
+ CACHE_TTL = configuration.GetValue(AppEnvironmentVariables.VAULT_CACHE_TTL, 60 * 60 * 12);
+ if (token.IsNullOrWhiteSpace()) throw new ApplicationException("VAULT_TOKEN is empty");
+ if (vaultUrl.IsNullOrWhiteSpace()) throw new ApplicationException("VAULT_URL is empty");
+ client.DefaultRequestHeaders.Add("X-Vault-Token", token);
+ client.BaseAddress = new Uri(vaultUrl);
+ _client = client;
+ _cache = cache;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ public static object Data { get; set; }
+
+ public T Get<T>(string path) {
+ var result = _cache.GetOrCreate(AppConstants.VAULT_CACHE_KEY,
+ cacheEntry => {
+ cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(CACHE_TTL);
+ var getSecretResponse = _client.GetFromJsonAsync<GetSecretResponse<T>>("/v1/kv/data/" + path).Result;
+
+ if (getSecretResponse == null) {
+ return default;
+ }
+
+ Log.Debug("Setting new Vault cache, "
+ + new {
+ PATH = path,
+ CACHE_TTL,
+ Data = JsonSerializer.Serialize(getSecretResponse.Data.Data)
+ });
+ return getSecretResponse.Data.Data ?? default;
+ });
+ return result;
+ }
+
+ public T Refresh<T>(string path) {
+ _cache.Remove(AppConstants.VAULT_CACHE_KEY);
+ CACHE_TTL = _configuration.GetValue(AppEnvironmentVariables.VAULT_CACHE_TTL, 60 * 60 * 12);
+ return Get<T>(path);
+ }
+
+ public async Task<RenewTokenResponse> RenewTokenAsync<T>(string token) {
+ var response = await _client.PostAsJsonAsync("v1/auth/token/renew",
+ new {
+ Token = token
+ });
+ if (response.IsSuccessStatusCode) {
+ return await response.Content.ReadFromJsonAsync<RenewTokenResponse>();
+ }
+
+ return default;
+ }
+
+ public AppConfiguration GetCurrentAppConfiguration() {
+ var path = _configuration.GetValue<string>(AppEnvironmentVariables.MAIN_CONFIG_SHEET);
+ var result = Get<AppConfiguration>(path);
+ var overwrites = new {
+ DB_HOST = _configuration.GetValue("OVERWRITE_DB_HOST", string.Empty),
+ DB_PORT = _configuration.GetValue("OVERWRITE_DB_PORT", string.Empty),
+ DB_USER = _configuration.GetValue("OVERWRITE_DB_USER", string.Empty),
+ DB_PASSWORD = _configuration.GetValue("OVERWRITE_DB_PASSWORD", string.Empty),
+ DB_NAME = _configuration.GetValue("OVERWRITE_DB_NAME", string.Empty),
+ };
+ if (overwrites.DB_HOST.HasValue()) {
+ _logger.LogInformation("OVERWRITE_DB_HOST is specified, using it's value: " + overwrites.DB_HOST);
+ result.DB_HOST = overwrites.DB_HOST;
+ }
+
+ if (overwrites.DB_PORT.HasValue()) {
+ _logger.LogInformation("OVERWRITE_DB_PORT is specified, using it's value: " + overwrites.DB_PORT);
+ result.DB_PORT = overwrites.DB_PORT;
+ }
+
+ if (overwrites.DB_USER.HasValue()) {
+ _logger.LogInformation("OVERWRITE_DB_USER is specified, using it's value: " + overwrites.DB_USER);
+ result.DB_USER = overwrites.DB_USER;
+ }
+
+ if (overwrites.DB_PASSWORD.HasValue()) {
+ _logger.LogInformation("OVERWRITE_DB_PASSWORD is specified, using it's value: " + "(redacted)");
+ result.DB_PASSWORD = overwrites.DB_PASSWORD;
+ }
+
+ if (overwrites.DB_NAME.HasValue()) {
+ _logger.LogInformation("OVERWRITE_DB_NAME is specified, using it's value: " + overwrites.DB_NAME);
+ result.DB_NAME = overwrites.DB_NAME;
+ }
+
+ return result;
+ }
+
+ public AppConfiguration RefreshCurrentAppConfiguration() {
+ var path = _configuration.GetValue<string>(AppEnvironmentVariables.MAIN_CONFIG_SHEET);
+ return Refresh<AppConfiguration>(path);
+ }
+
+ public class RenewTokenResponse
+ {
+ public Guid RequestId { get; set; }
+ public string LeaseId { get; set; }
+ public bool Renewable { get; set; }
+ public long LeaseDuration { get; set; }
+ public object Data { get; set; }
+ public object WrapInfo { get; set; }
+ public List<string> Warnings { get; set; }
+ public Auth Auth { get; set; }
+ }
+
+ public class Auth
+ {
+ public string ClientToken { get; set; }
+ public string Accessor { get; set; }
+ public List<string> Policies { get; set; }
+ public List<string> TokenPolicies { get; set; }
+ public object Metadata { get; set; }
+ public long LeaseDuration { get; set; }
+ public bool Renewable { get; set; }
+ public string EntityId { get; set; }
+ public string TokenType { get; set; }
+ public bool Orphan { get; set; }
+ public object MfaRequirement { get; set; }
+ public long NumUses { get; set; }
+ }
+
+ public class GetSecretResponse<T>
+ {
+ public VaultSecret<T> Data { get; set; }
+ }
+
+ public class VaultSecret<T>
+ {
+ public T Data { get; set; }
+ public VaultSecretMetadata Metadata { get; set; }
+ }
+
+ public class VaultSecretMetadata
+ {
+ public DateTimeOffset CreatedTime { get; set; }
+ public object CustomMetadata { get; set; }
+ public string DeletionTime { get; set; }
+ public bool Destroyed { get; set; }
+ public long Version { get; set; }
+ }
+}
diff --git a/code/api/src/Utilities/BasicAuthenticationAttribute.cs b/code/api/src/Utilities/BasicAuthenticationAttribute.cs
new file mode 100644
index 0000000..0bfd007
--- /dev/null
+++ b/code/api/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/code/api/src/Utilities/BasicAuthenticationHandler.cs b/code/api/src/Utilities/BasicAuthenticationHandler.cs
new file mode 100644
index 0000000..6138193
--- /dev/null
+++ b/code/api/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 AppConfiguration _configuration;
+ private readonly ILogger<BasicAuthenticationHandler> _logger;
+
+ public BasicAuthenticationHandler(
+ IOptionsMonitor<AuthenticationSchemeOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock,
+ AppDbContext context,
+ VaultService vaultService
+ ) :
+ base(options, logger, encoder, clock) {
+ _context = context;
+ _configuration = vaultService.GetCurrentAppConfiguration();
+ _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 tokenEntropy = _configuration.APP_AES_KEY;
+ if (tokenEntropy.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 decryptedString = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(tokenEntropy);
+ var tokenIsGuid = Guid.TryParse(decryptedString, out var tokenId);
+
+ if (!tokenIsGuid) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
+ var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == tokenId);
+ 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/code/api/src/Utilities/ConfigurationExtensions.cs b/code/api/src/Utilities/ConfigurationExtensions.cs
new file mode 100644
index 0000000..405c702
--- /dev/null
+++ b/code/api/src/Utilities/ConfigurationExtensions.cs
@@ -0,0 +1,88 @@
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class ConfigurationExtensions
+{
+ public static string GetAppDatabaseConnectionString(this IConfiguration config, AppConfiguration configuration) {
+ var host = configuration.DB_HOST;
+ var port = configuration.DB_PORT;
+ var database = configuration.DB_NAME;
+ var user = configuration.DB_USER;
+ var password = configuration.DB_PASSWORD;
+
+ var res = "";
+ if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ } else {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ Log.Debug("Using app database connection string: " + res);
+ return res;
+ }
+
+ public static string GetAppDatabaseConnectionString(this IConfiguration config, Func<AppConfiguration> configuration) {
+ var _configuration = configuration();
+ var host = _configuration.DB_HOST;
+ var port = _configuration.DB_PORT;
+ var database = _configuration.DB_NAME;
+ var user = _configuration.DB_USER;
+ var password = _configuration.DB_PASSWORD;
+
+ var res = "";
+ if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ } else {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ Log.Debug("Using app database connection string: " + res);
+ return res;
+ }
+
+ public static string GetQuartzDatabaseConnectionString(this IConfiguration config, AppConfiguration configuration) {
+ var host = configuration.QUARTZ_DB_HOST;
+ var port = configuration.QUARTZ_DB_PORT;
+ var database = configuration.QUARTZ_DB_NAME;
+ var user = configuration.QUARTZ_DB_USER;
+ var password = configuration.QUARTZ_DB_PASSWORD;
+
+ var res = "";
+ if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ } else {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ Log.Debug("Using quartz database connection string: " + res);
+ return res;
+ }
+
+ public static string GetQuartzDatabaseConnectionString(this IConfiguration config, Func<AppConfiguration> configuration) {
+ var _configuration = configuration();
+ var host = _configuration.QUARTZ_DB_HOST;
+ var port = _configuration.QUARTZ_DB_PORT;
+ var database = _configuration.QUARTZ_DB_NAME;
+ var user = _configuration.QUARTZ_DB_USER;
+ var password = _configuration.QUARTZ_DB_PASSWORD;
+
+ var res = "";
+ if (config.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ } else {
+ res = $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ Log.Debug("Using quartz database connection string: " + res);
+ return res;
+ }
+
+ 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/code/api/src/Utilities/GithubAuthenticationHelpers.cs b/code/api/src/Utilities/GithubAuthenticationHelpers.cs
new file mode 100644
index 0000000..a4461d2
--- /dev/null
+++ b/code/api/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, AppConfiguration options) {
+ 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(options);
+ 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}', '', '{AppDateTime.UtcNow}')";
+ await connection.OpenAsync();
+ await using var insertUserCommand = new NpgsqlCommand(insertUserQuery, connection);
+ await insertUserCommand.ExecuteNonQueryAsync();
+ await connection.CloseAsync();
+
+ var refreshTokenEncryptionKey = options.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/code/api/src/Utilities/QuartzJsonSerializer.cs b/code/api/src/Utilities/QuartzJsonSerializer.cs
new file mode 100644
index 0000000..164a189
--- /dev/null
+++ b/code/api/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/code/api/src/Utilities/SwaggerDefaultValues.cs b/code/api/src/Utilities/SwaggerDefaultValues.cs
new file mode 100644
index 0000000..4b5c764
--- /dev/null
+++ b/code/api/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/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs b/code/api/src/Utilities/SwaggerGenOptionsExtensions.cs
new file mode 100644
index 0000000..a2dcf7a
--- /dev/null
+++ b/code/api/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/code/api/src/appsettings.json b/code/api/src/appsettings.json
new file mode 100644
index 0000000..8727fd7
--- /dev/null
+++ b/code/api/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/code/api/src/wwwroot/version.txt b/code/api/src/wwwroot/version.txt
new file mode 100644
index 0000000..9cf68da
--- /dev/null
+++ b/code/api/src/wwwroot/version.txt
@@ -0,0 +1 @@
+v47-server-dev
diff --git a/code/app/.gitignore b/code/app/.gitignore
new file mode 100644
index 0000000..f4401a3
--- /dev/null
+++ b/code/app/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
diff --git a/code/app/.npmrc b/code/app/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/code/app/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/code/app/.typesafe-i18n.json b/code/app/.typesafe-i18n.json
new file mode 100644
index 0000000..a51035e
--- /dev/null
+++ b/code/app/.typesafe-i18n.json
@@ -0,0 +1,5 @@
+{
+ "adapter": "svelte",
+ "$schema": "https://unpkg.com/typesafe-i18n@5.14.0/schema/typesafe-i18n.json",
+ "outputPath": "src/lib/i18n"
+} \ No newline at end of file
diff --git a/code/app/package.json b/code/app/package.json
new file mode 100644
index 0000000..5eb0be7
--- /dev/null
+++ b/code/app/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "greatoffice-kit",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "npm-run-all --parallel vite typesafe-i18n",
+ "typesafe-i18n": "typesafe-i18n",
+ "vite": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "test": "playwright test"
+ },
+ "devDependencies": {
+ "@developermuch/dev-svelte-headlessui": "0.0.1",
+ "@playwright/test": "^1.26.1",
+ "@rgossiaux/svelte-headlessui": "^1.0.2",
+ "@sveltejs/adapter-node": "1.0.0-next.96",
+ "@sveltejs/kit": "1.0.0-next.507",
+ "@sveltestack/svelte-query": "^1.6.0",
+ "@tailwindcss/forms": "^0.5.3",
+ "@tanstack/svelte-table": "^8.5.15",
+ "@types/cookie": "^0.5.1",
+ "@types/js-cookie": "^3.0.2",
+ "autoprefixer": "^10.4.12",
+ "cookie": "^0.5.0",
+ "devalue": "^3.1.3",
+ "js-cookie": "^3.0.1",
+ "npm-run-all": "^4.1.5",
+ "pino": "^8.6.1",
+ "pino-pretty": "^9.1.0",
+ "postcss": "^8.4.17",
+ "postcss-load-config": "^4.0.1",
+ "svelte": "^3.50.1",
+ "svelte-check": "^2.9.1",
+ "svelte-preprocess": "^4.10.7",
+ "tailwindcss": "^3.1.8",
+ "temporal-polyfill": "^0.0.8",
+ "tslib": "^2.4.0",
+ "typesafe-i18n": "^5.14.0",
+ "typescript": "^4.8.4",
+ "vite": "^3.1.4"
+ }
+} \ No newline at end of file
diff --git a/code/app/playwright.config.ts b/code/app/playwright.config.ts
new file mode 100644
index 0000000..22c46d9
--- /dev/null
+++ b/code/app/playwright.config.ts
@@ -0,0 +1,10 @@
+import type { PlaywrightTestConfig } from '@playwright/test';
+
+const config: PlaywrightTestConfig = {
+ webServer: {
+ command: 'pnpm run build && pnpm run preview',
+ port: 4173
+ }
+};
+
+export default config;
diff --git a/code/app/pnpm-lock.yaml b/code/app/pnpm-lock.yaml
new file mode 100644
index 0000000..fa03ab4
--- /dev/null
+++ b/code/app/pnpm-lock.yaml
@@ -0,0 +1,2299 @@
+lockfileVersion: 5.4
+
+specifiers:
+ '@developermuch/dev-svelte-headlessui': 0.0.1
+ '@playwright/test': ^1.26.1
+ '@rgossiaux/svelte-headlessui': ^1.0.2
+ '@sveltejs/adapter-node': 1.0.0-next.96
+ '@sveltejs/kit': 1.0.0-next.507
+ '@sveltestack/svelte-query': ^1.6.0
+ '@tailwindcss/forms': ^0.5.3
+ '@tanstack/svelte-table': ^8.5.15
+ '@types/cookie': ^0.5.1
+ '@types/js-cookie': ^3.0.2
+ autoprefixer: ^10.4.12
+ cookie: ^0.5.0
+ devalue: ^3.1.3
+ js-cookie: ^3.0.1
+ npm-run-all: ^4.1.5
+ pino: ^8.6.1
+ pino-pretty: ^9.1.0
+ postcss: ^8.4.17
+ postcss-load-config: ^4.0.1
+ svelte: ^3.50.1
+ svelte-check: ^2.9.1
+ svelte-preprocess: ^4.10.7
+ tailwindcss: ^3.1.8
+ temporal-polyfill: ^0.0.8
+ tslib: ^2.4.0
+ typesafe-i18n: ^5.14.0
+ typescript: ^4.8.4
+ vite: ^3.1.4
+
+devDependencies:
+ '@developermuch/dev-svelte-headlessui': 0.0.1_svelte@3.50.1
+ '@playwright/test': 1.26.1
+ '@rgossiaux/svelte-headlessui': 1.0.2_svelte@3.50.1
+ '@sveltejs/adapter-node': 1.0.0-next.96
+ '@sveltejs/kit': 1.0.0-next.507_svelte@3.50.1+vite@3.1.4
+ '@sveltestack/svelte-query': 1.6.0
+ '@tailwindcss/forms': 0.5.3_tailwindcss@3.1.8
+ '@tanstack/svelte-table': 8.5.15_svelte@3.50.1
+ '@types/cookie': 0.5.1
+ '@types/js-cookie': 3.0.2
+ autoprefixer: 10.4.12_postcss@8.4.17
+ cookie: 0.5.0
+ devalue: 3.1.3
+ js-cookie: 3.0.1
+ npm-run-all: 4.1.5
+ pino: 8.6.1
+ pino-pretty: 9.1.0
+ postcss: 8.4.17
+ postcss-load-config: 4.0.1_postcss@8.4.17
+ svelte: 3.50.1
+ svelte-check: 2.9.1_ejhwqstqdwfnekvhsm3hus3z4i
+ svelte-preprocess: 4.10.7_or4gyn62tntw7ihg73nagmkdja
+ tailwindcss: 3.1.8_postcss@8.4.17
+ temporal-polyfill: 0.0.8
+ tslib: 2.4.0
+ typesafe-i18n: 5.14.0_typescript@4.8.4
+ typescript: 4.8.4
+ vite: 3.1.4
+
+packages:
+
+ /@developermuch/dev-svelte-headlessui/0.0.1_svelte@3.50.1:
+ resolution: {integrity: sha512-tfBlHliv75oQFRrC430nIsw+A8+iFmr5c2g0A+VTlVD3960nEL9jOE0LDHYKq6VhX5LnOLTFIZwVKC1DxFo0QA==}
+ peerDependencies:
+ svelte: ^3.44.0
+ dependencies:
+ svelte: 3.50.1
+ dev: true
+
+ /@esbuild/android-arm/0.15.10:
+ resolution: {integrity: sha512-FNONeQPy/ox+5NBkcSbYJxoXj9GWu8gVGJTVmUyoOCKQFDTrHVKgNSzChdNt0I8Aj/iKcsDf2r9BFwv+FSNUXg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-loong64/0.15.10:
+ resolution: {integrity: sha512-w0Ou3Z83LOYEkwaui2M8VwIp+nLi/NA60lBLMvaJ+vXVMcsARYdEzLNE7RSm4+lSg4zq4d7fAVuzk7PNQ5JFgg==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@jridgewell/resolve-uri/3.1.0:
+ resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
+ /@jridgewell/sourcemap-codec/1.4.14:
+ resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==}
+ dev: true
+
+ /@jridgewell/trace-mapping/0.3.15:
+ resolution: {integrity: sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==}
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.0
+ '@jridgewell/sourcemap-codec': 1.4.14
+ dev: true
+
+ /@nodelib/fs.scandir/2.1.5:
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+ dev: true
+
+ /@nodelib/fs.stat/2.0.5:
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /@nodelib/fs.walk/1.2.8:
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.13.0
+ dev: true
+
+ /@playwright/test/1.26.1:
+ resolution: {integrity: sha512-bNxyZASVt2adSZ9gbD7NCydzcb5JaI0OR9hc7s+nmPeH604gwp0zp17NNpwXY4c8nvuBGQQ9oGDx72LE+cUWvw==}
+ engines: {node: '>=14'}
+ hasBin: true
+ dependencies:
+ '@types/node': 18.8.0
+ playwright-core: 1.26.1
+ dev: true
+
+ /@polka/url/1.0.0-next.21:
+ resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
+ dev: true
+
+ /@rgossiaux/svelte-headlessui/1.0.2_svelte@3.50.1:
+ resolution: {integrity: sha512-sauopYTSivhzXe1kAvgawkhyYJcQlK8Li3p0d2OtcCIVprOzdbard5lbqWB4xHDv83zAobt2mR08oizO2poHLQ==}
+ peerDependencies:
+ svelte: ^3.44.0
+ dependencies:
+ svelte: 3.50.1
+ dev: true
+
+ /@rollup/plugin-commonjs/22.0.2_rollup@2.79.1:
+ resolution: {integrity: sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ rollup: ^2.68.0
+ dependencies:
+ '@rollup/pluginutils': 3.1.0_rollup@2.79.1
+ commondir: 1.0.1
+ estree-walker: 2.0.2
+ glob: 7.2.3
+ is-reference: 1.2.1
+ magic-string: 0.25.9
+ resolve: 1.22.1
+ rollup: 2.79.1
+ dev: true
+
+ /@rollup/plugin-json/4.1.0_rollup@2.79.1:
+ resolution: {integrity: sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==}
+ peerDependencies:
+ rollup: ^1.20.0 || ^2.0.0
+ dependencies:
+ '@rollup/pluginutils': 3.1.0_rollup@2.79.1
+ rollup: 2.79.1
+ dev: true
+
+ /@rollup/plugin-node-resolve/14.1.0_rollup@2.79.1:
+ resolution: {integrity: sha512-5G2niJroNCz/1zqwXtk0t9+twOSDlG00k1Wfd7bkbbXmwg8H8dvgHdIWAun53Ps/rckfvOC7scDBjuGFg5OaWw==}
+ engines: {node: '>= 10.0.0'}
+ peerDependencies:
+ rollup: ^2.78.0
+ dependencies:
+ '@rollup/pluginutils': 3.1.0_rollup@2.79.1
+ '@types/resolve': 1.17.1
+ deepmerge: 4.2.2
+ is-builtin-module: 3.2.0
+ is-module: 1.0.0
+ resolve: 1.22.1
+ rollup: 2.79.1
+ dev: true
+
+ /@rollup/pluginutils/3.1.0_rollup@2.79.1:
+ resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
+ engines: {node: '>= 8.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0
+ dependencies:
+ '@types/estree': 0.0.39
+ estree-walker: 1.0.1
+ picomatch: 2.3.1
+ rollup: 2.79.1
+ dev: true
+
+ /@rollup/pluginutils/4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /@sveltejs/adapter-node/1.0.0-next.96:
+ resolution: {integrity: sha512-tIHaRolUYy2PiHl4RUWaOsYxEjK5lN9501qzCzFbYr/uoLnZcnPGSXNJICwX0AX9AUkV6cvkZey6bLbUQcwH0Q==}
+ dependencies:
+ '@rollup/plugin-commonjs': 22.0.2_rollup@2.79.1
+ '@rollup/plugin-json': 4.1.0_rollup@2.79.1
+ '@rollup/plugin-node-resolve': 14.1.0_rollup@2.79.1
+ rollup: 2.79.1
+ dev: true
+
+ /@sveltejs/kit/1.0.0-next.507_svelte@3.50.1+vite@3.1.4:
+ resolution: {integrity: sha512-GAgFb1yLUVOYPWXIPxh8j0iEjUOVvN42Xgsqf6j6j1Sb2/f0m0bC1O7eVbc8NQjNZIvgGuN8yxai188iIYQt7w==}
+ engines: {node: '>=16.14'}
+ hasBin: true
+ requiresBuild: true
+ peerDependencies:
+ svelte: ^3.44.0
+ vite: ^3.1.0
+ dependencies:
+ '@sveltejs/vite-plugin-svelte': 1.0.8_svelte@3.50.1+vite@3.1.4
+ '@types/cookie': 0.5.1
+ cookie: 0.5.0
+ devalue: 3.1.3
+ kleur: 4.1.5
+ magic-string: 0.26.5
+ mime: 3.0.0
+ node-fetch: 3.2.10
+ sade: 1.8.1
+ set-cookie-parser: 2.5.1
+ sirv: 2.0.2
+ svelte: 3.50.1
+ tiny-glob: 0.2.9
+ undici: 5.10.0
+ vite: 3.1.4
+ transitivePeerDependencies:
+ - diff-match-patch
+ - supports-color
+ dev: true
+
+ /@sveltejs/vite-plugin-svelte/1.0.8_svelte@3.50.1+vite@3.1.4:
+ resolution: {integrity: sha512-1xkVTB4pm6zuign858FzVYE9Fdw9MQBOlxrdd85STV0NvTDmcofcRpcrK+zcIyT8SZ2dseHLu8hvDwzssF6RfA==}
+ engines: {node: ^14.18.0 || >= 16}
+ peerDependencies:
+ diff-match-patch: ^1.0.5
+ svelte: ^3.44.0
+ vite: ^3.0.0
+ peerDependenciesMeta:
+ diff-match-patch:
+ optional: true
+ dependencies:
+ '@rollup/pluginutils': 4.2.1
+ debug: 4.3.4
+ deepmerge: 4.2.2
+ kleur: 4.1.5
+ magic-string: 0.26.5
+ svelte: 3.50.1
+ svelte-hmr: 0.15.0_svelte@3.50.1
+ vite: 3.1.4
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@sveltestack/svelte-query/1.6.0:
+ resolution: {integrity: sha512-C0wWuh6av1zu3Pzwrg6EQmX3BhDZQ4gMAdYu6Tfv4bjbEZTB00uEDz52z92IZdONh+iUKuyo0xRZ2e16k2Xifg==}
+ peerDependencies:
+ broadcast-channel: ^4.5.0
+ peerDependenciesMeta:
+ broadcast-channel:
+ optional: true
+ dev: true
+
+ /@tailwindcss/forms/0.5.3_tailwindcss@3.1.8:
+ resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
+ peerDependencies:
+ tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1'
+ dependencies:
+ mini-svg-data-uri: 1.4.4
+ tailwindcss: 3.1.8_postcss@8.4.17
+ dev: true
+
+ /@tanstack/svelte-table/8.5.15_svelte@3.50.1:
+ resolution: {integrity: sha512-Rxisdm7kcH8I33DOAv17bsVELbMTqJcZks5PK3Khe9jtXZADXurTEM9RSgmm/HNdHPF0CHnp90eOwtdpIlp23Q==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ svelte: ^3.48.0
+ dependencies:
+ '@tanstack/table-core': 8.5.15
+ svelte: 3.50.1
+ dev: true
+
+ /@tanstack/table-core/8.5.15:
+ resolution: {integrity: sha512-k+BcCOAYD610Cij6p1BPyEqjMQjZIdAnVDoIUKVnA/tfHbF4JlDP7pKAftXPBxyyX5Z1yQPurPnOdEY007Snyg==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /@types/cookie/0.5.1:
+ resolution: {integrity: sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==}
+ dev: true
+
+ /@types/estree/0.0.39:
+ resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
+ dev: true
+
+ /@types/estree/1.0.0:
+ resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
+ dev: true
+
+ /@types/js-cookie/3.0.2:
+ resolution: {integrity: sha512-6+0ekgfusHftJNYpihfkMu8BWdeHs9EOJuGcSofErjstGPfPGEu9yTu4t460lTzzAMl2cM5zngQJqPMHbbnvYA==}
+ dev: true
+
+ /@types/node/18.8.0:
+ resolution: {integrity: sha512-u+h43R6U8xXDt2vzUaVP3VwjjLyOJk6uEciZS8OSyziUQGOwmk+l+4drxcsDboHXwyTaqS1INebghmWMRxq3LA==}
+ dev: true
+
+ /@types/pug/2.0.6:
+ resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==}
+ dev: true
+
+ /@types/resolve/1.17.1:
+ resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
+ dependencies:
+ '@types/node': 18.8.0
+ dev: true
+
+ /@types/sass/1.43.1:
+ resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==}
+ dependencies:
+ '@types/node': 18.8.0
+ dev: true
+
+ /abort-controller/3.0.0:
+ resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
+ engines: {node: '>=6.5'}
+ dependencies:
+ event-target-shim: 5.0.1
+ dev: true
+
+ /acorn-node/1.8.2:
+ resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==}
+ dependencies:
+ acorn: 7.4.1
+ acorn-walk: 7.2.0
+ xtend: 4.0.2
+ dev: true
+
+ /acorn-walk/7.2.0:
+ resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==}
+ engines: {node: '>=0.4.0'}
+ dev: true
+
+ /acorn/7.4.1:
+ resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+ dev: true
+
+ /ansi-styles/3.2.1:
+ resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
+ engines: {node: '>=4'}
+ dependencies:
+ color-convert: 1.9.3
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /arg/5.0.2:
+ resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ dev: true
+
+ /atomic-sleep/1.0.0:
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+ engines: {node: '>=8.0.0'}
+ dev: true
+
+ /autoprefixer/10.4.12_postcss@8.4.17:
+ resolution: {integrity: sha512-WrCGV9/b97Pa+jtwf5UGaRjgQIg7OK3D06GnoYoZNcG1Xb8Gt3EfuKjlhh9i/VtT16g6PYjZ69jdJ2g8FxSC4Q==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+ dependencies:
+ browserslist: 4.21.4
+ caniuse-lite: 1.0.30001414
+ fraction.js: 4.2.0
+ normalize-range: 0.1.2
+ picocolors: 1.0.0
+ postcss: 8.4.17
+ postcss-value-parser: 4.2.0
+ dev: true
+
+ /balanced-match/1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ dev: true
+
+ /base64-js/1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /brace-expansion/1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+ dev: true
+
+ /brace-expansion/2.0.1:
+ resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
+ dependencies:
+ balanced-match: 1.0.2
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /browserslist/4.21.4:
+ resolution: {integrity: sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+ dependencies:
+ caniuse-lite: 1.0.30001414
+ electron-to-chromium: 1.4.270
+ node-releases: 2.0.6
+ update-browserslist-db: 1.0.9_browserslist@4.21.4
+ dev: true
+
+ /buffer-crc32/0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+ dev: true
+
+ /buffer/6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+ dev: true
+
+ /builtin-modules/3.3.0:
+ resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /call-bind/1.0.2:
+ resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
+ dependencies:
+ function-bind: 1.1.1
+ get-intrinsic: 1.1.3
+ dev: true
+
+ /callsites/3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /camelcase-css/2.0.1:
+ resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
+ engines: {node: '>= 6'}
+ dev: true
+
+ /caniuse-lite/1.0.30001414:
+ resolution: {integrity: sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg==}
+ dev: true
+
+ /chalk/2.4.2:
+ resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
+ engines: {node: '>=4'}
+ dependencies:
+ ansi-styles: 3.2.1
+ escape-string-regexp: 1.0.5
+ supports-color: 5.5.0
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /color-convert/1.9.3:
+ resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
+ dependencies:
+ color-name: 1.1.3
+ dev: true
+
+ /color-name/1.1.3:
+ resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
+ dev: true
+
+ /color-name/1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ dev: true
+
+ /colorette/2.0.19:
+ resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==}
+ dev: true
+
+ /commondir/1.0.1:
+ resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
+ dev: true
+
+ /concat-map/0.0.1:
+ resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ dev: true
+
+ /cookie/0.5.0:
+ resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
+ engines: {node: '>= 0.6'}
+ dev: true
+
+ /cross-spawn/6.0.5:
+ resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==}
+ engines: {node: '>=4.8'}
+ dependencies:
+ nice-try: 1.0.5
+ path-key: 2.0.1
+ semver: 5.7.1
+ shebang-command: 1.2.0
+ which: 1.3.1
+ dev: true
+
+ /cssesc/3.0.0:
+ resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
+ engines: {node: '>=4'}
+ hasBin: true
+ dev: true
+
+ /data-uri-to-buffer/4.0.0:
+ resolution: {integrity: sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==}
+ engines: {node: '>= 12'}
+ dev: true
+
+ /dateformat/4.6.3:
+ resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==}
+ dev: true
+
+ /debug/4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.2
+ dev: true
+
+ /deepmerge/4.2.2:
+ resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /define-properties/1.1.4:
+ resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-property-descriptors: 1.0.0
+ object-keys: 1.1.1
+ dev: true
+
+ /defined/1.0.0:
+ resolution: {integrity: sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==}
+ dev: true
+
+ /detect-indent/6.1.0:
+ resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /detective/5.2.1:
+ resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==}
+ engines: {node: '>=0.8.0'}
+ hasBin: true
+ dependencies:
+ acorn-node: 1.8.2
+ defined: 1.0.0
+ minimist: 1.2.6
+ dev: true
+
+ /devalue/3.1.3:
+ resolution: {integrity: sha512-9KO89Cb+qjzf2CqdrH+NuLaqdk9GhDP5EhR4zlkR51dvuIaiqtlkDkGzLMShDemwUy21raSMdu+kpX8Enw3yGQ==}
+ dev: true
+
+ /didyoumean/1.2.2:
+ resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+ dev: true
+
+ /dlv/1.1.3:
+ resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ dev: true
+
+ /electron-to-chromium/1.4.270:
+ resolution: {integrity: sha512-KNhIzgLiJmDDC444dj9vEOpZEgsV96ult9Iff98Vanumn+ShJHd5se8aX6KeVxdc0YQeqdrezBZv89rleDbvSg==}
+ dev: true
+
+ /end-of-stream/1.4.4:
+ resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
+ dependencies:
+ once: 1.4.0
+ dev: true
+
+ /error-ex/1.3.2:
+ resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+ dependencies:
+ is-arrayish: 0.2.1
+ dev: true
+
+ /es-abstract/1.20.3:
+ resolution: {integrity: sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ es-to-primitive: 1.2.1
+ function-bind: 1.1.1
+ function.prototype.name: 1.1.5
+ get-intrinsic: 1.1.3
+ get-symbol-description: 1.0.0
+ has: 1.0.3
+ has-property-descriptors: 1.0.0
+ has-symbols: 1.0.3
+ internal-slot: 1.0.3
+ is-callable: 1.2.7
+ is-negative-zero: 2.0.2
+ is-regex: 1.1.4
+ is-shared-array-buffer: 1.0.2
+ is-string: 1.0.7
+ is-weakref: 1.0.2
+ object-inspect: 1.12.2
+ object-keys: 1.1.1
+ object.assign: 4.1.4
+ regexp.prototype.flags: 1.4.3
+ safe-regex-test: 1.0.0
+ string.prototype.trimend: 1.0.5
+ string.prototype.trimstart: 1.0.5
+ unbox-primitive: 1.0.2
+ dev: true
+
+ /es-to-primitive/1.2.1:
+ resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ is-callable: 1.2.7
+ is-date-object: 1.0.5
+ is-symbol: 1.0.4
+ dev: true
+
+ /es6-promise/3.3.1:
+ resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==}
+ dev: true
+
+ /esbuild-android-64/0.15.10:
+ resolution: {integrity: sha512-UI7krF8OYO1N7JYTgLT9ML5j4+45ra3amLZKx7LO3lmLt1Ibn8t3aZbX5Pu4BjWiqDuJ3m/hsvhPhK/5Y/YpnA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.15.10:
+ resolution: {integrity: sha512-EOt55D6xBk5O05AK8brXUbZmoFj4chM8u3riGflLa6ziEoVvNjRdD7Cnp82NHQGfSHgYR06XsPI8/sMuA/cUwg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.15.10:
+ resolution: {integrity: sha512-hbDJugTicqIm+WKZgp208d7FcXcaK8j2c0l+fqSJ3d2AzQAfjEYDRM3Z2oMeqSJ9uFxyj/muSACLdix7oTstRA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.15.10:
+ resolution: {integrity: sha512-M1t5+Kj4IgSbYmunf2BB6EKLkWUq+XlqaFRiGOk8bmBapu9bCDrxjf4kUnWn59Dka3I27EiuHBKd1rSO4osLFQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.15.10:
+ resolution: {integrity: sha512-KMBFMa7C8oc97nqDdoZwtDBX7gfpolkk6Bcmj6YFMrtCMVgoU/x2DI1p74DmYl7CSS6Ppa3xgemrLrr5IjIn0w==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.15.10:
+ resolution: {integrity: sha512-m2KNbuCX13yQqLlbSojFMHpewbn8wW5uDS6DxRpmaZKzyq8Dbsku6hHvh2U+BcLwWY4mpgXzFUoENEf7IcioGg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.15.10:
+ resolution: {integrity: sha512-guXrwSYFAvNkuQ39FNeV4sNkNms1bLlA5vF1H0cazZBOLdLFIny6BhT+TUbK/hdByMQhtWQ5jI9VAmPKbVPu1w==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.15.10:
+ resolution: {integrity: sha512-jd8XfaSJeucMpD63YNMO1JCrdJhckHWcMv6O233bL4l6ogQKQOxBYSRP/XLWP+6kVTu0obXovuckJDcA0DKtQA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.15.10:
+ resolution: {integrity: sha512-6N8vThLL/Lysy9y4Ex8XoLQAlbZKUyExCWyayGi2KgTBelKpPgj6RZnUaKri0dHNPGgReJriKVU6+KDGQwn10A==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.15.10:
+ resolution: {integrity: sha512-GByBi4fgkvZFTHFDYNftu1DQ1GzR23jws0oWyCfhnI7eMOe+wgwWrc78dbNk709Ivdr/evefm2PJiUBMiusS1A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.15.10:
+ resolution: {integrity: sha512-BxP+LbaGVGIdQNJUNF7qpYjEGWb0YyHVSKqYKrn+pTwH/SiHUxFyJYSP3pqkku61olQiSBnSmWZ+YUpj78Tw7Q==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.15.10:
+ resolution: {integrity: sha512-LoSQCd6498PmninNgqd/BR7z3Bsk/mabImBWuQ4wQgmQEeanzWd5BQU2aNi9mBURCLgyheuZS6Xhrw5luw3OkQ==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.15.10:
+ resolution: {integrity: sha512-Lrl9Cr2YROvPV4wmZ1/g48httE8z/5SCiXIyebiB5N8VT7pX3t6meI7TQVHw/wQpqP/AF4SksDuFImPTM7Z32Q==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.15.10:
+ resolution: {integrity: sha512-ReP+6q3eLVVP2lpRrvl5EodKX7EZ1bS1/z5j6hsluAlZP5aHhk6ghT6Cq3IANvvDdscMMCB4QEbI+AjtvoOFpA==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.15.10:
+ resolution: {integrity: sha512-iGDYtJCMCqldMskQ4eIV+QSS/CuT7xyy9i2/FjpKvxAuCzrESZXiA1L64YNj6/afuzfBe9i8m/uDkFHy257hTw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.15.10:
+ resolution: {integrity: sha512-ftMMIwHWrnrYnvuJQRJs/Smlcb28F9ICGde/P3FUTCgDDM0N7WA0o9uOR38f5Xe2/OhNCgkjNeb7QeaE3cyWkQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.15.10:
+ resolution: {integrity: sha512-mf7hBL9Uo2gcy2r3rUFMjVpTaGpFJJE5QTDDqUFf1632FxteYANffDZmKbqX0PfeQ2XjUDE604IcE7OJeoHiyg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.15.10:
+ resolution: {integrity: sha512-ttFVo+Cg8b5+qHmZHbEc8Vl17kCleHhLzgT8X04y8zudEApo0PxPg9Mz8Z2cKH1bCYlve1XL8LkyXGFjtUYeGg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.15.10:
+ resolution: {integrity: sha512-2H0gdsyHi5x+8lbng3hLbxDWR7mKHWh5BXZGKVG830KUmXOOWFE2YKJ4tHRkejRduOGDrBvHBriYsGtmTv3ntA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.15.10:
+ resolution: {integrity: sha512-S+th4F+F8VLsHLR0zrUcG+Et4hx0RKgK1eyHc08kztmLOES8BWwMiaGdoW9hiXuzznXQ0I/Fg904MNbr11Nktw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.15.10:
+ resolution: {integrity: sha512-N7wBhfJ/E5fzn/SpNgX+oW2RLRjwaL8Y0ezqNqhjD6w0H2p0rDuEz2FKZqpqLnO8DCaWumKe8dsC/ljvVSSxng==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ '@esbuild/android-arm': 0.15.10
+ '@esbuild/linux-loong64': 0.15.10
+ esbuild-android-64: 0.15.10
+ esbuild-android-arm64: 0.15.10
+ esbuild-darwin-64: 0.15.10
+ esbuild-darwin-arm64: 0.15.10
+ esbuild-freebsd-64: 0.15.10
+ esbuild-freebsd-arm64: 0.15.10
+ esbuild-linux-32: 0.15.10
+ esbuild-linux-64: 0.15.10
+ esbuild-linux-arm: 0.15.10
+ esbuild-linux-arm64: 0.15.10
+ esbuild-linux-mips64le: 0.15.10
+ esbuild-linux-ppc64le: 0.15.10
+ esbuild-linux-riscv64: 0.15.10
+ esbuild-linux-s390x: 0.15.10
+ esbuild-netbsd-64: 0.15.10
+ esbuild-openbsd-64: 0.15.10
+ esbuild-sunos-64: 0.15.10
+ esbuild-windows-32: 0.15.10
+ esbuild-windows-64: 0.15.10
+ esbuild-windows-arm64: 0.15.10
+ dev: true
+
+ /escalade/3.1.1:
+ resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /escape-string-regexp/1.0.5:
+ resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
+ engines: {node: '>=0.8.0'}
+ dev: true
+
+ /estree-walker/1.0.1:
+ resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==}
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /event-target-shim/5.0.1:
+ resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /events/3.3.0:
+ resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
+ engines: {node: '>=0.8.x'}
+ dev: true
+
+ /fast-copy/2.1.7:
+ resolution: {integrity: sha512-ozrGwyuCTAy7YgFCua8rmqmytECYk/JYAMXcswOcm0qvGoE3tPb7ivBeIHTOK2DiapBhDZgacIhzhQIKU5TCfA==}
+ dev: true
+
+ /fast-glob/3.2.12:
+ resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==}
+ engines: {node: '>=8.6.0'}
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.5
+ dev: true
+
+ /fast-redact/3.1.2:
+ resolution: {integrity: sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /fast-safe-stringify/2.1.1:
+ resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
+ dev: true
+
+ /fastq/1.13.0:
+ resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
+ dependencies:
+ reusify: 1.0.4
+ dev: true
+
+ /fetch-blob/3.2.0:
+ resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
+ engines: {node: ^12.20 || >= 14.13}
+ dependencies:
+ node-domexception: 1.0.0
+ web-streams-polyfill: 3.2.1
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /formdata-polyfill/4.0.10:
+ resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
+ engines: {node: '>=12.20.0'}
+ dependencies:
+ fetch-blob: 3.2.0
+ dev: true
+
+ /fraction.js/4.2.0:
+ resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==}
+ dev: true
+
+ /fs.realpath/1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /function.prototype.name/1.1.5:
+ resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ es-abstract: 1.20.3
+ functions-have-names: 1.2.3
+ dev: true
+
+ /functions-have-names/1.2.3:
+ resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ dev: true
+
+ /get-intrinsic/1.1.3:
+ resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==}
+ dependencies:
+ function-bind: 1.1.1
+ has: 1.0.3
+ has-symbols: 1.0.3
+ dev: true
+
+ /get-symbol-description/1.0.0:
+ resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.1.3
+ dev: true
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob-parent/6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob/7.2.3:
+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+ dev: true
+
+ /glob/8.0.3:
+ resolution: {integrity: sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 5.1.0
+ once: 1.4.0
+ dev: true
+
+ /globalyzer/0.1.0:
+ resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==}
+ dev: true
+
+ /globrex/0.1.2:
+ resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ dev: true
+
+ /graceful-fs/4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+ dev: true
+
+ /has-bigints/1.0.2:
+ resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
+ dev: true
+
+ /has-flag/3.0.0:
+ resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /has-property-descriptors/1.0.0:
+ resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
+ dependencies:
+ get-intrinsic: 1.1.3
+ dev: true
+
+ /has-symbols/1.0.3:
+ resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /has-tostringtag/1.0.0:
+ resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-symbols: 1.0.3
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /help-me/4.1.0:
+ resolution: {integrity: sha512-5HMrkOks2j8Fpu2j5nTLhrBhT7VwHwELpqnSnx802ckofys5MO2SkLpgSz3dgNFHV7IYFX2igm5CM75SmuYidw==}
+ dependencies:
+ glob: 8.0.3
+ readable-stream: 3.6.0
+ dev: true
+
+ /hosted-git-info/2.8.9:
+ resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
+ dev: true
+
+ /ieee754/1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+ dev: true
+
+ /import-fresh/3.3.0:
+ resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
+ engines: {node: '>=6'}
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+ dev: true
+
+ /inflight/1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+ dev: true
+
+ /inherits/2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: true
+
+ /internal-slot/1.0.3:
+ resolution: {integrity: sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ get-intrinsic: 1.1.3
+ has: 1.0.3
+ side-channel: 1.0.4
+ dev: true
+
+ /is-arrayish/0.2.1:
+ resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ dev: true
+
+ /is-bigint/1.0.4:
+ resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
+ dependencies:
+ has-bigints: 1.0.2
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-boolean-object/1.1.2:
+ resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /is-builtin-module/3.2.0:
+ resolution: {integrity: sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw==}
+ engines: {node: '>=6'}
+ dependencies:
+ builtin-modules: 3.3.0
+ dev: true
+
+ /is-callable/1.2.7:
+ resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /is-core-module/2.10.0:
+ resolution: {integrity: sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-date-object/1.0.5:
+ resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-module/1.0.0:
+ resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
+ dev: true
+
+ /is-negative-zero/2.0.2:
+ resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /is-number-object/1.0.7:
+ resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /is-reference/1.2.1:
+ resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
+ dependencies:
+ '@types/estree': 1.0.0
+ dev: true
+
+ /is-regex/1.1.4:
+ resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /is-shared-array-buffer/1.0.2:
+ resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==}
+ dependencies:
+ call-bind: 1.0.2
+ dev: true
+
+ /is-string/1.0.7:
+ resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-tostringtag: 1.0.0
+ dev: true
+
+ /is-symbol/1.0.4:
+ resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ has-symbols: 1.0.3
+ dev: true
+
+ /is-weakref/1.0.2:
+ resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
+ dependencies:
+ call-bind: 1.0.2
+ dev: true
+
+ /isexe/2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ dev: true
+
+ /joycon/3.1.1:
+ resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /js-cookie/3.0.1:
+ resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /json-parse-better-errors/1.0.2:
+ resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==}
+ dev: true
+
+ /kleur/4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /lilconfig/2.0.6:
+ resolution: {integrity: sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /load-json-file/4.0.0:
+ resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==}
+ engines: {node: '>=4'}
+ dependencies:
+ graceful-fs: 4.2.10
+ parse-json: 4.0.0
+ pify: 3.0.0
+ strip-bom: 3.0.0
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /magic-string/0.26.5:
+ resolution: {integrity: sha512-yXUIYOOQnEHKHOftp5shMWpB9ImfgfDJpapa38j/qMtTj5QHWucvxP4lUtuRmHT9vAzvtpHkWKXW9xBwimXeNg==}
+ engines: {node: '>=12'}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /memorystream/0.3.1:
+ resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==}
+ engines: {node: '>= 0.10.0'}
+ dev: true
+
+ /merge2/1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /micromatch/4.0.5:
+ resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
+ engines: {node: '>=8.6'}
+ dependencies:
+ braces: 3.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /mime/3.0.0:
+ resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ dev: true
+
+ /min-indent/1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /mini-svg-data-uri/1.4.4:
+ resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
+ hasBin: true
+ dev: true
+
+ /minimatch/3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimatch/5.1.0:
+ resolution: {integrity: sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==}
+ engines: {node: '>=10'}
+ dependencies:
+ brace-expansion: 2.0.1
+ dev: true
+
+ /minimist/1.2.6:
+ resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
+ dev: true
+
+ /mkdirp/0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ dev: true
+
+ /mri/1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /mrmime/1.0.1:
+ resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /ms/2.1.2:
+ resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /nice-try/1.0.5:
+ resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
+ dev: true
+
+ /node-domexception/1.0.0:
+ resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
+ engines: {node: '>=10.5.0'}
+ dev: true
+
+ /node-fetch/3.2.10:
+ resolution: {integrity: sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ data-uri-to-buffer: 4.0.0
+ fetch-blob: 3.2.0
+ formdata-polyfill: 4.0.10
+ dev: true
+
+ /node-releases/2.0.6:
+ resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==}
+ dev: true
+
+ /normalize-package-data/2.5.0:
+ resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
+ dependencies:
+ hosted-git-info: 2.8.9
+ resolve: 1.22.1
+ semver: 5.7.1
+ validate-npm-package-license: 3.0.4
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /normalize-range/0.1.2:
+ resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /npm-run-all/4.1.5:
+ resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==}
+ engines: {node: '>= 4'}
+ hasBin: true
+ dependencies:
+ ansi-styles: 3.2.1
+ chalk: 2.4.2
+ cross-spawn: 6.0.5
+ memorystream: 0.3.1
+ minimatch: 3.1.2
+ pidtree: 0.3.1
+ read-pkg: 3.0.0
+ shell-quote: 1.7.3
+ string.prototype.padend: 3.1.3
+ dev: true
+
+ /object-hash/3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+ dev: true
+
+ /object-inspect/1.12.2:
+ resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
+ dev: true
+
+ /object-keys/1.1.1:
+ resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /object.assign/4.1.4:
+ resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ has-symbols: 1.0.3
+ object-keys: 1.1.1
+ dev: true
+
+ /on-exit-leak-free/2.1.0:
+ resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==}
+ dev: true
+
+ /once/1.4.0:
+ resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+ dependencies:
+ wrappy: 1.0.2
+ dev: true
+
+ /parent-module/1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+ dependencies:
+ callsites: 3.1.0
+ dev: true
+
+ /parse-json/4.0.0:
+ resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
+ engines: {node: '>=4'}
+ dependencies:
+ error-ex: 1.3.2
+ json-parse-better-errors: 1.0.2
+ dev: true
+
+ /path-is-absolute/1.0.1:
+ resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-key/2.0.1:
+ resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /path-type/3.0.0:
+ resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
+ engines: {node: '>=4'}
+ dependencies:
+ pify: 3.0.0
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /pidtree/0.3.1:
+ resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==}
+ engines: {node: '>=0.10'}
+ hasBin: true
+ dev: true
+
+ /pify/2.3.0:
+ resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /pify/3.0.0:
+ resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /pino-abstract-transport/1.0.0:
+ resolution: {integrity: sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==}
+ dependencies:
+ readable-stream: 4.2.0
+ split2: 4.1.0
+ dev: true
+
+ /pino-pretty/9.1.0:
+ resolution: {integrity: sha512-IM6NY9LLo/dVgY7/prJhCh4rAJukafdt0ibxeNOWc2fxKMyTk90SOB9Ao2HfbtShT9QPeP0ePpJktksMhSQMYA==}
+ hasBin: true
+ dependencies:
+ colorette: 2.0.19
+ dateformat: 4.6.3
+ fast-copy: 2.1.7
+ fast-safe-stringify: 2.1.1
+ help-me: 4.1.0
+ joycon: 3.1.1
+ minimist: 1.2.6
+ on-exit-leak-free: 2.1.0
+ pino-abstract-transport: 1.0.0
+ pump: 3.0.0
+ readable-stream: 4.2.0
+ secure-json-parse: 2.5.0
+ sonic-boom: 3.2.0
+ strip-json-comments: 3.1.1
+ dev: true
+
+ /pino-std-serializers/6.0.0:
+ resolution: {integrity: sha512-mMMOwSKrmyl+Y12Ri2xhH1lbzQxwwpuru9VjyJpgFIH4asSj88F2csdMwN6+M5g1Ll4rmsYghHLQJw81tgZ7LQ==}
+ dev: true
+
+ /pino/8.6.1:
+ resolution: {integrity: sha512-fi+V2K98eMZjQ/uEHHSiMALNrz7HaFdKNYuyA3ZUrbH0f1e8sPFDmeRGzg7ZH2q4QDxGnJPOswmqlEaTAZeDPA==}
+ hasBin: true
+ dependencies:
+ atomic-sleep: 1.0.0
+ fast-redact: 3.1.2
+ on-exit-leak-free: 2.1.0
+ pino-abstract-transport: 1.0.0
+ pino-std-serializers: 6.0.0
+ process-warning: 2.0.0
+ quick-format-unescaped: 4.0.4
+ real-require: 0.2.0
+ safe-stable-stringify: 2.4.0
+ sonic-boom: 3.2.0
+ thread-stream: 2.2.0
+ dev: true
+
+ /playwright-core/1.26.1:
+ resolution: {integrity: sha512-hzFchhhxnEiPc4qVPs9q2ZR+5eKNifY2hQDHtg1HnTTUuphYCBP8ZRb2si+B1TR7BHirgXaPi48LIye5SgrLAA==}
+ engines: {node: '>=14'}
+ hasBin: true
+ dev: true
+
+ /postcss-import/14.1.0_postcss@8.4.17:
+ resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ postcss: ^8.0.0
+ dependencies:
+ postcss: 8.4.17
+ postcss-value-parser: 4.2.0
+ read-cache: 1.0.0
+ resolve: 1.22.1
+ dev: true
+
+ /postcss-js/4.0.0_postcss@8.4.17:
+ resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==}
+ engines: {node: ^12 || ^14 || >= 16}
+ peerDependencies:
+ postcss: ^8.3.3
+ dependencies:
+ camelcase-css: 2.0.1
+ postcss: 8.4.17
+ dev: true
+
+ /postcss-load-config/3.1.4_postcss@8.4.17:
+ resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
+ engines: {node: '>= 10'}
+ peerDependencies:
+ postcss: '>=8.0.9'
+ ts-node: '>=9.0.0'
+ peerDependenciesMeta:
+ postcss:
+ optional: true
+ ts-node:
+ optional: true
+ dependencies:
+ lilconfig: 2.0.6
+ postcss: 8.4.17
+ yaml: 1.10.2
+ dev: true
+
+ /postcss-load-config/4.0.1_postcss@8.4.17:
+ resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
+ engines: {node: '>= 14'}
+ peerDependencies:
+ postcss: '>=8.0.9'
+ ts-node: '>=9.0.0'
+ peerDependenciesMeta:
+ postcss:
+ optional: true
+ ts-node:
+ optional: true
+ dependencies:
+ lilconfig: 2.0.6
+ postcss: 8.4.17
+ yaml: 2.1.2
+ dev: true
+
+ /postcss-nested/5.0.6_postcss@8.4.17:
+ resolution: {integrity: sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==}
+ engines: {node: '>=12.0'}
+ peerDependencies:
+ postcss: ^8.2.14
+ dependencies:
+ postcss: 8.4.17
+ postcss-selector-parser: 6.0.10
+ dev: true
+
+ /postcss-selector-parser/6.0.10:
+ resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
+ engines: {node: '>=4'}
+ dependencies:
+ cssesc: 3.0.0
+ util-deprecate: 1.0.2
+ dev: true
+
+ /postcss-value-parser/4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+ dev: true
+
+ /postcss/8.4.17:
+ resolution: {integrity: sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /process-warning/2.0.0:
+ resolution: {integrity: sha512-+MmoAXoUX+VTHAlwns0h+kFUWFs/3FZy+ZuchkgjyOu3oioLAo2LB5aCfKPh2+P9O18i3m43tUEv3YqttSy0Ww==}
+ dev: true
+
+ /process/0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+ dev: true
+
+ /pump/3.0.0:
+ resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
+ dependencies:
+ end-of-stream: 1.4.4
+ once: 1.4.0
+ dev: true
+
+ /queue-microtask/1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ dev: true
+
+ /quick-format-unescaped/4.0.4:
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+ dev: true
+
+ /quick-lru/5.1.1:
+ resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /read-cache/1.0.0:
+ resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
+ dependencies:
+ pify: 2.3.0
+ dev: true
+
+ /read-pkg/3.0.0:
+ resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==}
+ engines: {node: '>=4'}
+ dependencies:
+ load-json-file: 4.0.0
+ normalize-package-data: 2.5.0
+ path-type: 3.0.0
+ dev: true
+
+ /readable-stream/3.6.0:
+ resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==}
+ engines: {node: '>= 6'}
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+ dev: true
+
+ /readable-stream/4.2.0:
+ resolution: {integrity: sha512-gJrBHsaI3lgBoGMW/jHZsQ/o/TIWiu5ENCJG1BB7fuCKzpFM8GaS2UoBVt9NO+oI+3FcrBNbUkl3ilDe09aY4A==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ dependencies:
+ abort-controller: 3.0.0
+ buffer: 6.0.3
+ events: 3.3.0
+ process: 0.11.10
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /real-require/0.2.0:
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+ engines: {node: '>= 12.13.0'}
+ dev: true
+
+ /regexp.prototype.flags/1.4.3:
+ resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ functions-have-names: 1.2.3
+ dev: true
+
+ /resolve-from/4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /resolve/1.22.1:
+ resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.10.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /reusify/1.0.4:
+ resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ dev: true
+
+ /rimraf/2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.3
+ dev: true
+
+ /rollup/2.78.1:
+ resolution: {integrity: sha512-VeeCgtGi4P+o9hIg+xz4qQpRl6R401LWEXBmxYKOV4zlF82lyhgh2hTZnheFUbANE8l2A41F458iwj2vEYaXJg==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /rollup/2.79.1:
+ resolution: {integrity: sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /run-parallel/1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ dependencies:
+ queue-microtask: 1.2.3
+ dev: true
+
+ /sade/1.8.1:
+ resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
+ engines: {node: '>=6'}
+ dependencies:
+ mri: 1.2.0
+ dev: true
+
+ /safe-buffer/5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ dev: true
+
+ /safe-regex-test/1.0.0:
+ resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.1.3
+ is-regex: 1.1.4
+ dev: true
+
+ /safe-stable-stringify/2.4.0:
+ resolution: {integrity: sha512-eehKHKpab6E741ud7ZIMcXhKcP6TSIezPkNZhy5U8xC6+VvrRdUA2tMgxGxaGl4cz7c2Ew5+mg5+wNB16KQqrA==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /sander/0.5.1:
+ resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==}
+ dependencies:
+ es6-promise: 3.3.1
+ graceful-fs: 4.2.10
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+ dev: true
+
+ /secure-json-parse/2.5.0:
+ resolution: {integrity: sha512-ZQruFgZnIWH+WyO9t5rWt4ZEGqCKPwhiw+YbzTwpmT9elgLrLcfuyUiSnwwjUiVy9r4VM3urtbNF1xmEh9IL2w==}
+ dev: true
+
+ /semver/5.7.1:
+ resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
+ hasBin: true
+ dev: true
+
+ /set-cookie-parser/2.5.1:
+ resolution: {integrity: sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==}
+ dev: true
+
+ /shebang-command/1.2.0:
+ resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ shebang-regex: 1.0.0
+ dev: true
+
+ /shebang-regex/1.0.0:
+ resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /shell-quote/1.7.3:
+ resolution: {integrity: sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==}
+ dev: true
+
+ /side-channel/1.0.4:
+ resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
+ dependencies:
+ call-bind: 1.0.2
+ get-intrinsic: 1.1.3
+ object-inspect: 1.12.2
+ dev: true
+
+ /sirv/2.0.2:
+ resolution: {integrity: sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==}
+ engines: {node: '>= 10'}
+ dependencies:
+ '@polka/url': 1.0.0-next.21
+ mrmime: 1.0.1
+ totalist: 3.0.0
+ dev: true
+
+ /sonic-boom/3.2.0:
+ resolution: {integrity: sha512-SbbZ+Kqj/XIunvIAgUZRlqd6CGQYq71tRRbXR92Za8J/R3Yh4Av+TWENiSiEgnlwckYLyP0YZQWVfyNC0dzLaA==}
+ dependencies:
+ atomic-sleep: 1.0.0
+ dev: true
+
+ /sorcery/0.10.0:
+ resolution: {integrity: sha512-R5ocFmKZQFfSTstfOtHjJuAwbpGyf9qjQa1egyhvXSbM7emjrtLXtGdZsDJDABC85YBfVvrOiGWKSYXPKdvP1g==}
+ hasBin: true
+ dependencies:
+ buffer-crc32: 0.2.13
+ minimist: 1.2.6
+ sander: 0.5.1
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /spdx-correct/3.1.1:
+ resolution: {integrity: sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==}
+ dependencies:
+ spdx-expression-parse: 3.0.1
+ spdx-license-ids: 3.0.12
+ dev: true
+
+ /spdx-exceptions/2.3.0:
+ resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==}
+ dev: true
+
+ /spdx-expression-parse/3.0.1:
+ resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
+ dependencies:
+ spdx-exceptions: 2.3.0
+ spdx-license-ids: 3.0.12
+ dev: true
+
+ /spdx-license-ids/3.0.12:
+ resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==}
+ dev: true
+
+ /split2/4.1.0:
+ resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==}
+ engines: {node: '>= 10.x'}
+ dev: true
+
+ /string.prototype.padend/3.1.3:
+ resolution: {integrity: sha512-jNIIeokznm8SD/TZISQsZKYu7RJyheFNt84DUPrh482GC8RVp2MKqm2O5oBRdGxbDQoXrhhWtPIWQOiy20svUg==}
+ engines: {node: '>= 0.4'}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ es-abstract: 1.20.3
+ dev: true
+
+ /string.prototype.trimend/1.0.5:
+ resolution: {integrity: sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ es-abstract: 1.20.3
+ dev: true
+
+ /string.prototype.trimstart/1.0.5:
+ resolution: {integrity: sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==}
+ dependencies:
+ call-bind: 1.0.2
+ define-properties: 1.1.4
+ es-abstract: 1.20.3
+ dev: true
+
+ /string_decoder/1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ dependencies:
+ safe-buffer: 5.2.1
+ dev: true
+
+ /strip-bom/3.0.0:
+ resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /strip-indent/3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ min-indent: 1.0.1
+ dev: true
+
+ /strip-json-comments/3.1.1:
+ resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /supports-color/5.5.0:
+ resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
+ engines: {node: '>=4'}
+ dependencies:
+ has-flag: 3.0.0
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /svelte-check/2.9.1_ejhwqstqdwfnekvhsm3hus3z4i:
+ resolution: {integrity: sha512-+BFPsj6irZ+t2pVSVo//2Ic1mI3A52xCwbkSTVhTqYZqgawcyZd9pYZoEac3fIWbEeTyCb5X82ORKI/gjn+P7A==}
+ hasBin: true
+ peerDependencies:
+ svelte: ^3.24.0
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.15
+ chokidar: 3.5.3
+ fast-glob: 3.2.12
+ import-fresh: 3.3.0
+ picocolors: 1.0.0
+ sade: 1.8.1
+ svelte: 3.50.1
+ svelte-preprocess: 4.10.7_or4gyn62tntw7ihg73nagmkdja
+ typescript: 4.8.4
+ transitivePeerDependencies:
+ - '@babel/core'
+ - coffeescript
+ - less
+ - node-sass
+ - postcss
+ - postcss-load-config
+ - pug
+ - sass
+ - stylus
+ - sugarss
+ dev: true
+
+ /svelte-hmr/0.15.0_svelte@3.50.1:
+ resolution: {integrity: sha512-Aw21SsyoohyVn4yiKXWPNCSW2DQNH/76kvUnE9kpt4h9hcg9tfyQc6xshx9hzgMfGF0kVx0EGD8oBMWSnATeOg==}
+ engines: {node: ^12.20 || ^14.13.1 || >= 16}
+ peerDependencies:
+ svelte: '>=3.19.0'
+ dependencies:
+ svelte: 3.50.1
+ dev: true
+
+ /svelte-preprocess/4.10.7_or4gyn62tntw7ihg73nagmkdja:
+ resolution: {integrity: sha512-sNPBnqYD6FnmdBrUmBCaqS00RyCsCpj2BG58A1JBswNF7b0OKviwxqVrOL/CKyJrLSClrSeqQv5BXNg2RUbPOw==}
+ engines: {node: '>= 9.11.2'}
+ requiresBuild: true
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ node-sass: '*'
+ postcss: ^7 || ^8
+ postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: ^0.55.0
+ sugarss: ^2.0.0
+ svelte: ^3.23.0
+ typescript: ^3.9.5 || ^4.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ node-sass:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@types/pug': 2.0.6
+ '@types/sass': 1.43.1
+ detect-indent: 6.1.0
+ magic-string: 0.25.9
+ postcss: 8.4.17
+ postcss-load-config: 4.0.1_postcss@8.4.17
+ sorcery: 0.10.0
+ strip-indent: 3.0.0
+ svelte: 3.50.1
+ typescript: 4.8.4
+ dev: true
+
+ /svelte/3.50.1:
+ resolution: {integrity: sha512-bS4odcsdj5D5jEg6riZuMg5NKelzPtmsCbD9RG+8umU03TeNkdWnP6pqbCm0s8UQNBkqk29w/Bdubn3C+HWSwA==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /tailwindcss/3.1.8_postcss@8.4.17:
+ resolution: {integrity: sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==}
+ engines: {node: '>=12.13.0'}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.0.9
+ dependencies:
+ arg: 5.0.2
+ chokidar: 3.5.3
+ color-name: 1.1.4
+ detective: 5.2.1
+ didyoumean: 1.2.2
+ dlv: 1.1.3
+ fast-glob: 3.2.12
+ glob-parent: 6.0.2
+ is-glob: 4.0.3
+ lilconfig: 2.0.6
+ normalize-path: 3.0.0
+ object-hash: 3.0.0
+ picocolors: 1.0.0
+ postcss: 8.4.17
+ postcss-import: 14.1.0_postcss@8.4.17
+ postcss-js: 4.0.0_postcss@8.4.17
+ postcss-load-config: 3.1.4_postcss@8.4.17
+ postcss-nested: 5.0.6_postcss@8.4.17
+ postcss-selector-parser: 6.0.10
+ postcss-value-parser: 4.2.0
+ quick-lru: 5.1.1
+ resolve: 1.22.1
+ transitivePeerDependencies:
+ - ts-node
+ dev: true
+
+ /temporal-polyfill/0.0.8:
+ resolution: {integrity: sha512-IuA8GhS1PRC04H/zVNAIxJvCZQum6V5HjqFj7gz1a3SMUf/Kf1xIXILNYtxrWYnGqIU/RrDRxlCKCm/vmqnBvw==}
+ dependencies:
+ temporal-spec: 0.0.3
+ dev: true
+
+ /temporal-spec/0.0.3:
+ resolution: {integrity: sha512-gJu7QRqn5c2vTSkYWGC4qz1i+FZ9C+Cz16UIBMRcjgXOsHfXeSIgaWUKeq/2rz1iNfFxvmF/ywqbfC6ggTpjkA==}
+ dev: true
+
+ /thread-stream/2.2.0:
+ resolution: {integrity: sha512-rUkv4/fnb4rqy/gGy7VuqK6wE1+1DOCOWy4RMeaV69ZHMP11tQKZvZSip1yTgrKCMZzEMcCL/bKfHvSfDHx+iQ==}
+ dependencies:
+ real-require: 0.2.0
+ dev: true
+
+ /tiny-glob/0.2.9:
+ resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
+ dependencies:
+ globalyzer: 0.1.0
+ globrex: 0.1.2
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /totalist/3.0.0:
+ resolution: {integrity: sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /tslib/2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ dev: true
+
+ /typesafe-i18n/5.14.0_typescript@4.8.4:
+ resolution: {integrity: sha512-ZNHysUvZZhmUuMjBvDGtUI8vT3g//4ay5fFOk2sJCsjx4ztippW1Hrhrq59nJ9mV/Q0u4OX80Gyorq8L3rwNLw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=3.5.1'
+ dependencies:
+ typescript: 4.8.4
+ dev: true
+
+ /typescript/4.8.4:
+ resolution: {integrity: sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
+ /unbox-primitive/1.0.2:
+ resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
+ dependencies:
+ call-bind: 1.0.2
+ has-bigints: 1.0.2
+ has-symbols: 1.0.3
+ which-boxed-primitive: 1.0.2
+ dev: true
+
+ /undici/5.10.0:
+ resolution: {integrity: sha512-c8HsD3IbwmjjbLvoZuRI26TZic+TSEe8FPMLLOkN1AfYRhdjnKBU6yL+IwcSCbdZiX4e5t0lfMDLDCqj4Sq70g==}
+ engines: {node: '>=12.18'}
+ dev: true
+
+ /update-browserslist-db/1.0.9_browserslist@4.21.4:
+ resolution: {integrity: sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.21.4
+ escalade: 3.1.1
+ picocolors: 1.0.0
+ dev: true
+
+ /util-deprecate/1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ dev: true
+
+ /validate-npm-package-license/3.0.4:
+ resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
+ dependencies:
+ spdx-correct: 3.1.1
+ spdx-expression-parse: 3.0.1
+ dev: true
+
+ /vite/3.1.4:
+ resolution: {integrity: sha512-JoQI08aBjY9lycL7jcEq4p9o1xUjq5aRvdH4KWaXtkSx7e7RpAh9D3IjzDWRD4Fg44LS3oDAIOG/Kq1L+82psA==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ terser:
+ optional: true
+ dependencies:
+ esbuild: 0.15.10
+ postcss: 8.4.17
+ resolve: 1.22.1
+ rollup: 2.78.1
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /web-streams-polyfill/3.2.1:
+ resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /which-boxed-primitive/1.0.2:
+ resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
+ dependencies:
+ is-bigint: 1.0.4
+ is-boolean-object: 1.1.2
+ is-number-object: 1.0.7
+ is-string: 1.0.7
+ is-symbol: 1.0.4
+ dev: true
+
+ /which/1.3.1:
+ resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
+ hasBin: true
+ dependencies:
+ isexe: 2.0.0
+ dev: true
+
+ /wrappy/1.0.2:
+ resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ dev: true
+
+ /xtend/4.0.2:
+ resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
+ engines: {node: '>=0.4'}
+ dev: true
+
+ /yaml/1.10.2:
+ resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
+ engines: {node: '>= 6'}
+ dev: true
+
+ /yaml/2.1.2:
+ resolution: {integrity: sha512-VSdf2/K3FqAetooKQv45Hcu6sA00aDgWZeGcG6V9IYJnVLTnb6988Tie79K5nx2vK7cEpf+yW8Oy+7iPAbdiHA==}
+ engines: {node: '>= 14'}
+ dev: true
diff --git a/code/app/postcss.config.cjs b/code/app/postcss.config.cjs
new file mode 100644
index 0000000..a53e3b3
--- /dev/null
+++ b/code/app/postcss.config.cjs
@@ -0,0 +1,13 @@
+const tailwindcss = require("tailwindcss");
+const autoprefixer = require("autoprefixer");
+const nesting = require("tailwindcss/nesting");
+
+const config = {
+ plugins: [
+ nesting,
+ tailwindcss,
+ autoprefixer
+ ],
+};
+
+module.exports = config;
diff --git a/code/app/src/actions/pwKey.js b/code/app/src/actions/pwKey.js
new file mode 100644
index 0000000..2c019f3
--- /dev/null
+++ b/code/app/src/actions/pwKey.js
@@ -0,0 +1,12 @@
+import { is_development, is_testing } from "$lib/configuration";
+export default function pwKey(node, value) {
+ if (!value)
+ return;
+ if (!is_testing()) {
+ if (is_development())
+ console.warn("VITE_TESTING is false, so not setting pw-key attributes");
+ return;
+ }
+ node.setAttribute("pw-key", value);
+}
+//# sourceMappingURL=pwKey.js.map \ No newline at end of file
diff --git a/code/app/src/actions/pwKey.js.map b/code/app/src/actions/pwKey.js.map
new file mode 100644
index 0000000..2c37f87
--- /dev/null
+++ b/code/app/src/actions/pwKey.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"pwKey.js","sourceRoot":"","sources":["pwKey.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhE,MAAM,CAAC,OAAO,UAAU,KAAK,CAAC,IAAiB,EAAE,KAAyB;IACtE,IAAI,CAAC,KAAK;QAAE,OAAO;IACnB,IAAI,CAAC,UAAU,EAAE,EAAE;QACf,IAAI,cAAc,EAAE;YAAE,OAAO,CAAC,IAAI,CAAC,yDAAyD,CAAC,CAAC;QAC9F,OAAO;KACV;IACD,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;AACvC,CAAC"} \ No newline at end of file
diff --git a/code/app/src/actions/pwKey.ts b/code/app/src/actions/pwKey.ts
new file mode 100644
index 0000000..a2f22e7
--- /dev/null
+++ b/code/app/src/actions/pwKey.ts
@@ -0,0 +1,10 @@
+import { is_development, is_testing } from "$lib/configuration";
+
+export default function pwKey(node: HTMLElement, value: string | undefined) {
+ if (!value) return;
+ if (!is_testing()) {
+ if (is_development()) console.warn("VITE_TESTING is false, so not setting pw-key attributes");
+ return;
+ }
+ node.setAttribute("pw-key", value);
+} \ No newline at end of file
diff --git a/code/app/src/app.d.ts b/code/app/src/app.d.ts
new file mode 100644
index 0000000..220ddc1
--- /dev/null
+++ b/code/app/src/app.d.ts
@@ -0,0 +1,9 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+// and what to do when importing types
+declare namespace App {
+ interface Locals {}
+ interface Platform {}
+ interface PrivateEnv {}
+ interface PublicEnv {}
+} \ No newline at end of file
diff --git a/code/app/src/app.html b/code/app/src/app.html
new file mode 100644
index 0000000..308b223
--- /dev/null
+++ b/code/app/src/app.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html class="h-full bg-white" lang="en">
+
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width" />
+ %sveltekit.head%
+</head>
+
+<body class="h-full">
+ <div>%sveltekit.body%</div>
+</body>
+
+</html> \ No newline at end of file
diff --git a/code/app/src/app.pcss b/code/app/src/app.pcss
new file mode 100644
index 0000000..d256fea
--- /dev/null
+++ b/code/app/src/app.pcss
@@ -0,0 +1,34 @@
+/* Write your global styles here, in PostCSS syntax */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+pre {
+ font-family: monospace !important;
+}
+
+*:focus-visible {
+ outline: 1px auto;
+}
+
+.c-disabled {
+ cursor: not-allowed !important;
+ filter: opacity(.45);
+ pointer-events: none !important;
+}
+
+.c-disabled.loading {
+ cursor: wait !important;
+}
+
+.link {
+ @apply text-blue-600 hover:text-blue-700 transition duration-300 ease-in-out mb-4;
+
+ &.danger {
+ @apply text-red-600 hover:text-red-700;
+ }
+
+ &.active {
+ @apply underline
+ }
+} \ No newline at end of file
diff --git a/code/app/src/global.d.ts b/code/app/src/global.d.ts
new file mode 100644
index 0000000..13f5e16
--- /dev/null
+++ b/code/app/src/global.d.ts
@@ -0,0 +1,11 @@
+/// <reference types="@sveltejs/kit" />
+
+type Locales = import('$lib/i18n/i18n-types').Locales
+type TranslationFunctions = import('$lib/i18n/i18n-types').TranslationFunctions
+
+declare namespace App {
+ interface Locals {
+ locale: Locales
+ LL: TranslationFunctions
+ }
+} \ No newline at end of file
diff --git a/code/app/src/lib/api/internal-fetch.ts b/code/app/src/lib/api/internal-fetch.ts
new file mode 100644
index 0000000..b21d669
--- /dev/null
+++ b/code/app/src/lib/api/internal-fetch.ts
@@ -0,0 +1,170 @@
+import { Temporal } from "temporal-polyfill";
+import { clear_session_data } from "$lib/session";
+import { resolve_references } from "$lib/helpers";
+import type { IInternalFetchResponse } from "$lib/models/IInternalFetchResponse";
+import type { IInternalFetchRequest } from "$lib/models/IInternalFetchRequest";
+import { redirect } from "@sveltejs/kit";
+
+export async function http_post(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "post",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "get",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_delete(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "delete",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch (error) {
+ // ignored
+ }
+ }
+
+ return result;
+}
+
+async function internal_fetch(request: IInternalFetchRequest): Promise<Response> {
+ if (!request.init) request.init = {};
+ request.init.credentials = "include";
+ request.init.headers = {
+ "X-TimeZone": Temporal.Now.timeZone().id,
+ ...request.init.headers
+ };
+
+ const fetch_request = new Request(request.url, request.init);
+ let response: any;
+
+ try {
+ if (request.timeout && request.timeout > 500) {
+ response = await Promise.race([
+ fetch(fetch_request),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout))
+ ]);
+ } else {
+ response = await fetch(fetch_request);
+ }
+ } catch (error: any) {
+ console.log(error);
+ if (error.message === "Timeout") {
+ console.error("Request timed out");
+ } else if (error.message === "Network request failed") {
+ console.error("No internet connection");
+ } else {
+ throw error; // rethrow other unexpected errors
+ }
+ }
+
+ return response;
+}
+
+async function is_401(response: Response): Promise<boolean> {
+ if (response.status === 401) {
+ clear_session_data();
+ throw redirect(307, "/login");
+ }
+ return false;
+}
diff --git a/code/app/src/lib/api/root.ts b/code/app/src/lib/api/root.ts
new file mode 100644
index 0000000..3e5bda2
--- /dev/null
+++ b/code/app/src/lib/api/root.ts
@@ -0,0 +1,6 @@
+import {http_post} from "$lib/api/internal-fetch";
+import {api_base} from "$lib/configuration";
+
+export function server_log(message: string): void {
+ http_post(api_base("_/api/log"), message);
+}
diff --git a/code/app/src/lib/api/time-entry.ts b/code/app/src/lib/api/time-entry.ts
new file mode 100644
index 0000000..a40b0c2
--- /dev/null
+++ b/code/app/src/lib/api/time-entry.ts
@@ -0,0 +1,83 @@
+import {api_base} from "$lib/configuration";
+import {is_guid} from "$lib/helpers";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {TimeCategoryDto} from "$lib/models/TimeCategoryDto";
+import type {TimeLabelDto} from "$lib/models/TimeLabelDto";
+import type {TimeEntryDto} from "$lib/models/TimeEntryDto";
+import type {TimeEntryQuery} from "$lib/models/TimeEntryQuery";
+import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse";
+
+
+// ENTRIES
+
+export async function create_time_entry(payload: TimeEntryDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/create"), payload);
+}
+
+export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> {
+ if (is_guid(entryId)) {
+ return http_get(api_base("v1/entries/" + entryId));
+ }
+ throw new Error("entryId is not a valid guid.");
+}
+
+export async function get_time_entries(entryQuery: TimeEntryQuery): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/query"), entryQuery);
+}
+
+export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/entries/" + id + "/delete"));
+}
+
+export async function update_time_entry(entryDto: TimeEntryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!entryDto.category) throw new Error("category is empty");
+ if (!entryDto.stop) throw new Error("stop is empty");
+ if (!entryDto.start) throw new Error("start is empty");
+ return http_post(api_base("v1/entries/update"), entryDto);
+}
+
+// LABELS
+export async function create_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/labels/create"), labelDto);
+}
+
+export async function get_time_labels(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/labels"));
+}
+
+export async function delete_time_label(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/labels/" + id + "/delete"));
+}
+
+export async function update_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!labelDto.name) throw new Error("name is empty");
+ if (!labelDto.color) throw new Error("color is empty");
+ return http_post(api_base("v1/labels/update"), labelDto);
+}
+
+// CATEGORIES
+export async function create_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/create"), category);
+}
+
+export async function get_time_categories(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/categories"));
+}
+
+export async function delete_time_category(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/categories/" + id + "/delete"));
+}
+
+export async function update_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid");
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/update"), category);
+}
diff --git a/code/app/src/lib/api/user.ts b/code/app/src/lib/api/user.ts
new file mode 100644
index 0000000..f0dc932
--- /dev/null
+++ b/code/app/src/lib/api/user.ts
@@ -0,0 +1,47 @@
+import {api_base} from "$lib/configuration";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {LoginPayload} from "$lib/models/LoginPayload";
+import type {UpdateProfilePayload} from "$lib/models/UpdateProfilePayload";
+import type {CreateAccountPayload} from "$lib/models/CreateAccountPayload";
+import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse";
+
+export async function login(payload: LoginPayload): Promise<IInternalFetchResponse> {
+ return http_post(api_base("_/account/login"), payload);
+}
+
+export async function logout(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account/logout"));
+}
+
+export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> {
+ if (!username) throw new Error("Username is empty");
+ return http_get(api_base("_/forgot-password-requests/create?username=" + username));
+}
+
+export async function check_forgot_password_request(public_id: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id));
+}
+
+export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_post(api_base("_/forgot-password-requests/fulfill"), {id: public_id, newPassword});
+}
+
+export async function delete_account(): Promise<IInternalFetchResponse> {
+ return http_delete(api_base("_/account/delete"));
+}
+
+export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/update"), payload);
+}
+
+export async function create_account(payload: CreateAccountPayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/create"), payload);
+}
+
+export async function get_profile_for_active_check(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account"), 0, true);
+}
diff --git a/code/app/src/lib/colors.ts b/code/app/src/lib/colors.ts
new file mode 100644
index 0000000..34c7992
--- /dev/null
+++ b/code/app/src/lib/colors.ts
@@ -0,0 +1,47 @@
+export function generate_random_hex_color(skip_contrast_check = false) {
+ let hex = __generate_random_hex_color();
+ if (skip_contrast_check) return hex;
+ while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) {
+ hex = __generate_random_hex_color();
+ }
+
+ return hex;
+}
+
+// Largely copied from chroma js api
+function __generate_random_hex_color(): string {
+ let code = "#";
+ for (let i = 0; i < 6; i++) {
+ code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
+ }
+ return code;
+}
+
+function __calculate_contrast_ratio(hex1: string, hex2: string): number {
+ const rgb1 = __hex_to_rgb(hex1);
+ const rgb2 = __hex_to_rgb(hex2);
+ const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]);
+ const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]);
+ const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
+ return result;
+}
+
+function __hex_to_rgb(hex: string): number[] {
+ if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return [];
+ if (hex[0] === "#") hex = hex.substring(1, hex.length);
+ return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)];
+}
+
+function __get_luminance(r: any, g: any, b: any) {
+ // relative luminance
+ // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ r = __luminance_x(r);
+ g = __luminance_x(g);
+ b = __luminance_x(b);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function __luminance_x(x: any) {
+ x /= 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+}
diff --git a/code/app/src/lib/components/alert.svelte b/code/app/src/lib/components/alert.svelte
new file mode 100644
index 0000000..fd57105
--- /dev/null
+++ b/code/app/src/lib/components/alert.svelte
@@ -0,0 +1,268 @@
+<script lang="ts">
+ import { random_string } from "$lib/helpers";
+ import { createEventDispatcher } from "svelte";
+ import { onMount } from "svelte";
+ import pwKey from "$actions/pwKey";
+ import { Temporal } from "temporal-polyfill";
+ import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon, XCircleIcon, XMarkIcon } from "./icons";
+
+ const dispatch = createEventDispatcher();
+ const noCooldownSetting = "no-cooldown";
+
+ let iconComponent: any;
+ let colorClassPart = "";
+
+ /**
+ * An optional id for this alert, a default is set if not specified.
+ * This value is necessary for closeable cooldown to work.
+ */
+ // if no unique id is supplied, cooldown will not work between page loads.
+ // Therefore we are disabling it with noCooldownSetting in the fallback id.
+ export let id = "alert--" + noCooldownSetting + "--" + random_string(4);
+ /**
+ * The title to communicate, value is optional
+ */
+ export let title = "";
+ /**
+ * The message to communicate, value is optional
+ */
+ export let message = "";
+ /**
+ * Changes the alerts color and icon.
+ */
+ export let type: "info" | "success" | "warning" | "error" = "info";
+ /**
+ * If true the alert can be removed from the DOM by clicking on a X icon on the upper right hand courner
+ */
+ export let closeable = false;
+ /**
+ * The amount of seconds that should go by before this alert is shown again, only works when a unique id is set.
+ * Set to ~ if it should only be shown once per client (State stored in localestorage).
+ **/
+ export let closeableCooldown = "-1";
+ /**
+ * The text that is displayed on the right link
+ */
+ export let rightLinkText = "";
+ /**
+ * An array of list items displayed under the message or title
+ */
+ export let listItems: Array<string> = [];
+ /**
+ * An array of {id:string;text:string;color?:string}, where id is dispatched back as an svelte event with this syntax act$id (ex: on:actcancel).
+ * Text is the button text
+ * Color is the optional tailwind color to used, the value is used in classes like bg-$color-50.
+ */
+ export let actions: Array<{ id: string; text: string; color?: string }> = [];
+ /**
+ * This value is set on a plain anchor tag without any svelte routing,
+ * listen to the on:rightLinkClick if you want to intercept the click without navigating
+ */
+ export let rightLinkHref = "javascript:void(0)";
+ $: cooldownEnabled =
+ id.indexOf(noCooldownSetting) === -1 && closeable && (closeableCooldown === "~" || parseInt(closeableCooldown) > 0);
+ /**
+ * Sets this alerts visibility state, when this is false it is removed from the dom using an {#if} block.
+ */
+ export let visible = closeableCooldown === "~" || parseInt(closeableCooldown) > 0 ? false : true;
+
+ export let _pwKey: string | undefined = undefined;
+
+ const cooldownStorageKey = "lastseen--" + id;
+
+ $: switch (type) {
+ case "info": {
+ colorClassPart = "blue";
+ iconComponent = InformationCircleIcon;
+ break;
+ }
+ case "warning": {
+ colorClassPart = "yellow";
+ iconComponent = ExclamationTriangleIcon;
+ break;
+ }
+ case "error": {
+ colorClassPart = "red";
+ iconComponent = XCircleIcon;
+ break;
+ }
+ case "success": {
+ colorClassPart = "green";
+ iconComponent = CheckCircleIcon;
+ break;
+ }
+ }
+
+ function close() {
+ visible = false;
+ if (cooldownEnabled) {
+ console.log("Cooldown enabled for " + id + ", " + closeableCooldown === "~" ? "with an endless cooldown" : "");
+ localStorage.setItem(cooldownStorageKey, String(Temporal.Now.instant().epochSeconds));
+ }
+ }
+
+ function rightLinkClicked() {
+ dispatch("rightLinkCliked");
+ }
+
+ function actionClicked(name: string) {
+ dispatch("act" + name);
+ }
+
+ // Manages the state of the alert if cooldown is enabled
+ function run_cooldown() {
+ if (!cooldownEnabled) {
+ console.log("Alert cooldown is not enabled for " + id);
+ return;
+ }
+ if (!localStorage.getItem(cooldownStorageKey)) {
+ console.log("Alert " + id + " has not been seen yet, displaying");
+ visible = true;
+ return;
+ }
+ // if (!visible) {
+ // console.log(
+ // "Alert " + id + " is not visible, stopping cooldown change"
+ // );
+ // return;
+ // }
+ if (closeableCooldown === "~") {
+ console.log("Alert " + id + " has an infinite cooldown, hiding");
+ visible = false;
+ return;
+ }
+
+ const lastSeen = Temporal.Instant.fromEpochSeconds(parseInt(localStorage.getItem(cooldownStorageKey) ?? "-1"));
+ if (Temporal.Instant.compare(Temporal.Now.instant(), lastSeen.add({ seconds: parseInt(closeableCooldown) })) === 1) {
+ console.log(
+ "Alert " +
+ id +
+ " has a cooldown of " +
+ closeableCooldown +
+ " and was last seen " +
+ lastSeen.toLocaleString() +
+ " making it due for a showing"
+ );
+ visible = true;
+ } else {
+ visible = false;
+ }
+ }
+
+ onMount(() => {
+ if (cooldownEnabled) {
+ run_cooldown();
+ }
+
+ if (closeable && closeableCooldown && id.indexOf(noCooldownSetting) !== -1) {
+ // TODO: This prints twice before shutting up as it should, in this example look at the only alert with closeableCooldown in alertsbook.
+ // Looks like svelte mounts three times and that my id is only set on the third. Not sure it does at all after logging the id onMount.
+ console.error("Alert cooldown does not work without specifying a unique id, related id: " + id);
+ }
+ });
+</script>
+
+{#if visible}
+ <div class="rounded-md bg-{colorClassPart}-50 p-4 {$$restProps.class ?? ''}" use:pwKey={_pwKey}>
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svelte:component this={iconComponent} class="text-{colorClassPart}-400" />
+ </div>
+ <div class="ml-3 text-sm w-full">
+ {#if !rightLinkText}
+ {#if title}
+ <h3 class="font-medium text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ {:else}
+ <div class="flex-1 md:flex md:justify-between">
+ <div>
+ {#if title}
+ <h3 class="font-medium text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ <p class="mt-3 text-sm md:mt-0 md:ml-6 flex items-end">
+ <a
+ href={rightLinkHref}
+ on:click={() => rightLinkClicked()}
+ class="whitespace-nowrap font-medium text-{colorClassPart}-700 hover:text-{colorClassPart}-600"
+ >
+ {rightLinkText}
+ <span aria-hidden="true"> &rarr;</span>
+ </a>
+ </p>
+ </div>
+ {/if}
+ {#if actions?.length ?? 0}
+ <div class="ml-2 mt-4">
+ <div class="-mx-2 -my-1.5 flex gap-1">
+ {#each actions as action}
+ {@const color = action?.color ?? colorClassPart}
+ <button
+ type="button"
+ on:click={() => actionClicked(action.id)}
+ class="rounded-md
+ bg-{color}-50
+ px-2 py-1.5 text-sm font-medium
+ text-{color}-800
+ hover:bg-{color}-100
+ focus:outline-none focus:ring-2
+ focus:ring-{color}-600
+ focus:ring-offset-2
+ focus:ring-offset-{color}-50"
+ >
+ {action.text}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <div class="ml-auto pl-3">
+ <div class="-mx-1.5 -my-1.5">
+ <button
+ type="button"
+ on:click={() => close()}
+ class="inline-flex rounded-md bg-{colorClassPart}-50 p-1.5 text-{colorClassPart}-500 hover:bg-{colorClassPart}-100 focus:outline-none focus:ring-2 focus:ring-{colorClassPart}-600 focus:ring-offset-2 focus:ring-offset-{colorClassPart}-50"
+ >
+ <span class="sr-only">Dismiss</span>
+ <XMarkIcon />
+ </button>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+{/if}
diff --git a/code/app/src/lib/components/button.svelte b/code/app/src/lib/components/button.svelte
new file mode 100644
index 0000000..cbc09e2
--- /dev/null
+++ b/code/app/src/lib/components/button.svelte
@@ -0,0 +1,103 @@
+<script context="module" lang="ts">
+ export type ButtonKind = "primary" | "secondary" | "white";
+ export type ButtonSize = "sm" | "lg" | "md" | "xl";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+
+ import { SpinnerIcon } from "./icons";
+
+ export let kind = "primary" as ButtonKind;
+ export let size = "md" as ButtonSize;
+ export let type: "button" | "submit" | "reset" = "button";
+ export let id: string | undefined = undefined;
+ export let tabindex: string | undefined = undefined;
+ export let style: string | undefined = undefined;
+ export let title: string | undefined = undefined;
+ export let disabled: boolean | null = false;
+ export let href: string | undefined = undefined;
+ export let text: string;
+ export let loading = false;
+ export let fullWidth = false;
+ export let _pwKey: string | undefined = undefined;
+
+ let sizeClasses = "";
+ let kindClasses = "";
+ let spinnerTextClasses = "";
+ let spinnerMarginClasses = "";
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || loading || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ } as any;
+
+ $: switch (size) {
+ case "sm":
+ sizeClasses = "px-2.5 py-1.5 text-xs";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "md":
+ sizeClasses = "px-3 py-2 text-sm";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "lg":
+ sizeClasses = "px-3 py-2 text-lg";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "xl":
+ sizeClasses = "px-6 py-3 text-xl";
+ spinnerMarginClasses = "mr-2";
+ break;
+ }
+
+ $: switch (kind) {
+ case "secondary":
+ kindClasses = "border-transparent text-teal-800 bg-teal-100 hover:bg-teal-200 focus:ring-teal-500";
+ spinnerTextClasses = "teal-800";
+ break;
+ case "primary":
+ kindClasses = "border-transparent text-teal-900 bg-teal-300 hover:bg-teal-400 focus:ring-teal-500";
+ spinnerTextClasses = "text-teal-900";
+ break;
+ case "white":
+ kindClasses = "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-gray-400";
+ spinnerTextClasses = "text-gray-700";
+ break;
+ }
+</script>
+
+{#if href}
+ <a
+ use:pwKey={_pwKey}
+ {...shared_props}
+ {href}
+ class="{sizeClasses} {kindClasses} {loading ? 'disabled:' : ''} {$$restProps.class ?? ''} {fullWidth
+ ? 'w-full justify-center'
+ : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </a>
+{:else}
+ <button
+ use:pwKey={_pwKey}
+ {...shared_props}
+ on:click
+ class="{sizeClasses} {kindClasses} {$$restProps.class ?? ''}
+ {fullWidth
+ ? 'w-full justify-center'
+ : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </button>
+{/if}
diff --git a/code/app/src/lib/components/checkbox.svelte b/code/app/src/lib/components/checkbox.svelte
new file mode 100644
index 0000000..b2fcddb
--- /dev/null
+++ b/code/app/src/lib/components/checkbox.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { random_string } from "$lib/helpers";
+
+ export let label: string;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let disabled: boolean | null = null;
+ export let checked: boolean;
+ export let _pwKey: string | undefined = undefined;
+</script>
+
+<div class="flex items-center">
+ <input
+ {name}
+ use:pwKey={_pwKey}
+ {disabled}
+ {id}
+ type="checkbox"
+ bind:checked
+ class="h-4 w-4 text-teal-600 focus:ring-teal-500 border-gray-300 rounded"
+ />
+ <label for={id} class="ml-2 block text-sm text-gray-900">{label}</label>
+</div>
diff --git a/code/app/src/lib/components/icons/adjustments.svelte b/code/app/src/lib/components/icons/adjustments.svelte
new file mode 100644
index 0000000..83bda27
--- /dev/null
+++ b/code/app/src/lib/components/icons/adjustments.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/bars-3-center-left.svelte b/code/app/src/lib/components/icons/bars-3-center-left.svelte
new file mode 100644
index 0000000..785ece3
--- /dev/null
+++ b/code/app/src/lib/components/icons/bars-3-center-left.svelte
@@ -0,0 +1,15 @@
+<svg
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/calendar.svelte b/code/app/src/lib/components/icons/calendar.svelte
new file mode 100644
index 0000000..e0053ee
--- /dev/null
+++ b/code/app/src/lib/components/icons/calendar.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/check-circle.svelte b/code/app/src/lib/components/icons/check-circle.svelte
new file mode 100644
index 0000000..e30778e
--- /dev/null
+++ b/code/app/src/lib/components/icons/check-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/chevron-up-down.svelte b/code/app/src/lib/components/icons/chevron-up-down.svelte
new file mode 100644
index 0000000..c07aed5
--- /dev/null
+++ b/code/app/src/lib/components/icons/chevron-up-down.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/database.svelte b/code/app/src/lib/components/icons/database.svelte
new file mode 100644
index 0000000..6ffdadb
--- /dev/null
+++ b/code/app/src/lib/components/icons/database.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/exclamation-circle.svelte b/code/app/src/lib/components/icons/exclamation-circle.svelte
new file mode 100644
index 0000000..2ce79b1
--- /dev/null
+++ b/code/app/src/lib/components/icons/exclamation-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/exclamation-triangle.svelte b/code/app/src/lib/components/icons/exclamation-triangle.svelte
new file mode 100644
index 0000000..8d807db
--- /dev/null
+++ b/code/app/src/lib/components/icons/exclamation-triangle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/folder-open.svelte b/code/app/src/lib/components/icons/folder-open.svelte
new file mode 100644
index 0000000..409c8e2
--- /dev/null
+++ b/code/app/src/lib/components/icons/folder-open.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/home.svelte b/code/app/src/lib/components/icons/home.svelte
new file mode 100644
index 0000000..ee8305d
--- /dev/null
+++ b/code/app/src/lib/components/icons/home.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/index.ts b/code/app/src/lib/components/icons/index.ts
new file mode 100644
index 0000000..8c24873
--- /dev/null
+++ b/code/app/src/lib/components/icons/index.ts
@@ -0,0 +1,41 @@
+import XIcon from "./x.svelte";
+import MenuIcon from "./menu.svelte";
+import AdjustmentsIcon from "./adjustments.svelte";
+import DatabaseIcon from "./database.svelte";
+import HomeIcon from "./home.svelte";
+import InformationCircleIcon from "./information-circle.svelte";
+import ExclamationTriangleIcon from "./exclamation-triangle.svelte";
+import XCircleIcon from "./x-circle.svelte";
+import CheckCircleIcon from "./check-circle.svelte";
+import XMarkIcon from "./x-mark.svelte";
+import SpinnerIcon from "./spinner.svelte";
+import ExclamationCircleIcon from "./exclamation-circle.svelte";
+import ChevronUpDownIcon from "./chevron-up-down.svelte";
+import MagnifyingGlassIcon from "./magnifying-glass.svelte";
+import Bars3CenterLeftIcon from "./bars-3-center-left.svelte";
+import CalendarIcon from "./calendar.svelte";
+import FolderOpenIcon from "./folder-open.svelte";
+import MegaphoneIcon from "./megaphone.svelte";
+import QueueListIcon from "./queue-list.svelte";
+
+export {
+ QueueListIcon,
+ FolderOpenIcon,
+ MegaphoneIcon,
+ CalendarIcon,
+ Bars3CenterLeftIcon,
+ MagnifyingGlassIcon,
+ ChevronUpDownIcon,
+ XIcon,
+ MenuIcon,
+ HomeIcon,
+ DatabaseIcon,
+ AdjustmentsIcon,
+ InformationCircleIcon,
+ ExclamationTriangleIcon,
+ ExclamationCircleIcon,
+ XCircleIcon,
+ CheckCircleIcon,
+ XMarkIcon,
+ SpinnerIcon
+} \ No newline at end of file
diff --git a/code/app/src/lib/components/icons/information-circle.svelte b/code/app/src/lib/components/icons/information-circle.svelte
new file mode 100644
index 0000000..68dbc60
--- /dev/null
+++ b/code/app/src/lib/components/icons/information-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M19 10.5a8.5 8.5 0 11-17 0 8.5 8.5 0 0117 0zM8.25 9.75A.75.75 0 019 9h.253a1.75 1.75 0 011.709 2.13l-.46 2.066a.25.25 0 00.245.304H11a.75.75 0 010 1.5h-.253a1.75 1.75 0 01-1.709-2.13l.46-2.066a.25.25 0 00-.245-.304H9a.75.75 0 01-.75-.75zM10 7a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/magnifying-glass.svelte b/code/app/src/lib/components/icons/magnifying-glass.svelte
new file mode 100644
index 0000000..f8fdb6e
--- /dev/null
+++ b/code/app/src/lib/components/icons/magnifying-glass.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/megaphone.svelte b/code/app/src/lib/components/icons/megaphone.svelte
new file mode 100644
index 0000000..7ada5f3
--- /dev/null
+++ b/code/app/src/lib/components/icons/megaphone.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/menu.svelte b/code/app/src/lib/components/icons/menu.svelte
new file mode 100644
index 0000000..471d85f
--- /dev/null
+++ b/code/app/src/lib/components/icons/menu.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 6h16M4 12h16M4 18h16"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/queue-list.svelte b/code/app/src/lib/components/icons/queue-list.svelte
new file mode 100644
index 0000000..6148394
--- /dev/null
+++ b/code/app/src/lib/components/icons/queue-list.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/spinner.svelte b/code/app/src/lib/components/icons/spinner.svelte
new file mode 100644
index 0000000..80cc57c
--- /dev/null
+++ b/code/app/src/lib/components/icons/spinner.svelte
@@ -0,0 +1,20 @@
+<svg
+ class="-ml-1 mr-3 h-5 w-5 animate-spin {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+>
+ <circle
+ class="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ stroke-width="4"
+ />
+ <path
+ class="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x-circle.svelte b/code/app/src/lib/components/icons/x-circle.svelte
new file mode 100644
index 0000000..3793b5a
--- /dev/null
+++ b/code/app/src/lib/components/icons/x-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x-mark.svelte b/code/app/src/lib/components/icons/x-mark.svelte
new file mode 100644
index 0000000..fd1c6a1
--- /dev/null
+++ b/code/app/src/lib/components/icons/x-mark.svelte
@@ -0,0 +1,11 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x.svelte b/code/app/src/lib/components/icons/x.svelte
new file mode 100644
index 0000000..6125ab8
--- /dev/null
+++ b/code/app/src/lib/components/icons/x.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+</svg>
diff --git a/code/app/src/lib/components/index.ts b/code/app/src/lib/components/index.ts
new file mode 100644
index 0000000..a81e0c3
--- /dev/null
+++ b/code/app/src/lib/components/index.ts
@@ -0,0 +1,15 @@
+import Alert from "./alert.svelte";
+import Button from "./button.svelte";
+import Checkbox from "./checkbox.svelte";
+import Input from "./input.svelte";
+import LocaleSwitcher from "./locale-switcher.svelte";
+import Switch from "./switch.svelte";
+
+export {
+ Alert,
+ Button,
+ Checkbox,
+ Input,
+ LocaleSwitcher,
+ Switch
+} \ No newline at end of file
diff --git a/code/app/src/lib/components/input.svelte b/code/app/src/lib/components/input.svelte
new file mode 100644
index 0000000..c0ed654
--- /dev/null
+++ b/code/app/src/lib/components/input.svelte
@@ -0,0 +1,103 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { random_string } from "$lib/helpers";
+ import { ExclamationCircleIcon } from "./icons";
+
+ export let label: string | undefined = undefined;
+ export let type: string = "text";
+ export let autocomplete: string | undefined = undefined;
+ export let required: boolean | undefined = undefined;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let placeholder: string | undefined = undefined;
+ export let helpText: string | undefined = undefined;
+ export let errorText: string | undefined = undefined;
+ export let disabled = false;
+ export let hideLabel = false;
+ export let cornerHint: string | undefined = undefined;
+ export let icon: any = undefined;
+ export let addon: string | undefined = undefined;
+ export let value: string | undefined;
+ export let wrapperClass: string | undefined = undefined;
+ export let _pwKey: string | undefined = undefined;
+
+ $: ariaErrorDescribedBy = id + "__" + "error";
+ $: attributes = {
+ "aria-describedby": errorText ? ariaErrorDescribedBy : null,
+ "aria-invalid": errorText ? "true" : null,
+ disabled: disabled || null,
+ autocomplete: autocomplete || null,
+ required: required || null,
+ } as any;
+ $: hasBling = icon || addon || errorText;
+ const defaultColorClass = "border-gray-300 focus:border-teal-500 focus:ring-teal-500";
+ let colorClass = defaultColorClass;
+ $: if (errorText) {
+ colorClass = "placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500 text-red-900 pr-10 border-red-300";
+ } else {
+ colorClass = defaultColorClass;
+ }
+
+ function typeAction(node: HTMLInputElement) {
+ node.type = type;
+ }
+</script>
+
+<div class={wrapperClass}>
+ {#if label && !cornerHint && !hideLabel}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ </label>
+ {:else if cornerHint && !hideLabel}
+ <div class="flex justify-between">
+ {#if label}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ </label>
+ {/if}
+ <span class="text-sm text-gray-500">
+ {cornerHint}
+ </span>
+ </div>
+ {/if}
+ <div class="mt-1 {hasBling ? 'relative rounded-md' : ''} {addon ? 'flex' : ''}">
+ {#if icon}
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <svelte:component this={icon} class={errorText ? "text-red-500" : "text-gray-400"} />
+ </div>
+ {:else if addon}
+ <div class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
+ <span class="text-gray-500 sm:text-sm">{addon}</span>
+ </div>
+ {/if}
+ <input
+ use:typeAction
+ use:pwKey={_pwKey}
+ {name}
+ {id}
+ {...attributes}
+ bind:value
+ class="block w-full rounded-md shadow-sm sm:text-sm
+ {colorClass}
+ {disabled ? 'disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500' : ''}
+ {addon ? 'min-w-0 flex-1 rounded-none rounded-r-md' : ''}
+ {icon ? 'pl-10' : ''}"
+ {placeholder}
+ />
+ {#if errorText}
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ <ExclamationCircleIcon class="text-red-500" />
+ </div>
+ {/if}
+ </div>
+ {#if helpText && !errorText}
+ <p class="mt-2 text-sm text-gray-500">
+ {helpText}
+ </p>
+ {/if}
+ {#if errorText}
+ <p class="mt-2 text-sm text-red-600" id={ariaErrorDescribedBy}>
+ {errorText}
+ </p>
+ {/if}
+</div>
diff --git a/code/app/src/lib/components/locale-switcher.svelte b/code/app/src/lib/components/locale-switcher.svelte
new file mode 100644
index 0000000..f880bfb
--- /dev/null
+++ b/code/app/src/lib/components/locale-switcher.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { browser } from "$app/environment";
+ import { page } from "$app/stores";
+ import { CookieNames } from "$lib/configuration";
+ import { setLocale, locale } from "$lib/i18n/i18n-svelte";
+ import type { Locales } from "$lib/i18n/i18n-types";
+ import { locales } from "$lib/i18n/i18n-util";
+ import { loadLocaleAsync } from "$lib/i18n/i18n-util.async";
+ import Cookies from "js-cookie";
+
+ export let _pwKey: string | undefined = undefined;
+
+ async function switch_locale(newLocale: Locales) {
+ if (!newLocale || $locale === newLocale) return;
+ await loadLocaleAsync(newLocale);
+ setLocale(newLocale);
+ document.querySelector("html")?.setAttribute("lang", newLocale);
+ Cookies.set(CookieNames.locale, newLocale, {
+ sameSite: "strict",
+ domain: location.hostname,
+ });
+ console.log("Switched to: " + newLocale);
+ }
+
+ function on_change(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ switch_locale(target.options[target.selectedIndex].value as Locales);
+ }
+
+ $: if (browser) {
+ switch_locale($page.params.lang as Locales);
+ }
+
+ function get_locale_name(iso: string) {
+ switch (iso) {
+ case "nb": {
+ return "Norsk Bokmål";
+ }
+ case "en": {
+ return "English";
+ }
+ }
+ }
+</script>
+
+<select
+ use:pwKey={_pwKey}
+ on:change={on_change}
+ class="mt-1 mr-1 block border-none py-2 pl-3 pr-10 text-base rounded-md right-0 absolute focus:outline-none focus:ring-teal-500 sm:text-sm"
+>
+ {#each locales as aLocale}
+ <option value={aLocale}>{get_locale_name(aLocale)}</option>
+ {/each}
+</select>
diff --git a/code/app/src/lib/components/switch.svelte b/code/app/src/lib/components/switch.svelte
new file mode 100644
index 0000000..16da23a
--- /dev/null
+++ b/code/app/src/lib/components/switch.svelte
@@ -0,0 +1,143 @@
+<script context="module" lang="ts">
+ export type SwitchType = "short" | "icon" | "default";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+
+
+ export let enabled = false;
+ export let type: SwitchType = "default";
+ export let srText = "Use setting";
+ export let label: string | undefined = undefined;
+ export let description: string | undefined = undefined;
+ export let rightAlignedLabelDescription = false;
+ export let _pwKey:string|undefined = undefined;
+
+ $: colorClass = enabled
+ ? "bg-teal-600 focus:ring-teal-500"
+ : "bg-gray-200 focus:ring-teal-500";
+ $: translateClass = enabled ? "translate-x-5" : "translate-x-0";
+ $: hasLabelOrDescription = label || description;
+
+ function toggle() {
+ enabled = !enabled;
+ }
+</script>
+
+<div
+ class="{hasLabelOrDescription
+ ? 'flex items-center'
+ : ''} {rightAlignedLabelDescription ? '' : 'justify-between'}"
+>
+ {#if hasLabelOrDescription && !rightAlignedLabelDescription}
+ <span class="flex flex-grow flex-col">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+ {#if type === "short"}
+ <button
+ type="button"
+ class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ aria-hidden="true"
+ class="pointer-events-none absolute h-full w-full rounded-md"
+ />
+ <span
+ aria-hidden="true"
+ class="{colorClass} pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"
+ />
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out"
+ />
+ </button>
+ {:else if type === "icon"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ class="{translateClass} pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ >
+ <span
+ class="{enabled
+ ? 'opacity-0 ease-out duration-100'
+ : 'opacity-100 ease-in duration-200'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg
+ class="h-3 w-3 text-gray-400"
+ fill="none"
+ viewBox="0 0 12 12"
+ >
+ <path
+ d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </span>
+ <span
+ class="{enabled
+ ? 'opacity-100 ease-in duration-200'
+ : 'opacity-0 ease-out duration-100'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg
+ class="h-3 w-3 text-indigo-600"
+ fill="currentColor"
+ viewBox="0 0 12 12"
+ >
+ <path
+ d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
+ />
+ </svg>
+ </span>
+ </span>
+ </button>
+ {:else if type === "default"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ />
+ </button>
+ {/if}
+ {#if hasLabelOrDescription && rightAlignedLabelDescription}
+ <span class="ml-3">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+</div>
diff --git a/code/app/src/lib/configuration.ts b/code/app/src/lib/configuration.ts
new file mode 100644
index 0000000..5a6a1bf
--- /dev/null
+++ b/code/app/src/lib/configuration.ts
@@ -0,0 +1,60 @@
+export const BASE_DOMAIN = "dev.greatoffice.app";
+export const DEV_BASE_DOMAIN = "http://localhost";
+export const API_ADDRESS = "https://api." + BASE_DOMAIN;
+export const DEV_API_ADDRESS = "http://localhost:5000";
+export const SECONDS_BETWEEN_SESSION_CHECK = 600;
+
+export function api_base(path: string = ""): string {
+ return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : "");
+}
+
+export function is_development(): boolean {
+ return import.meta.env.DEV;
+}
+
+export function is_testing(): boolean {
+ return import.meta.env.VITE_TESTING;
+}
+
+export function is_debug(): boolean {
+ return localStorage.getItem(StorageKeys.debug) !== "true";
+}
+
+export const CookieNames = {
+ theme: "go_theme",
+ locale: "go_locale",
+ session: "go_session"
+};
+
+export function get_test_context(): TestContext {
+ return {
+ user: {
+ username: import.meta.env.VITE_TEST_USERNAME,
+ password: import.meta.env.VITE_TEST_PASSWORD
+ }
+ }
+}
+
+export interface TestContext {
+ user: {
+ username: string,
+ password: string
+ }
+}
+
+export const QueryKeys = {
+ labels: "labels",
+ categories: "categories",
+ entries: "entries",
+};
+
+export const StorageKeys = {
+ session: "sessionData",
+ theme: "theme",
+ debug: "debug",
+ categories: "categories",
+ labels: "labels",
+ entries: "entries",
+ stopwatch: "stopwatchState",
+ logLevel: "logLevel"
+}; \ No newline at end of file
diff --git a/code/app/src/lib/helpers.ts b/code/app/src/lib/helpers.ts
new file mode 100644
index 0000000..3fa1653
--- /dev/null
+++ b/code/app/src/lib/helpers.ts
@@ -0,0 +1,497 @@
+import { browser } from "$app/environment";
+import type { TimeEntryDto } from "$lib/models/TimeEntryDto";
+import type { UnwrappedEntryDateTime } from "$lib/models/UnwrappedEntryDateTime";
+import { logInfo } from "$lib/logger";
+import { Temporal } from "temporal-polyfill";
+
+export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i);
+export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm);
+export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/);
+
+export function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> {
+ if (unsorted.length < 1) return unsorted;
+ const byStart = unsorted.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start));
+ });
+
+ return byStart.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop));
+ });
+}
+
+export function get_element_by_pw_key(key: string): HTMLElement | null {
+ return document.querySelector("[pw-key='" + key + "']");
+}
+
+export function get_pw_key_selector(key: string): string {
+ return "[pw-key='" + key + "']";
+}
+
+export function is_email(value: string): boolean {
+ return EMAIL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_url(value: string): boolean {
+ return URL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_norwegian_phone_number(value: string): boolean {
+ if (value.length < 8 || value.length > 12) {
+ return false;
+ }
+ return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value));
+}
+
+export function get_cookie(name: string) {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(";").shift();
+}
+
+export function set_cookie(name: string, value: string, baseDomain = window.location.hostname) {
+ document.cookie = name + "=" + encodeURIComponent(value) + (baseDomain ? ";domain=" + baseDomain : "");
+}
+
+export function unwrap_date_time_from_entry(entry: TimeEntryDto): UnwrappedEntryDateTime {
+ if (!entry) throw new Error("entry was undefined");
+ const currentTimeZone = Temporal.Now.timeZone().id;
+ const startInstant = Temporal.Instant.from(entry.start).toZonedDateTimeISO(currentTimeZone);
+ const stopInstant = Temporal.Instant.from(entry.stop).toZonedDateTimeISO(currentTimeZone);
+
+ return {
+ start_date: startInstant.toPlainDate(),
+ stop_date: stopInstant.toPlainDate(),
+ start_time: startInstant.toPlainTime(),
+ stop_time: stopInstant.toPlainTime(),
+ duration: Temporal.Duration.from({
+ hours: stopInstant.hour,
+ minutes: stopInstant.minute,
+ }).subtract(Temporal.Duration.from({
+ hours: startInstant.hour,
+ minutes: startInstant.minute,
+ })),
+ };
+}
+
+
+export function is_guid(value: string): boolean {
+ if (!value) {
+ return false;
+ }
+ if (value[0] === "{") {
+ value = value.substring(1, value.length - 1);
+ }
+ return GUID_REGEX.test(value);
+}
+
+export function is_empty_object(obj: object): boolean {
+ return obj !== void 0 && Object.keys(obj).length > 0;
+}
+
+export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> {
+ let start = 0;
+ let merge = [];
+
+ while (start < a.length) {
+
+ if (a[start] === b[start]) {
+ //pushing the merged objects into array
+ merge.push({ ...a[start], ...b[start] });
+ }
+ //incrementing start value
+ start = start + 1;
+ }
+ return merge;
+}
+
+export function set_favicon(url: string) {
+ // Find the current favicon element
+ const favicon = document.querySelector("link[rel=\"icon\"]") as HTMLLinkElement;
+ if (favicon) {
+ // Update the new link
+ favicon.href = url;
+ } else {
+ // Create new `link`
+ const link = document.createElement("link");
+ link.rel = "icon";
+ link.href = url;
+
+ // Append to the `head` element
+ document.head.appendChild(link);
+ }
+}
+export function no_type_check(x: any) {
+ return x;
+}
+export function capitalise(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export function set_emoji_favicon(emoji: string) {
+ // Create a canvas element
+ const canvas = document.createElement("canvas");
+ canvas.height = 64;
+ canvas.width = 64;
+
+ // Get the canvas context
+ const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+ context.font = "64px serif";
+ context.fillText(emoji, 0, 64);
+
+ // Get the custom URL
+ const url = canvas.toDataURL();
+
+ // Update the favicon
+ set_favicon(url);
+}
+
+
+// https://stackoverflow.com/a/48400665/11961742
+export function seconds_to_hour_minute_string(seconds: number, hourChar = "h", minuteChar = "m") {
+ const hours = Math.floor(seconds / (60 * 60));
+ seconds -= hours * (60 * 60);
+ const minutes = Math.floor(seconds / 60);
+ return hours + "h" + minutes + "m";
+}
+
+export function seconds_to_hour_minute(seconds: number) {
+ const hours = Math.floor(seconds / (60 * 60));
+ seconds -= hours * (60 * 60);
+ const minutes = Math.floor(seconds / 60);
+ return { hours, minutes };
+}
+
+export function get_query_string(params: any = {}): string {
+ const map = Object.keys(params).reduce((arr: Array<string>, key: string) => {
+ if (params[key] !== undefined) {
+ return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
+ }
+ return arr;
+ }, [] as any);
+
+ if (map.length) {
+ return `?${map.join("&")}`;
+ }
+
+ return "";
+}
+
+export function make_url(url: string, params: object): string {
+ return `${url}${get_query_string(params)}`;
+}
+
+export function noop() {
+}
+
+export async function run_async(functionToRun: Function): Promise<any> {
+ return new Promise((greatSuccess, graveFailure) => {
+ try {
+ greatSuccess(functionToRun());
+ } catch (exception) {
+ graveFailure(exception);
+ }
+ });
+}
+
+// https://stackoverflow.com/a/45215694/11961742
+export function get_selected_options(domElement: HTMLSelectElement): Array<string> {
+ const ret = [];
+
+ // fast but not universally supported
+ if (domElement.selectedOptions !== undefined) {
+ for (let i = 0; i < domElement.selectedOptions.length; i++) {
+ ret.push(domElement.selectedOptions[i].value);
+ }
+
+ // compatible, but can be painfully slow
+ } else {
+ for (let i = 0; i < domElement.options.length; i++) {
+ if (domElement.options[i].selected) {
+ ret.push(domElement.options[i].value);
+ }
+ }
+ }
+ return ret;
+}
+
+export function random_string(length: number): string {
+ if (!length) {
+ throw new Error("length is undefined");
+ }
+ let result = "";
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ const charactersLength = characters.length;
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+}
+
+interface CreateElementOptions {
+ name: string,
+ properties?: object,
+ children?: Array<HTMLElement | Function | Node>
+}
+
+export function create_element_from_object(elementOptions: CreateElementOptions): HTMLElement {
+ return create_element(elementOptions.name, elementOptions.properties, elementOptions.children);
+}
+
+export function create_element(name: string, properties?: object, children?: Array<HTMLElement | any>): HTMLElement {
+ if (!name || name.length < 1) {
+ throw new Error("name is required");
+ }
+ const node = document.createElement(name);
+ if (properties) {
+ for (const [key, value] of Object.entries(properties)) {
+ // @ts-ignore
+ node[key] = value;
+ }
+ }
+
+ if (children && children.length > 0) {
+ let actualChildren = children;
+ if (typeof children === "function") {
+ // @ts-ignore
+ actualChildren = children();
+ }
+ for (const child of actualChildren) {
+ node.appendChild(child as Node);
+ }
+ }
+ return node;
+}
+
+export function get_element_position(element: HTMLElement | any) {
+ if (!element) return { x: 0, y: 0 };
+ let x = 0;
+ let y = 0;
+ while (true) {
+ x += element.offsetLeft;
+ y += element.offsetTop;
+ if (element.offsetParent === null) {
+ break;
+ }
+ element = element.offsetParent;
+ }
+ return { x, y };
+}
+
+export function restrict_input_to_numbers(element: HTMLElement, specials: Array<string> = [], mergeSpecialsWithDefaults: boolean = false): void {
+ if (element) {
+ element.addEventListener("keydown", (e) => {
+ const defaultSpecials = ["Backspace", "ArrowLeft", "ArrowRight", "Tab"];
+ let keys = specials.length > 0 ? specials : defaultSpecials;
+ if (mergeSpecialsWithDefaults && specials) {
+ keys = [...specials, ...defaultSpecials];
+ }
+ if (keys.indexOf(e.key) !== -1) {
+ return;
+ }
+ if (isNaN(parseInt(e.key))) {
+ e.preventDefault();
+ }
+ });
+ }
+}
+
+export function element_has_focus(element: HTMLElement): boolean {
+ return element === document.activeElement;
+}
+
+export function move_focus(element: HTMLElement): void {
+ if (!element) {
+ element = document.getElementsByTagName("body")[0];
+ }
+ element.focus();
+ // @ts-ignore
+ if (!element_has_focus(element)) {
+ element.setAttribute("tabindex", "-1");
+ element.focus();
+ }
+}
+
+export function get_url_parameter(name: string): string {
+ // @ts-ignore
+ return new RegExp("[?&]" + name + "=([^&#]*)")?.exec(window.location.href)[1];
+}
+
+export function update_url_parameter(param: string, newVal: string): void {
+ let newAdditionalURL = "";
+ let tempArray = location.href.split("?");
+ const baseURL = tempArray[0];
+ const additionalURL = tempArray[1];
+ let temp = "";
+ if (additionalURL) {
+ tempArray = additionalURL.split("&");
+ for (let i = 0; i < tempArray.length; i++) {
+ if (tempArray[i].split("=")[0] !== param) {
+ newAdditionalURL += temp + tempArray[i];
+ temp = "&";
+ }
+ }
+ }
+ const rows_txt = temp + "" + param + "=" + newVal;
+ const newUrl = baseURL + "?" + newAdditionalURL + rows_txt;
+ window.history.replaceState("", "", newUrl);
+}
+
+
+export function get_style_string(rules: CSSRuleList) {
+ let styleString = "";
+ for (const [key, value] of Object.entries(rules)) {
+ styleString += key + ":" + value + ";";
+ }
+ return styleString;
+}
+
+export function parse_iso_local(s: string) {
+ const b = s.split(/\D/);
+ //@ts-ignore
+ return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
+}
+
+export function resolve_references(json: any) {
+ if (!json) return;
+ if (typeof json === "string") {
+ json = JSON.parse(json ?? "{}");
+ }
+ const byid = {}, refs = [];
+ json = function recurse(obj, prop, parent) {
+ if (typeof obj !== "object" || !obj) {
+ return obj;
+ }
+ if (Object.prototype.toString.call(obj) === "[object Array]") {
+ for (let i = 0; i < obj.length; i++) {
+ if (typeof obj[i] !== "object" || !obj[i]) {
+ continue;
+ } else if ("$ref" in obj[i]) {
+ // @ts-ignore
+ obj[i] = recurse(obj[i], i, obj);
+ } else {
+ obj[i] = recurse(obj[i], prop, obj);
+ }
+ }
+ return obj;
+ }
+ if ("$ref" in obj) {
+ let ref = obj.$ref;
+ if (ref in byid) {
+ // @ts-ignore
+ return byid[ref];
+ }
+ refs.push([parent, prop, ref]);
+ return;
+ } else if ("$id" in obj) {
+ let id = obj.$id;
+ delete obj.$id;
+ if ("$values" in obj) {
+ obj = obj.$values.map(recurse);
+ } else {
+ for (let prop2 in obj) {
+ // @ts-ignore
+ obj[prop2] = recurse(obj[prop2], prop2, obj);
+ }
+ }
+ // @ts-ignore
+ byid[id] = obj;
+ }
+ return obj;
+ }(json);
+ for (let i = 0; i < refs.length; i++) {
+ let ref = refs[i];
+ // @ts-ignore
+ ref[0][ref[1]] = byid[ref[2]];
+ }
+ return json;
+}
+
+export function get_random_int(min: number, max: number): number {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function to_readable_bytes(bytes: number): string {
+ const s = ["bytes", "kB", "MB", "GB", "TB", "PB"];
+ const e = Math.floor(Math.log(bytes) / Math.log(1024));
+ return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e];
+}
+
+export function can_use_dom(): boolean {
+ return !!(typeof window !== "undefined" && window.document && window.document.createElement);
+}
+
+export function session_storage_remove_regex(regex: RegExp): void {
+ if (!browser) {
+ logInfo("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ let n = sessionStorage.length;
+ while (n--) {
+ const key = sessionStorage.key(n);
+ if (key && regex.test(key)) {
+ sessionStorage.removeItem(key);
+ }
+ }
+}
+
+export function local_storage_remove_regex(regex: RegExp): void {
+ if (!browser) {
+ logInfo("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ let n = localStorage.length;
+ while (n--) {
+ const key = localStorage.key(n);
+ if (key && regex.test(key)) {
+ localStorage.removeItem(key);
+ }
+ }
+}
+
+export function session_storage_set_json(key: string, value: object): void {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ sessionStorage.setItem(key, JSON.stringify(value));
+}
+
+export function session_storage_get_json(key: string): object {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return {};
+ }
+ return JSON.parse(sessionStorage.getItem(key) ?? "{}");
+}
+
+export function local_storage_set_json(key: string, value: object): void {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+export function local_storage_get_json(key: string): object {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return {};
+ }
+ return JSON.parse(localStorage.getItem(key) ?? "{}");
+}
+
+export function get_hash_code(value: string): number | undefined {
+ let hash = 0;
+ if (value.length === 0) {
+ return;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const char = value.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash |= 0;
+ }
+ return hash;
+}
diff --git a/code/app/src/lib/i18n/en/app/index.ts b/code/app/src/lib/i18n/en/app/index.ts
new file mode 100644
index 0000000..7cd05ee
--- /dev/null
+++ b/code/app/src/lib/i18n/en/app/index.ts
@@ -0,0 +1,5 @@
+import type { BaseTranslation } from '../../i18n-types'
+
+const en_app: BaseTranslation = {}
+
+export default en_app \ No newline at end of file
diff --git a/code/app/src/lib/i18n/en/index.ts b/code/app/src/lib/i18n/en/index.ts
new file mode 100644
index 0000000..e084a6c
--- /dev/null
+++ b/code/app/src/lib/i18n/en/index.ts
@@ -0,0 +1,50 @@
+import type { BaseTranslation } from "../i18n-types";
+
+const en: BaseTranslation = {
+ or: "Or",
+ emailAddress: "Email address",
+ password: "Password",
+ pageNotFound: "Page not found",
+ noInternet: "It seems like your device does not have a internet connection, please check your connection.",
+ reset: "Reset",
+ of: "{0} of {1}",
+ isRequired: "{0} is required",
+ submit: "Submit",
+ success: "Success",
+ tryAgainSoon: "Try again soon",
+ createANewAccount: "Create a new account",
+ unexpectedError: "An unexpected error occured",
+ notFound: "Not found",
+ documentation: "Documentation",
+ tos: "Terms of service",
+ privacyPolicy: "Privacy policy",
+ signIntoYourAccount: "Sign into your account",
+ signInPage: {
+ notMyComputer: "This is not my computer",
+ resetPassword: "Reset password",
+ yourPasswordIsUpdated: "Your password is updated",
+ signIn: "Sign In",
+ yourNewPasswordIsApplied: "Your new password is applied",
+ signInBelow: "Sign in below",
+ yourAccountIsDisabled: "Your account is disabled",
+ contactYourAdminIfDisabled: "Contact your administrator if this feels wrong",
+ youHaveReachedInactivityLimit: "You've reached the hidden inactivity limit",
+ feelFreeToSignInAgain: "Feel free to sign in again"
+ },
+ signUpPage: {
+ createYourNewAccount: "Create your new account",
+ },
+ resetPasswordPage: {
+ setANewPassword: "Set a new password",
+ expired: "Expired",
+ requestHasExpired: "Your request has expired",
+ requestANewReset: "Request a new reset",
+ newPassword: "New password",
+ requestSentMessage: "If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.",
+ requestAPasswordReset: "Request a password reset",
+ requestNotFound: "Your request was not found",
+ submitANewRequestBelow: "Submit a new reset request below"
+ }
+};
+
+export default en;
diff --git a/code/app/src/lib/i18n/formatters.ts b/code/app/src/lib/i18n/formatters.ts
new file mode 100644
index 0000000..5232b7d
--- /dev/null
+++ b/code/app/src/lib/i18n/formatters.ts
@@ -0,0 +1,13 @@
+import { capitalise } from '$lib/helpers'
+import type { FormattersInitializer } from 'typesafe-i18n'
+import type { Locales, Formatters } from './i18n-types'
+
+export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
+
+ const formatters: Formatters = {
+ // add your formatter functions here
+ capitalise: (value: string) => capitalise(value)
+ }
+
+ return formatters
+}
diff --git a/code/app/src/lib/i18n/i18n-svelte.ts b/code/app/src/lib/i18n/i18n-svelte.ts
new file mode 100644
index 0000000..6cdffb3
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-svelte.ts
@@ -0,0 +1,12 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initI18nSvelte } from 'typesafe-i18n/svelte'
+import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales } from './i18n-util'
+
+const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export { locale, LL, setLocale }
+
+export default LL
diff --git a/code/app/src/lib/i18n/i18n-types.ts b/code/app/src/lib/i18n/i18n-types.ts
new file mode 100644
index 0000000..0df6d1a
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-types.ts
@@ -0,0 +1,359 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n'
+
+export type BaseTranslation = BaseTranslationType & DisallowNamespaces
+export type BaseLocale = 'en'
+
+export type Locales =
+ | 'en'
+ | 'nb'
+
+export type Translation = RootTranslation & DisallowNamespaces
+
+export type Translations = RootTranslation &
+{
+ app: NamespaceAppTranslation
+}
+
+type RootTranslation = {
+ /**
+ * O​r
+ */
+ or: string
+ /**
+ * E​m​a​i​l​ ​a​d​d​r​e​s​s
+ */
+ emailAddress: string
+ /**
+ * P​a​s​s​w​o​r​d
+ */
+ password: string
+ /**
+ * P​a​g​e​ ​n​o​t​ ​f​o​u​n​d
+ */
+ pageNotFound: string
+ /**
+ * I​t​ ​s​e​e​m​s​ ​l​i​k​e​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​d​o​e​s​ ​n​o​t​ ​h​a​v​e​ ​a​ ​i​n​t​e​r​n​e​t​ ​c​o​n​n​e​c​t​i​o​n​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​c​o​n​n​e​c​t​i​o​n​.
+ */
+ noInternet: string
+ /**
+ * R​e​s​e​t
+ */
+ reset: string
+ /**
+ * {​0​}​ ​o​f​ ​{​1​}
+ * @param {unknown} 0
+ * @param {unknown} 1
+ */
+ of: RequiredParams<'0' | '1'>
+ /**
+ * {​0​}​ ​i​s​ ​r​e​q​u​i​r​e​d
+ * @param {unknown} 0
+ */
+ isRequired: RequiredParams<'0'>
+ /**
+ * S​u​b​m​i​t
+ */
+ submit: string
+ /**
+ * S​u​c​c​e​s​s
+ */
+ success: string
+ /**
+ * T​r​y​ ​a​g​a​i​n​ ​s​o​o​n
+ */
+ tryAgainSoon: string
+ /**
+ * C​r​e​a​t​e​ ​a​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createANewAccount: string
+ /**
+ * A​n​ ​u​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​e​d
+ */
+ unexpectedError: string
+ /**
+ * N​o​t​ ​f​o​u​n​d
+ */
+ notFound: string
+ /**
+ * D​o​c​u​m​e​n​t​a​t​i​o​n
+ */
+ documentation: string
+ /**
+ * T​e​r​m​s​ ​o​f​ ​s​e​r​v​i​c​e
+ */
+ tos: string
+ /**
+ * P​r​i​v​a​c​y​ ​p​o​l​i​c​y
+ */
+ privacyPolicy: string
+ /**
+ * S​i​g​n​ ​i​n​t​o​ ​y​o​u​r​ ​a​c​c​o​u​n​t
+ */
+ signIntoYourAccount: string
+ signInPage: {
+ /**
+ * T​h​i​s​ ​i​s​ ​n​o​t​ ​m​y​ ​c​o​m​p​u​t​e​r
+ */
+ notMyComputer: string
+ /**
+ * R​e​s​e​t​ ​p​a​s​s​w​o​r​d
+ */
+ resetPassword: string
+ /**
+ * Y​o​u​r​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​u​p​d​a​t​e​d
+ */
+ yourPasswordIsUpdated: string
+ /**
+ * S​i​g​n​ ​I​n
+ */
+ signIn: string
+ /**
+ * Y​o​u​r​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​a​p​p​l​i​e​d
+ */
+ yourNewPasswordIsApplied: string
+ /**
+ * S​i​g​n​ ​i​n​ ​b​e​l​o​w
+ */
+ signInBelow: string
+ /**
+ * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​i​s​ ​d​i​s​a​b​l​e​d
+ */
+ yourAccountIsDisabled: string
+ /**
+ * C​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​i​f​ ​t​h​i​s​ ​f​e​e​l​s​ ​w​r​o​n​g
+ */
+ contactYourAdminIfDisabled: string
+ /**
+ * Y​o​u​'​v​e​ ​r​e​a​c​h​e​d​ ​t​h​e​ ​h​i​d​d​e​n​ ​i​n​a​c​t​i​v​i​t​y​ ​l​i​m​i​t
+ */
+ youHaveReachedInactivityLimit: string
+ /**
+ * F​e​e​l​ ​f​r​e​e​ ​t​o​ ​s​i​g​n​ ​i​n​ ​a​g​a​i​n
+ */
+ feelFreeToSignInAgain: string
+ }
+ signUpPage: {
+ /**
+ * C​r​e​a​t​e​ ​y​o​u​r​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createYourNewAccount: string
+ }
+ resetPasswordPage: {
+ /**
+ * S​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ setANewPassword: string
+ /**
+ * E​x​p​i​r​e​d
+ */
+ expired: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​h​a​s​ ​e​x​p​i​r​e​d
+ */
+ requestHasExpired: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​n​e​w​ ​r​e​s​e​t
+ */
+ requestANewReset: string
+ /**
+ * N​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ newPassword: string
+ /**
+ * I​f​ ​w​e​ ​f​i​n​d​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​i​n​ ​o​u​r​ ​s​y​s​t​e​m​s​,​ ​y​o​u​ ​w​i​l​l​ ​r​e​c​e​i​v​e​ ​a​n​ ​e​m​a​i​l​ ​w​i​t​h​ ​i​n​s​t​r​u​c​t​i​o​n​s​ ​o​n​ ​h​o​w​ ​t​o​ ​s​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​f​o​r​ ​y​o​u​r​ ​a​c​c​o​u​n​t​.
+ */
+ requestSentMessage: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t
+ */
+ requestAPasswordReset: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d
+ */
+ requestNotFound: string
+ /**
+ * S​u​b​m​i​t​ ​a​ ​n​e​w​ ​r​e​s​e​t​ ​r​e​q​u​e​s​t​ ​b​e​l​o​w
+ */
+ submitANewRequestBelow: string
+ }
+}
+
+export type NamespaceAppTranslation = {}
+
+export type Namespaces =
+ | 'app'
+
+type DisallowNamespaces = {
+ /**
+ * reserved for 'app'-namespace\
+ * you need to use the `./app/index.ts` file instead
+ */
+ app?: "[typesafe-i18n] reserved for 'app'-namespace. You need to use the `./app/index.ts` file instead."
+}
+
+export type TranslationFunctions = {
+ /**
+ * Or
+ */
+ or: () => LocalizedString
+ /**
+ * Email address
+ */
+ emailAddress: () => LocalizedString
+ /**
+ * Password
+ */
+ password: () => LocalizedString
+ /**
+ * Page not found
+ */
+ pageNotFound: () => LocalizedString
+ /**
+ * It seems like your device does not have a internet connection, please check your connection.
+ */
+ noInternet: () => LocalizedString
+ /**
+ * Reset
+ */
+ reset: () => LocalizedString
+ /**
+ * {0} of {1}
+ */
+ of: (arg0: unknown, arg1: unknown) => LocalizedString
+ /**
+ * {0} is required
+ */
+ isRequired: (arg0: unknown) => LocalizedString
+ /**
+ * Submit
+ */
+ submit: () => LocalizedString
+ /**
+ * Success
+ */
+ success: () => LocalizedString
+ /**
+ * Try again soon
+ */
+ tryAgainSoon: () => LocalizedString
+ /**
+ * Create a new account
+ */
+ createANewAccount: () => LocalizedString
+ /**
+ * An unexpected error occured
+ */
+ unexpectedError: () => LocalizedString
+ /**
+ * Not found
+ */
+ notFound: () => LocalizedString
+ /**
+ * Documentation
+ */
+ documentation: () => LocalizedString
+ /**
+ * Terms of service
+ */
+ tos: () => LocalizedString
+ /**
+ * Privacy policy
+ */
+ privacyPolicy: () => LocalizedString
+ /**
+ * Sign into your account
+ */
+ signIntoYourAccount: () => LocalizedString
+ signInPage: {
+ /**
+ * This is not my computer
+ */
+ notMyComputer: () => LocalizedString
+ /**
+ * Reset password
+ */
+ resetPassword: () => LocalizedString
+ /**
+ * Your password is updated
+ */
+ yourPasswordIsUpdated: () => LocalizedString
+ /**
+ * Sign In
+ */
+ signIn: () => LocalizedString
+ /**
+ * Your new password is applied
+ */
+ yourNewPasswordIsApplied: () => LocalizedString
+ /**
+ * Sign in below
+ */
+ signInBelow: () => LocalizedString
+ /**
+ * Your account is disabled
+ */
+ yourAccountIsDisabled: () => LocalizedString
+ /**
+ * Contact your administrator if this feels wrong
+ */
+ contactYourAdminIfDisabled: () => LocalizedString
+ /**
+ * You've reached the hidden inactivity limit
+ */
+ youHaveReachedInactivityLimit: () => LocalizedString
+ /**
+ * Feel free to sign in again
+ */
+ feelFreeToSignInAgain: () => LocalizedString
+ }
+ signUpPage: {
+ /**
+ * Create your new account
+ */
+ createYourNewAccount: () => LocalizedString
+ }
+ resetPasswordPage: {
+ /**
+ * Set a new password
+ */
+ setANewPassword: () => LocalizedString
+ /**
+ * Expired
+ */
+ expired: () => LocalizedString
+ /**
+ * Your request has expired
+ */
+ requestHasExpired: () => LocalizedString
+ /**
+ * Request a new reset
+ */
+ requestANewReset: () => LocalizedString
+ /**
+ * New password
+ */
+ newPassword: () => LocalizedString
+ /**
+ * If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.
+ */
+ requestSentMessage: () => LocalizedString
+ /**
+ * Request a password reset
+ */
+ requestAPasswordReset: () => LocalizedString
+ /**
+ * Your request was not found
+ */
+ requestNotFound: () => LocalizedString
+ /**
+ * Submit a new reset request below
+ */
+ submitANewRequestBelow: () => LocalizedString
+ }
+ app: {
+ }
+}
+
+export type Formatters = {}
diff --git a/code/app/src/lib/i18n/i18n-util.async.ts b/code/app/src/lib/i18n/i18n-util.async.ts
new file mode 100644
index 0000000..00b8e0a
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.async.ts
@@ -0,0 +1,42 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Namespaces, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+const localeTranslationLoaders = {
+ en: () => import('./en'),
+ nb: () => import('./nb'),
+}
+
+const localeNamespaceLoaders = {
+ en: {
+ app: () => import('./en/app')
+ },
+ nb: {
+ app: () => import('./nb/app')
+ }
+}
+
+const updateDictionary = (locale: Locales, dictionary: Partial<Translations>) =>
+ loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
+
+export const importLocaleAsync = async (locale: Locales) =>
+ (await localeTranslationLoaders[locale]()).default as unknown as Translations
+
+export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
+ updateDictionary(locale, await importLocaleAsync(locale))
+ loadFormatters(locale)
+}
+
+export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
+
+export const importNamespaceAsync = async<Namespace extends Namespaces>(locale: Locales, namespace: Namespace) =>
+ (await localeNamespaceLoaders[locale][namespace]()).default as unknown as Translations[Namespace]
+
+export const loadNamespaceAsync = async <Namespace extends Namespaces>(locale: Locales, namespace: Namespace): Promise<void> =>
+ void updateDictionary(locale, { [namespace]: await importNamespaceAsync(locale, namespace )})
diff --git a/code/app/src/lib/i18n/i18n-util.sync.ts b/code/app/src/lib/i18n/i18n-util.sync.ts
new file mode 100644
index 0000000..8144fdc
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.sync.ts
@@ -0,0 +1,35 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+import en from './en'
+import nb from './nb'
+
+import en_app from './en/app'
+import nb_app from './nb/app'
+
+const localeTranslations = {
+ en: {
+ ...en,
+ app: en_app
+ },
+ nb: {
+ ...nb,
+ app: nb_app
+ },
+}
+
+export const loadLocale = (locale: Locales): void => {
+ if (loadedLocales[locale]) return
+
+ loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
+ loadFormatters(locale)
+}
+
+export const loadAllLocales = (): void => locales.forEach(loadLocale)
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
diff --git a/code/app/src/lib/i18n/i18n-util.ts b/code/app/src/lib/i18n/i18n-util.ts
new file mode 100644
index 0000000..35f023c
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.ts
@@ -0,0 +1,39 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
+import type { LocaleDetector } from 'typesafe-i18n/detectors'
+import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
+import type { Formatters, Locales, Namespaces, Translations, TranslationFunctions } from './i18n-types'
+
+export const baseLocale: Locales = 'en'
+
+export const locales: Locales[] = [
+ 'en',
+ 'nb'
+]
+
+export const namespaces: Namespaces[] = [
+ 'app'
+]
+
+export const isLocale = (locale: string) => locales.includes(locale as Locales)
+
+export const isNamespace = (namespace: string) => namespaces.includes(namespace as Namespaces)
+
+export const loadedLocales = {} as Record<Locales, Translations>
+
+export const loadedFormatters = {} as Record<Locales, Formatters>
+
+export const i18nString = (locale: Locales) => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
+
+export const i18nObject = (locale: Locales) =>
+ initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
+ locale,
+ loadedLocales[locale],
+ loadedFormatters[locale]
+ )
+
+export const i18n = () => initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export const detectLocale = (...detectors: LocaleDetector[]) => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
diff --git a/code/app/src/lib/i18n/nb/app/index.ts b/code/app/src/lib/i18n/nb/app/index.ts
new file mode 100644
index 0000000..15d0b9a
--- /dev/null
+++ b/code/app/src/lib/i18n/nb/app/index.ts
@@ -0,0 +1,8 @@
+import type { NamespaceAppTranslation } from '../../i18n-types'
+
+const nb_app: NamespaceAppTranslation = {
+ // TODO: insert translations
+
+}
+
+export default nb_app
diff --git a/code/app/src/lib/i18n/nb/index.ts b/code/app/src/lib/i18n/nb/index.ts
new file mode 100644
index 0000000..fa81477
--- /dev/null
+++ b/code/app/src/lib/i18n/nb/index.ts
@@ -0,0 +1,50 @@
+import type { Translation } from "../i18n-types";
+
+const nb: Translation = {
+ or: "Eller",
+ emailAddress: "E-postadresse",
+ password: "Passord",
+ pageNotFound: "Fant ikke siden",
+ noInternet: "Det ser ut som at du ikke tilkoblet internettet, sjekk tilkoblingen din for å fortsette",
+ reset: "Tilbakestill",
+ of: "{0} av {1}",
+ isRequired: "{0} er påkrevd",
+ submit: "Send",
+ success: "Suksess",
+ tryAgainSoon: "Prøv igjen snart",
+ createANewAccount: "Lag en ny konto",
+ unexpectedError: "En uventet feil oppstod",
+ notFound: "Ikke funnet",
+ documentation: "Dokumentasjon",
+ tos: "Vilkår",
+ privacyPolicy: "Personvernerklæring",
+ signIntoYourAccount: "Logg inn med din konto",
+ signInPage: {
+ notMyComputer: "Dette er ikke min datamaskin",
+ resetPassword: "Tilbakestill passord",
+ yourPasswordIsUpdated: "Ditt passord er oppdater",
+ signIn: "Logg inn",
+ yourNewPasswordIsApplied: "Ditt nye passord er satt",
+ signInBelow: "Logg inn nedenfor",
+ yourAccountIsDisabled: "Din konto er deaktivert",
+ contactYourAdminIfDisabled: "Ta kontakt med din administrator hvis dette føles feil",
+ youHaveReachedInactivityLimit: "Du har nådd den hemmelige inaktivitetsgrensen",
+ feelFreeToSignInAgain: "Logg gjerne inn igjen"
+ },
+ signUpPage: {
+ createYourNewAccount: "Opprett din nye konto",
+ },
+ resetPasswordPage: {
+ setANewPassword: "Skriv et nytt passord",
+ expired: "Utgått",
+ requestHasExpired: "Din forespørsel er utgått",
+ requestANewReset: "Spør om en ny tilbakestillingslenke",
+ newPassword: "Nytt passord",
+ requestSentMessage: "Hvis vi finner e-postadressen din i våre systemer, vil du få en e-post med instrukser for å sette ditt nye passord.",
+ requestAPasswordReset: "Forespør tilbakestilling av ditt passord",
+ requestNotFound: "Din forespørsel ble ikke funnet",
+ submitANewRequestBelow: "Spør om en ny tilbakestillingslenke nedenfor"
+ }
+}
+
+export default nb; \ No newline at end of file
diff --git a/code/app/src/lib/logger.ts b/code/app/src/lib/logger.ts
new file mode 100644
index 0000000..df0a821
--- /dev/null
+++ b/code/app/src/lib/logger.ts
@@ -0,0 +1,86 @@
+import { browser, dev } from "$app/environment";
+import { StorageKeys } from "$lib/configuration";
+import pino from "pino";
+
+const pinoConfig = dev ? {
+ transport: {
+ target: "pino-pretty",
+ }
+} : {};
+
+const pinoLogger = pino(pinoConfig);
+
+function browserLogLevel(): number {
+ if (browser) return LogLevel.toNumber(sessionStorage.getItem(StorageKeys.logLevel), LogLevel.INFO);
+ throw new Error("Called browser api in server");
+}
+
+function serverLogLevel(): number {
+ if (!browser) return LogLevel.toNumber(import.meta.env.VITE_LOG_LEVEL, LogLevel.ERROR);
+ throw new Error("Called server api in browser");
+}
+
+export const LogLevel = {
+ DEBUG: 0,
+ INFO: 1,
+ ERROR: 2,
+ SILENT: 3,
+ toString(levelInt: number): string {
+ switch (levelInt) {
+ case 0:
+ return "DEBUG";
+ case 1:
+ return "INFO";
+ case 2:
+ return "ERROR";
+ case 3:
+ return "SILENT";
+ default:
+ throw new Error("Log level int is unknown");
+ }
+ },
+ toNumber(levelString?: string | null, fallback?: number): number {
+ if (!levelString && fallback) return fallback;
+ else if (!levelString && !fallback) throw new Error("levelString was empty, and no fallback was specified");
+ switch (levelString?.toUpperCase()) {
+ case "DEBUG":
+ return 0;
+ case "INFO":
+ return 1;
+ case "ERROR":
+ return 2;
+ case "SILENT":
+ return 3;
+ default:
+ if (!fallback) throw new Error("Log level string is unknown");
+ else return fallback;
+ }
+ },
+};
+
+export function logDebug(message: string, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.DEBUG) {
+ pinoLogger.debug(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.DEBUG) {
+ pinoLogger.debug(message, additional);
+ }
+}
+
+export function logInfo(message: string, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.INFO) {
+ pinoLogger.info(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.INFO) {
+ pinoLogger.info(message, additional);
+ }
+}
+
+export function logError(message: any, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.ERROR) {
+ pinoLogger.error(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.ERROR) {
+ pinoLogger.error(message, additional);
+ }
+} \ No newline at end of file
diff --git a/code/app/src/lib/models/CreateAccountPayload.ts b/code/app/src/lib/models/CreateAccountPayload.ts
new file mode 100644
index 0000000..d116308
--- /dev/null
+++ b/code/app/src/lib/models/CreateAccountPayload.ts
@@ -0,0 +1,4 @@
+export interface CreateAccountPayload {
+ username: string,
+ password: string
+}
diff --git a/code/app/src/lib/models/ErrorResult.ts b/code/app/src/lib/models/ErrorResult.ts
new file mode 100644
index 0000000..7c70017
--- /dev/null
+++ b/code/app/src/lib/models/ErrorResult.ts
@@ -0,0 +1,4 @@
+export interface ErrorResult {
+ title: string,
+ text: string
+}
diff --git a/code/app/src/lib/models/IInternalFetchRequest.ts b/code/app/src/lib/models/IInternalFetchRequest.ts
new file mode 100644
index 0000000..68505e2
--- /dev/null
+++ b/code/app/src/lib/models/IInternalFetchRequest.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchRequest {
+ url: string,
+ init?: RequestInit,
+ timeout?: number
+ retry_count?: number
+}
diff --git a/code/app/src/lib/models/IInternalFetchResponse.ts b/code/app/src/lib/models/IInternalFetchResponse.ts
new file mode 100644
index 0000000..6c91b35
--- /dev/null
+++ b/code/app/src/lib/models/IInternalFetchResponse.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchResponse {
+ ok: boolean,
+ status: number,
+ data: any,
+ http_response: Response
+}
diff --git a/code/app/src/lib/models/ISession.ts b/code/app/src/lib/models/ISession.ts
new file mode 100644
index 0000000..7587145
--- /dev/null
+++ b/code/app/src/lib/models/ISession.ts
@@ -0,0 +1,8 @@
+export interface ISession {
+ profile: {
+ username: string,
+ displayName: string,
+ id: string,
+ },
+ lastChecked: number,
+} \ No newline at end of file
diff --git a/code/app/src/lib/models/IValidationResult.ts b/code/app/src/lib/models/IValidationResult.ts
new file mode 100644
index 0000000..9a21b13
--- /dev/null
+++ b/code/app/src/lib/models/IValidationResult.ts
@@ -0,0 +1,31 @@
+export interface IValidationResult {
+ errors: Array<IValidationError>,
+ has_errors: Function,
+ add_error: Function,
+ remove_error: Function,
+}
+
+export interface IValidationError {
+ _id?: string,
+ title: string,
+ text?: string
+}
+
+export default class ValidationResult implements IValidationResult {
+ errors: IValidationError[]
+ has_errors(): boolean {
+ return this.errors?.length > 0;
+ }
+ add_error(prop: string, error: IValidationError): void {
+ if (!this.errors) this.errors = [];
+ error._id = prop;
+ this.errors.push(error);
+ }
+ remove_error(property: string): void {
+ const new_errors = [];
+ for (const error of this.errors) {
+ if (error._id != property) new_errors.push(error)
+ }
+ this.errors = new_errors;
+ }
+}
diff --git a/code/app/src/lib/models/LoginPayload.ts b/code/app/src/lib/models/LoginPayload.ts
new file mode 100644
index 0000000..beb96cf
--- /dev/null
+++ b/code/app/src/lib/models/LoginPayload.ts
@@ -0,0 +1,5 @@
+export interface LoginPayload {
+ username: string,
+ password: string,
+ persist: boolean
+}
diff --git a/code/app/src/lib/models/TimeCategoryDto.ts b/code/app/src/lib/models/TimeCategoryDto.ts
new file mode 100644
index 0000000..fcdb7ea
--- /dev/null
+++ b/code/app/src/lib/models/TimeCategoryDto.ts
@@ -0,0 +1,9 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeCategoryDto {
+ selected?: boolean;
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/code/app/src/lib/models/TimeEntryDto.ts b/code/app/src/lib/models/TimeEntryDto.ts
new file mode 100644
index 0000000..571c52e
--- /dev/null
+++ b/code/app/src/lib/models/TimeEntryDto.ts
@@ -0,0 +1,13 @@
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeEntryDto {
+ id: string,
+ modified_at?: Temporal.PlainDate,
+ start: string,
+ stop: string,
+ description: string,
+ labels?: Array<TimeLabelDto>,
+ category: TimeCategoryDto,
+}
diff --git a/code/app/src/lib/models/TimeEntryQuery.ts b/code/app/src/lib/models/TimeEntryQuery.ts
new file mode 100644
index 0000000..d983d1a
--- /dev/null
+++ b/code/app/src/lib/models/TimeEntryQuery.ts
@@ -0,0 +1,27 @@
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { Temporal } from "temporal-polyfill";
+
+export interface TimeEntryQuery {
+ duration: TimeEntryQueryDuration,
+ categories?: Array<TimeCategoryDto>,
+ labels?: Array<TimeLabelDto>,
+ dateRange?: TimeEntryQueryDateRange,
+ specificDate?: Temporal.PlainDateTime
+ page: number,
+ pageSize: number
+}
+
+export interface TimeEntryQueryDateRange {
+ from: Temporal.PlainDateTime,
+ to: Temporal.PlainDateTime
+}
+
+export enum TimeEntryQueryDuration {
+ TODAY = 0,
+ THIS_WEEK = 1,
+ THIS_MONTH = 2,
+ THIS_YEAR = 3,
+ SPECIFIC_DATE = 4,
+ DATE_RANGE = 5,
+}
diff --git a/code/app/src/lib/models/TimeLabelDto.ts b/code/app/src/lib/models/TimeLabelDto.ts
new file mode 100644
index 0000000..7183bcf
--- /dev/null
+++ b/code/app/src/lib/models/TimeLabelDto.ts
@@ -0,0 +1,8 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeLabelDto {
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/code/app/src/lib/models/TimeQueryDto.ts b/code/app/src/lib/models/TimeQueryDto.ts
new file mode 100644
index 0000000..607c51e
--- /dev/null
+++ b/code/app/src/lib/models/TimeQueryDto.ts
@@ -0,0 +1,29 @@
+import type { TimeEntryDto } from "./TimeEntryDto";
+import ValidationResult, { IValidationResult } from "./IValidationResult";
+
+export interface ITimeQueryDto {
+ results: Array<TimeEntryDto>,
+ page: number,
+ pageSize: number,
+ totalRecords: number,
+ totalPageCount: number,
+ is_valid: Function
+}
+
+export class TimeQueryDto implements ITimeQueryDto {
+ results: TimeEntryDto[];
+ page: number;
+ pageSize: number;
+ totalRecords: number;
+ totalPageCount: number;
+
+ is_valid(): IValidationResult {
+ const result = new ValidationResult();
+ if (this.page < 0) {
+ result.add_error("page", {
+ title: "Page cannot be less than zero",
+ })
+ }
+ return result;
+ }
+}
diff --git a/code/app/src/lib/models/UnwrappedEntryDateTime.ts b/code/app/src/lib/models/UnwrappedEntryDateTime.ts
new file mode 100644
index 0000000..d614f91
--- /dev/null
+++ b/code/app/src/lib/models/UnwrappedEntryDateTime.ts
@@ -0,0 +1,9 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface UnwrappedEntryDateTime {
+ start_date: Temporal.PlainDate,
+ stop_date: Temporal.PlainDate,
+ start_time: Temporal.PlainTime,
+ stop_time: Temporal.PlainTime,
+ duration: Temporal.Duration,
+}
diff --git a/code/app/src/lib/models/UpdateProfilePayload.ts b/code/app/src/lib/models/UpdateProfilePayload.ts
new file mode 100644
index 0000000..d2983ff
--- /dev/null
+++ b/code/app/src/lib/models/UpdateProfilePayload.ts
@@ -0,0 +1,4 @@
+export interface UpdateProfilePayload {
+ username?: string,
+ password?: string,
+}
diff --git a/code/app/src/lib/persistent-store.ts b/code/app/src/lib/persistent-store.ts
new file mode 100644
index 0000000..922f3ab
--- /dev/null
+++ b/code/app/src/lib/persistent-store.ts
@@ -0,0 +1,102 @@
+import { writable as _writable, readable as _readable, } from "svelte/store";
+import type { Writable, Readable, StartStopNotifier } from "svelte/store";
+
+enum StoreType {
+ SESSION = 0,
+ LOCAL = 1
+}
+
+interface StoreOptions {
+ store?: StoreType;
+}
+
+const default_store_options = {
+ store: StoreType.SESSION
+} as StoreOptions;
+
+interface WritableStore<T> {
+ name: string,
+ initialState: T,
+ options?: StoreOptions
+}
+
+interface ReadableStore<T> {
+ name: string,
+ initialState: T,
+ callback: StartStopNotifier<any>,
+ options?: StoreOptions
+}
+
+function get_store(type: StoreType): Storage {
+ switch (type) {
+ case StoreType.SESSION:
+ return window.sessionStorage;
+ case StoreType.LOCAL:
+ return window.localStorage;
+ }
+}
+
+function prepared_store_value(value: any): string {
+ try {
+ return JSON.stringify(value);
+ } catch (e) {
+ console.error(e);
+ return "__INVALID__";
+ }
+}
+
+function get_store_value<T>(options: WritableStore<T> | ReadableStore<T>): any {
+ try {
+ const storage = get_store(options.options.store);
+ const value = storage.getItem(options.name);
+ if (!value) return false;
+ return JSON.parse(value);
+ } catch (e) {
+ console.error(e);
+ return { __INVALID__: true };
+ }
+}
+
+function hydrate<T>(store: Writable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const value = get_store_value<T>(options);
+ if (value && store.set) store.set(value);
+}
+
+function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const storage = get_store(options.options.store);
+ if (!store.subscribe) return;
+ store.subscribe((state: any) => {
+ storage.setItem(options.name, prepared_store_value(state));
+ });
+}
+
+function writable_persistent<T>(options: WritableStore<T>): Writable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating writable store with options: ", options);
+ const store = _writable<T>(options.initialState);
+ hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+function readable_persistent<T>(options: ReadableStore<T>): Readable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating readable store with options: ", options);
+ const store = _readable<T>(options.initialState, options.callback);
+ // hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+export {
+ writable_persistent,
+ readable_persistent,
+ StoreType
+};
+
+export type {
+ WritableStore,
+ ReadableStore,
+ StoreOptions
+};
+
diff --git a/code/app/src/lib/session.ts b/code/app/src/lib/session.ts
new file mode 100644
index 0000000..ee79933
--- /dev/null
+++ b/code/app/src/lib/session.ts
@@ -0,0 +1,69 @@
+import {logError, logInfo} from "$lib/logger";
+import { Temporal } from "temporal-polyfill";
+import { get_profile_for_active_check, logout } from "./api/user";
+import { is_guid, session_storage_get_json, session_storage_set_json } from "./helpers";
+import { SECONDS_BETWEEN_SESSION_CHECK, StorageKeys } from "./configuration";
+import type { ISession } from "$lib/models/ISession";
+
+export async function is_active(forceRefresh: boolean = false): Promise<boolean> {
+ const nowEpoch = Temporal.Now.instant().epochSeconds;
+ const data = session_storage_get_json(StorageKeys.session) as ISession;
+ const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK;
+ const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch);
+ if (forceRefresh || lastCheckIsStaleOrNone) {
+ return await call_api();
+ } else {
+ const sessionIsValid = data.profile && is_guid(data.profile.id);
+ if (!sessionIsValid) {
+ clear_session_data();
+ logInfo("Session data is not valid");
+ }
+ return sessionIsValid;
+ }
+}
+
+export async function end_session(cb: Function): Promise<void> {
+ await logout();
+ clear_session_data();
+ cb();
+}
+
+async function call_api(): Promise<boolean> {
+ logInfo("Getting profile data while checking session state");
+ try {
+ const response = await get_profile_for_active_check();
+ if (response.ok) {
+ const userData = await response.data;
+ if (is_guid(userData.id) && userData.username) {
+ const session = {
+ profile: userData,
+ lastChecked: Temporal.Now.instant().epochSeconds
+ } as ISession;
+ session_storage_set_json(StorageKeys.session, session);
+ logInfo("Successfully got profile data while checking session state");
+ return true;
+ } else {
+ logError("Api returned invalid data while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } else {
+ logError("Api returned unsuccessfully while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } catch (e) {
+ logError(e);
+ clear_session_data();
+ return false;
+ }
+}
+
+export function clear_session_data() {
+ session_storage_set_json(StorageKeys.session, {});
+ logInfo("Cleared session data.");
+}
+
+export function get_session_data(): ISession {
+ return session_storage_get_json(StorageKeys.session) as ISession;
+}
diff --git a/code/app/src/routes/(main)/(app)/+layout.svelte b/code/app/src/routes/(main)/(app)/+layout.svelte
new file mode 100644
index 0000000..0be6ff3
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/+layout.svelte
@@ -0,0 +1,297 @@
+<script lang="ts">
+ import {
+ ChevronUpDownIcon,
+ MagnifyingGlassIcon,
+ Bars3CenterLeftIcon,
+ XMarkIcon,
+ HomeIcon,
+ MegaphoneIcon,
+ FolderOpenIcon,
+ QueueListIcon,
+ CalendarIcon,
+ } from "$lib/components/icons";
+ import { Dialog, Menu, MenuButton, MenuItem, MenuItems, Transition, TransitionChild, TransitionRoot } from "@rgossiaux/svelte-headlessui";
+ import { DialogPanel } from "@developermuch/dev-svelte-headlessui";
+ import type { ISession } from "$lib/models/ISession";
+ import { Input } from "$lib/components";
+ import { end_session } from "$lib/session";
+ import { goto } from "$app/navigation";
+ import { page } from "$app/stores";
+
+ const session = {
+ profile: {
+ username: "Brukernavn",
+ displayName: "epost@adresse.no",
+ },
+ } as ISession;
+
+ let sidebarOpen = false;
+ let sidebarSearchValue: string | undefined;
+
+ function sign_out() {
+ end_session(() => goto("/sign-in"));
+ }
+
+ const navigationItems = [
+ {
+ href: "/home",
+ name: "Home",
+ icon: HomeIcon,
+ },
+ {
+ href: "/projects",
+ name: "Projects",
+ icon: CalendarIcon,
+ },
+ {
+ href: "/tickets",
+ name: "Tickets",
+ icon: MegaphoneIcon,
+ },
+ {
+ href: "/todo",
+ name: "Todo",
+ icon: QueueListIcon,
+ },
+ {
+ href: "/wiki",
+ name: "Wiki",
+ icon: FolderOpenIcon,
+ },
+ ];
+</script>
+
+<div class="min-h-full">
+ <!-- Mobile sidebar -->
+ <TransitionRoot show={sidebarOpen}>
+ <Dialog as="div" class="relative z-40 lg:hidden" on:close={() => (sidebarOpen = false)}>
+ <TransitionChild
+ as="div"
+ enter="transition-opacity ease-linear duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="transition-opacity ease-linear duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="fixed inset-0 bg-gray-600 bg-opacity-75" />
+ </TransitionChild>
+
+ <div class="fixed inset-0 z-40 flex">
+ <TransitionChild
+ as="div"
+ enter="transition ease-in-out duration-300 transform"
+ enterFrom="-translate-x-full"
+ enterTo="translate-x-0"
+ leave="transition ease-in-out duration-300 transform"
+ leaveFrom="translate-x-0"
+ leaveTo="-translate-x-full"
+ >
+ <DialogPanel class="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4">
+ <TransitionChild
+ as="div"
+ enter="ease-in-out duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="ease-in-out duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="absolute top-0 right-0 -mr-12 pt-2">
+ <button
+ type="button"
+ class="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
+ on:click={() => (sidebarOpen = false)}
+ >
+ <span class="sr-only">Close sidebar</span>
+ <XMarkIcon class="text-white" aria-hidden="true" />
+ </button>
+ </div>
+ </TransitionChild>
+ <div class="mt-5 h-0 flex-1 overflow-y-auto">
+ <nav class="px-2">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </DialogPanel>
+ </TransitionChild>
+ <div class="w-14 flex-shrink-0" aria-hidden="true">
+ <!-- Dummy element to force sidebar to shrink to fit close icon -->
+ </div>
+ </div>
+ </Dialog>
+ </TransitionRoot>
+
+ <!-- Static sidebar for desktop -->
+ <div class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-gray-200 lg:bg-gray-100 lg:pb-4">
+ <div class="flex h-0 flex-1 p-3 flex-col overflow-y-auto">
+ <!-- User account dropdown -->
+ <Menu class="relative inline-block text-left">
+ <MenuButton
+ class="group w-full rounded-md bg-gray-100 px-3.5 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-gray-100"
+ >
+ <span class="flex w-full items-center justify-between">
+ <span class="flex min-w-0 items-center justify-between space-x-3">
+ <span class="flex min-w-0 flex-1 flex-col">
+ <span class="truncate text-sm font-medium text-gray-900">
+ {session.profile.username}
+ </span>
+ <span class="truncate text-sm text-gray-500">{session.profile.displayName}</span>
+ </span>
+ </span>
+ <ChevronUpDownIcon class="flex-shrink-0 text-gray-400 group-hover:text-gray-500" aria-hidden="true" />
+ </span>
+ </MenuButton>
+ <Transition
+ leave="transition ease-in duration-75"
+ enter="transition ease-out duration-100"
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 left-0 z-10 mt-1 origin-top divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a href="/profile" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> View profile </a>
+ </MenuItem>
+ <MenuItem>
+ <a href="/settings" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> Settings </a>
+ </MenuItem>
+ </div>
+ <div class="py-1">
+ <MenuItem>
+ <span
+ on:click={() => sign_out()}
+ class="text-gray-700 block px-4 py-2 text-sm hover:bg-red-200 hover:text-red-900 cursor-pointer"
+ >
+ Sign out
+ </span>
+ </MenuItem>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ <!-- Sidebar Search -->
+ <div class="mt-3 hidden">
+ <label for="search" class="sr-only">Search</label>
+ <div class="relative mt-1 rounded-md shadow-sm">
+ <Input type="search" name="search" icon={MagnifyingGlassIcon} placeholder="Search" bind:value={sidebarSearchValue} />
+ </div>
+ </div>
+ <!-- Navigation -->
+ <nav class="mt-5">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current ? 'bg-gray-200 text-gray-900' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </div>
+
+ <!-- Main column -->
+ <div class="flex flex-col lg:pl-64">
+ <!-- Search header -->
+ <div class="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:hidden">
+ <button
+ type="button"
+ class="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 lg:hidden"
+ on:click={() => (sidebarOpen = true)}
+ >
+ <span class="sr-only">Open sidebar</span>
+ <Bars3CenterLeftIcon aria-hidden="true" />
+ </button>
+ <div class="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
+ <div class="flex flex-1">
+ <form class="flex w-full md:ml-0" action="#" method="GET">
+ <label for="search-field" class="sr-only">Search</label>
+ <div class="relative w-full text-gray-400 focus-within:text-gray-600">
+ <Input
+ bind:value={sidebarSearchValue}
+ icon={MagnifyingGlassIcon}
+ id="search-field"
+ name="search-field"
+ placeholder="Search"
+ type="search"
+ />
+ </div>
+ </form>
+ </div>
+ <div class="flex items-center">
+ <!-- Profile dropdown -->
+ <Menu as="div" class="relative ml-3">
+ <div>
+ <MenuButton
+ class="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2"
+ >
+ <span class="sr-only">Open user menu</span>
+ </MenuButton>
+ </div>
+ <Transition
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a href="/profile" class="text-gray-700 block px-4 py-2 text-sm"> View profile </a>
+ </MenuItem>
+ <MenuItem>
+ <a href="/settings" class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"> Settings </a>
+ </MenuItem>
+ <div class="py-1">
+ <MenuItem>
+ <span on:click={() => sign_out()} class="text-gray-700 block px-4 py-2 text-sm"> Sign out </span>
+ </MenuItem>
+ </div>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ </div>
+ </div>
+ </div>
+ <main class="flex-1">
+ <slot />
+ </main>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(app)/home/+page.svelte b/code/app/src/routes/(main)/(app)/home/+page.svelte
new file mode 100644
index 0000000..247ee47
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/home/+page.svelte
@@ -0,0 +1 @@
+<h1>Welcome Home</h1> \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(app)/org/+page.svelte b/code/app/src/routes/(main)/(app)/org/+page.svelte
new file mode 100644
index 0000000..429ec25
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/org/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>$ORGNAME</h1>
diff --git a/code/app/src/routes/(main)/(app)/profile/+page.svelte b/code/app/src/routes/(main)/(app)/profile/+page.svelte
new file mode 100644
index 0000000..7c6eb3e
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/profile/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Hi, Ivar</h1>
diff --git a/code/app/src/routes/(main)/(app)/projects/+page.svelte b/code/app/src/routes/(main)/(app)/projects/+page.svelte
new file mode 100644
index 0000000..683938a
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte
@@ -0,0 +1,5 @@
+<script lang="ts">
+ import { createSvelteTable } from "@tanstack/svelte-table";
+</script>
+
+<h1>Projects</h1>
diff --git a/code/app/src/routes/(main)/(app)/settings/+page.svelte b/code/app/src/routes/(main)/(app)/settings/+page.svelte
new file mode 100644
index 0000000..ae6d403
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/settings/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Settings</h1>
diff --git a/code/app/src/routes/(main)/(app)/tickets/+page.svelte b/code/app/src/routes/(main)/(app)/tickets/+page.svelte
new file mode 100644
index 0000000..2a4792b
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/tickets/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Tickets</h1>
diff --git a/code/app/src/routes/(main)/(app)/todo/+page.svelte b/code/app/src/routes/(main)/(app)/todo/+page.svelte
new file mode 100644
index 0000000..e29f263
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/todo/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Todo</h1>
diff --git a/code/app/src/routes/(main)/(app)/wiki/+page.svelte b/code/app/src/routes/(main)/(app)/wiki/+page.svelte
new file mode 100644
index 0000000..1762d43
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/wiki/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Wiki</h1>
diff --git a/code/app/src/routes/(main)/(public)/+layout.svelte b/code/app/src/routes/(main)/(public)/+layout.svelte
new file mode 100644
index 0000000..69c29c5
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/+layout.svelte
@@ -0,0 +1,18 @@
+<script>
+ import LL from "$lib/i18n/i18n-svelte";
+</script>
+
+<slot />
+<footer
+ class="grid sm:gap-5 grid-flow-row sm:justify-center px-2 sm:grid-flow-col"
+>
+ <a href="https://greatoffice.life/privacy" class="link">
+ {$LL.privacyPolicy()}
+ </a>
+ <a href="https://greatoffice.life/tos" class="link">
+ {$LL.tos()}
+ </a>
+ <a href="https://greatoffice.life/documentation" class="link">
+ {$LL.documentation()}
+ </a>
+</footer>
diff --git a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte
new file mode 100644
index 0000000..aa26892
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { create_forgot_password_request } from "$lib/api/user";
+ import { Alert, Input, Button } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+
+ const formData = {
+ email: "",
+ };
+
+ $: showErrorAlert =
+ (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0 &&
+ !showSuccessAlert;
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+
+ let loading = false;
+ let showSuccessAlert = false;
+
+ async function submitFormAsync() {
+ errorData.text = "";
+ errorData.title = "";
+ showSuccessAlert = false;
+ loading = true;
+ const request = await create_forgot_password_request(formData.email);
+ loading = false;
+ if (!request.ok) {
+ errorData.text = request.data.text ?? $LL.tryAgainSoon();
+ errorData.title = request.data.title ?? $LL.unexpectedError();
+ return;
+ }
+ showSuccessAlert = true;
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.requestAPasswordReset()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ <Alert
+ title={errorData.title}
+ message={errorData.text}
+ type="error"
+ visible={showErrorAlert}
+ />
+
+ <Alert
+ type="success"
+ title={$LL.success()}
+ message={$LL.resetPasswordPage.requestSentMessage()}
+ visible={showSuccessAlert}
+ />
+
+ <Input
+ id="email"
+ name="email"
+ type="email"
+ autocomplete="email"
+ required
+ bind:value={formData.email}
+ label={$LL.emailAddress()}
+ />
+ <Button text={$LL.submit()} type="submit" {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js
new file mode 100644
index 0000000..1c7fa30
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js
@@ -0,0 +1,11 @@
+import { is_guid } from '$lib/helpers';
+import { redirect } from '@sveltejs/kit';
+export const load = async ({ params }) => {
+ const resetRequestId = params.id ?? "";
+ if (!is_guid(resetRequestId))
+ throw redirect(302, "/reset-password");
+ return {
+ resetRequestId
+ };
+};
+//# sourceMappingURL=+page.server.js.map \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map
new file mode 100644
index 0000000..52fb93b
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"+page.server.js","sourceRoot":"","sources":["+page.server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC,MAAM,CAAC,MAAM,IAAI,GAAmB,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE;IACrD,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC;QAAE,MAAM,QAAQ,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IACrE,OAAO;QACH,cAAc;KACjB,CAAC;AACN,CAAC,CAAC"} \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
new file mode 100644
index 0000000..389d04c
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
@@ -0,0 +1,11 @@
+import { is_guid } from '$lib/helpers';
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ params }) => {
+ const resetRequestId = params.id ?? "";
+ if (!is_guid(resetRequestId)) throw redirect(302, "/reset-password");
+ return {
+ resetRequestId
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
new file mode 100644
index 0000000..562d902
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
@@ -0,0 +1,132 @@
+<script lang="ts">
+ import {
+ check_forgot_password_request,
+ fulfill_forgot_password_request,
+ } from "$lib/api/user";
+ import { onMount } from "svelte";
+ import LL from "$lib/i18n/i18n-svelte";
+ import { Alert, Input, Button } from "$lib/components";
+ import type { PageServerData } from "./$types";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+ import { goto } from "$app/navigation";
+ import { Message, messageQueryKey } from "../../sign-in/+page.svelte";
+
+ export let data: PageServerData;
+
+ const formData = {
+ newPassword: "",
+ };
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+
+ let errorState: undefined | "expired" | "404" | "unknown";
+
+ let finishedPreliminaryLoading = false;
+ let loading = false;
+ let canSubmit = true;
+
+ async function submitFormAsync() {
+ if (!canSubmit) return;
+ loading = true;
+ const request = await fulfill_forgot_password_request(
+ data.resetRequestId,
+ formData.newPassword
+ );
+ if (request.ok) {
+ goto(
+ "/sign-in?" +
+ messageQueryKey +
+ "=" +
+ Message.AFTER_PASSWORD_RESET
+ );
+ }
+
+ loading = false;
+ }
+
+ onMount(async () => {
+ errorState = undefined;
+ const isValidRequest = await check_forgot_password_request(
+ data.resetRequestId
+ );
+ if (!isValidRequest.ok && isValidRequest.status !== 404) {
+ errorState = "unknown";
+ canSubmit = false;
+ }
+ if (isValidRequest.status === 404) {
+ errorState = "404";
+ canSubmit = false;
+ }
+ if (isValidRequest.ok && isValidRequest.data !== true) {
+ errorState = "expired";
+ canSubmit = false;
+ }
+ finishedPreliminaryLoading = true;
+ });
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if finishedPreliminaryLoading}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.setANewPassword()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form
+ class="space-y-6"
+ on:submit|preventDefault={submitFormAsync}
+ >
+ {#if errorState === "404"}
+ <Alert
+ title={$LL.notFound()}
+ message={$LL.resetPasswordPage.requestNotFound()}
+ />
+ {:else if errorState === "expired"}
+ <Alert
+ title={$LL.resetPasswordPage.expired()}
+ message={$LL.resetPasswordPage.requestHasExpired()}
+ rightLinkHref="/reset-password"
+ rightLinkText={$LL.resetPasswordPage.requestANewReset()}
+ />
+ {:else if errorState === "unknown"}
+ <Alert
+ title={$LL.unexpectedError()}
+ message={$LL.tryAgainSoon()}
+ />
+ {/if}
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ autocomplete="new-password"
+ required
+ bind:value={formData.newPassword}
+ label={$LL.resetPasswordPage.newPassword()}
+ />
+
+ <Button
+ text={$LL.submit()}
+ type="submit"
+ {loading}
+ fullWidth
+ />
+ </form>
+ </div>
+ </div>
+ {:else}
+ <p>Checking your request...</p>
+ {/if}
+</div>
diff --git a/code/app/src/routes/(main)/(public)/sign-in/+page.svelte b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
new file mode 100644
index 0000000..908e2ba
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
@@ -0,0 +1,133 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { login } from "$lib/api/user";
+ import { Button, Checkbox, Input, Alert } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+ import type { LoginPayload } from "$lib/models/LoginPayload";
+ import pwKey from "$actions/pwKey";
+ import { onMount } from "svelte";
+ import { messageQueryKey, signInPageTestKeys, type Message } from ".";
+
+ let loading = false;
+ let messageType: Message | undefined = undefined;
+
+ const data = {
+ username: "",
+ password: "",
+ persist: true,
+ } as LoginPayload;
+
+ let errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+ $: showErrorAlert = (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0;
+
+ onMount(() => {
+ const searcher = new URLSearchParams(window.location.search);
+ if (searcher.get(messageQueryKey)) {
+ messageType = searcher.get(messageQueryKey) as Message;
+ searcher.delete(messageQueryKey);
+ history.replaceState(null, "", window.location.origin + window.location.pathname);
+ }
+ });
+
+ async function submitFormAsync() {
+ errorData = { text: "", title: "" };
+ loading = true;
+ data.persist = !data.persist;
+ const loginResponse = await login(data);
+ if (loginResponse.ok) {
+ await goto("/home");
+ } else {
+ errorData.title = loginResponse.data.title;
+ errorData.text = loginResponse.data.text;
+ }
+ loading = false;
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if messageType}
+ <div class="sm:max-w-md sm:mx-auto sm:w-full">
+ {#if messageType === "after-password-reset"}
+ <Alert
+ title={$LL.signInPage.yourNewPasswordIsApplied()}
+ _pwKey={signInPageTestKeys.afterPasswordResetAlert}
+ message={$LL.signInPage.signInBelow()}
+ closeable
+ />
+ {:else if messageType === "user-disabled"}
+ <Alert
+ title={$LL.signInPage.yourAccountIsDisabled()}
+ _pwKey={signInPageTestKeys.userDisabledAlert}
+ message={$LL.signInPage.contactYourAdminIfDisabled()}
+ closeable
+ />
+ {:else if messageType === "user-inactivity"}
+ <Alert
+ title={$LL.signInPage.youHaveReachedInactivityLimit()}
+ _pwKey={signInPageTestKeys.userInactivityAlert}
+ message={$LL.signInPage.feelFreeToSignInAgain()}
+ closeable
+ />
+ {/if}
+ </div>
+ {/if}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signInPage.signIn()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-up" use:pwKey={signInPageTestKeys.signUpAnchor} class="link">{$LL.createANewAccount().toLowerCase()}</a>
+ </p>
+ </div>
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ {#if showErrorAlert}
+ <Alert title={errorData.title} message={errorData.text} type="error" _pwKey={signInPageTestKeys.formErrorAlert} />
+ {/if}
+ <form class="space-y-6" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={submitFormAsync}>
+ <Input
+ id="username"
+ _pwKey={signInPageTestKeys.usernameInput}
+ name="username"
+ type="email"
+ label={$LL.emailAddress()}
+ required
+ bind:value={data.username}
+ />
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ label={$LL.password()}
+ _pwKey={signInPageTestKeys.passwordInput}
+ autocomplete="current-password"
+ required
+ bind:value={data.password}
+ />
+
+ <div class="flex items-center justify-between">
+ <Checkbox
+ id="remember-me"
+ _pwKey={signInPageTestKeys.rememberMeCheckbox}
+ name="remember-me"
+ bind:checked={data.persist}
+ label={$LL.signInPage.notMyComputer()}
+ />
+ <div class="text-sm">
+ <a href="/reset-password" class="link" use:pwKey={signInPageTestKeys.resetPasswordAnchor}>
+ {$LL.signInPage.resetPassword()}
+ </a>
+ </div>
+ </div>
+
+ <Button text={$LL.submit()} fullWidth type="submit" {loading} />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/sign-in/index.ts b/code/app/src/routes/(main)/(public)/sign-in/index.ts
new file mode 100644
index 0000000..cbdcbf6
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/index.ts
@@ -0,0 +1,19 @@
+export enum Message {
+ AFTER_PASSWORD_RESET = "after-password-reset",
+ USER_INACTIVITY = "user-inactivity",
+ USER_DISABLED = "user-disabled",
+}
+
+export const messageQueryKey = "m";
+export const signInPageTestKeys = {
+ passwordInput: "password-input",
+ usernameInput: "username-input",
+ rememberMeCheckbox: "remember-me-checkbox",
+ signInForm: "sign-in-form",
+ userInactivityAlert: Message.USER_INACTIVITY + "-alert",
+ userDisabledAlert: Message.USER_DISABLED + "-alert",
+ afterPasswordResetAlert: Message.AFTER_PASSWORD_RESET + "-alert",
+ formErrorAlert: "form-error-alert",
+ resetPasswordAnchor: "reset-password-anchor",
+ signUpAnchor: "sign-up-anchor",
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts
new file mode 100644
index 0000000..ea8c494
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+import { signInPageTestKeys } from "../index";
+import { get_test_context } from "$lib/configuration";
+import { get_pw_key_selector } from "$lib/helpers";
+
+const context = get_test_context();
+
+test("form loads", async ({ page }) => {
+ page.goto("/sign-in");
+ const form = page.locator(get_pw_key_selector(signInPageTestKeys.signInForm));
+ expect(form.isVisible()).toBeTruthy();
+})
diff --git a/code/app/src/routes/(main)/(public)/sign-up/+page.svelte b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
new file mode 100644
index 0000000..0dfa41a
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { create_account } from "$lib/api/user";
+ import { Button, Input, Alert } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { CreateAccountPayload } from "$lib/models/CreateAccountPayload";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+
+ const formData = {
+ username: "",
+ password: "",
+ } as CreateAccountPayload;
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+ let loading = false;
+ $: showErrorAlert =
+ (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0;
+
+ async function submitFormAsync() {
+ loading = true;
+ errorData.text = "";
+ errorData.title = "";
+ const response = await create_account(formData);
+ loading = false;
+ if (response.ok) {
+ await goto("/home");
+ return;
+ }
+ errorData.title = response.data?.title ?? $LL.unexpectedError();
+ errorData.text = response.data?.text ?? $LL.tryAgainSoon();
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signUpPage.createYourNewAccount()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <Alert
+ title={errorData.title}
+ message={errorData.text}
+ type="error"
+ class="mb-2"
+ visible={showErrorAlert}
+ />
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ <Input
+ label={$LL.emailAddress()}
+ id="email"
+ name="email"
+ autocomplete="email"
+ required
+ type="email"
+ bind:value={formData.username}
+ />
+
+ <Input
+ label={$LL.password()}
+ id="password"
+ name="password"
+ required
+ type="password"
+ bind:value={formData.password}
+ />
+ <Button type="submit" text={$LL.submit()} {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/+layout.server.ts b/code/app/src/routes/(main)/+layout.server.ts
new file mode 100644
index 0000000..d2eb2eb
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.server.ts
@@ -0,0 +1,34 @@
+import { api_base, CookieNames } from "$lib/configuration";
+import { logError } from "$lib/logger";
+import { error, redirect } from "@sveltejs/kit";
+import type { LayoutServerLoad } from "./$types";
+
+export const load: LayoutServerLoad = async ({ routeId, cookies, locals }) => {
+ const isPublicRoute = (routeId?.startsWith("(main)/(public)") || routeId === "(main)") ?? true;
+
+ let sessionIsValid = (await fetch(api_base("_/valid-session"), {
+ headers: {
+ Cookie: CookieNames.session + "=" + cookies.get(CookieNames.session)
+ }
+ }).catch((e) => {
+ logError(e);
+ throw error(503, {
+ message: "We are experiencing a service distruption! Have patience while we resolve the issue."
+ })
+ })).ok;
+
+ console.log("Base Layout loaded", {
+ sessionIsValid,
+ isPublicRoute,
+ routeId
+ });
+
+ if (sessionIsValid && isPublicRoute) {
+ throw redirect(302, "/home");
+ } else if (!sessionIsValid && !isPublicRoute) {
+ throw redirect(302, "/sign-in");
+ }
+ return {
+ locale: locals.locale
+ }
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/+layout.svelte b/code/app/src/routes/(main)/+layout.svelte
new file mode 100644
index 0000000..1a870bb
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import "../../app.pcss";
+ import { setLocale } from "$lib/i18n/i18n-svelte";
+ import LocaleSwitcher from "$lib/components/locale-switcher.svelte";
+ import { ExclamationTriangleIcon } from "$lib/components/icons";
+ import type { LayoutData } from "./$types";
+
+ let online = true;
+ export let data: LayoutData;
+ setLocale(data.locale);
+</script>
+
+<svelte:window bind:online />
+
+{#if !online}
+ <div class="bg-yellow-50 relative z-50 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <ExclamationTriangleIcon class="bg-yellow-50 text-yellow-500" />
+ </div>
+ <div class="ml-3">
+ <p class="text-sm text-yellow-700">You seem to be offline, please check your internet connection.</p>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<LocaleSwitcher />
+<slot />
diff --git a/code/app/src/routes/(main)/+layout.ts b/code/app/src/routes/(main)/+layout.ts
new file mode 100644
index 0000000..5d0e005
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.ts
@@ -0,0 +1,15 @@
+import type { LayoutLoad } from './$types'
+import type { Locales } from '$lib/i18n/i18n-types'
+import { loadLocaleAsync } from '$lib/i18n/i18n-util.async'
+import { setLocale } from '$lib/i18n/i18n-svelte'
+
+export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => {
+ // load dictionary into memory
+ await loadLocaleAsync(locale)
+
+ // if you need to output a localized string in a `load` function,
+ // you always need to call `setLocale` right before you access the `LL` store
+ setLocale(locale)
+ // pass locale to the "rendering context"
+ return { locale }
+} \ No newline at end of file
diff --git a/code/app/src/routes/(main)/+page.svelte b/code/app/src/routes/(main)/+page.svelte
new file mode 100644
index 0000000..e507a19
--- /dev/null
+++ b/code/app/src/routes/(main)/+page.svelte
@@ -0,0 +1 @@
+<p class="text-bold p-1">Hold on...</p>
diff --git a/code/app/src/routes/book/+layout.svelte b/code/app/src/routes/book/+layout.svelte
new file mode 100644
index 0000000..aeed0d4
--- /dev/null
+++ b/code/app/src/routes/book/+layout.svelte
@@ -0,0 +1,64 @@
+<script>
+ import { page } from "$app/stores";
+ import "../../app.pcss";
+</script>
+
+<div id="wrapper">
+ <nav>
+ <a
+ href="/book/alerts"
+ class="link"
+ class:active={$page.url.pathname.startsWith("/book/alerts")}
+ >Alerts</a
+ >
+ <a
+ href="/book/buttons"
+ class="link"
+ class:active={$page.url.pathname.startsWith("/book/buttons")}
+ >Buttons</a
+ >
+ <a
+ href="/book/toggles"
+ class="link"
+ class:active={$page.url.pathname.startsWith("/book/toggles")}
+ >Toggles</a
+ >
+ <a
+ href="/book/inputs"
+ class="link"
+ class:active={$page.url.pathname.startsWith("/book/inputs")}
+ >Inputs</a
+ >
+ </nav>
+ <main>
+ <slot />
+ </main>
+</div>
+
+<style global lang="postcss">
+ #wrapper {
+ display: flex;
+ flex-direction: row;
+ }
+ nav {
+ min-width: 120px;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ position: -webkit-sticky;
+ top: 0;
+ height: fit-content;
+ }
+ main {
+ width: 100%;
+ padding: 10px;
+ }
+ section {
+ margin-bottom: 25px;
+
+ h2 {
+ margin-bottom: 5px;
+ }
+ }
+</style>
diff --git a/code/app/src/routes/book/+page.svelte b/code/app/src/routes/book/+page.svelte
new file mode 100644
index 0000000..635b3c2
--- /dev/null
+++ b/code/app/src/routes/book/+page.svelte
@@ -0,0 +1 @@
+<p>A showcase of greatoffices components</p>
diff --git a/code/app/src/routes/book/alerts/+page.svelte b/code/app/src/routes/book/alerts/+page.svelte
new file mode 100644
index 0000000..d008d85
--- /dev/null
+++ b/code/app/src/routes/book/alerts/+page.svelte
@@ -0,0 +1,70 @@
+<script>
+ import Alert from "$lib/components/alert.svelte";
+</script>
+
+<section>
+ <h2>Info</h2>
+ <Alert type="info" message="This is message" title="This is title" />
+</section>
+<section>
+ <h2>Warning</h2>
+ <Alert type="warning" message="This is message" title="This is title" />
+</section>
+<section>
+ <h2>Error</h2>
+ <Alert type="error" message="This is message" title="This is title" />
+</section>
+<section>
+ <h2>Success</h2>
+ <Alert type="success" message="This is message" title="This is title" />
+</section>
+<section>
+ <h2>Actions</h2>
+ <Alert
+ type="info"
+ message="This is message"
+ title="This is title"
+ closeable
+ actions={[
+ {
+ id: "confirm",
+ text: "Yes!",
+ },
+ {
+ id: "cancel",
+ text: "No!",
+ color: "red",
+ },
+ ]}
+ />
+</section>
+<section>
+ <h2>Right link</h2>
+ <Alert
+ on:rightLinkCliked={() => alert("Right link clicked")}
+ rightLinkText="Link or action"
+ title="Go here"
+ message="Hehe"
+ type="error"
+ />
+</section>
+<section>
+ <h2>List</h2>
+ <Alert
+ title="This is title"
+ listItems={["Message 1", "Message 2"]}
+ type="error"
+ message="This is bad dude"
+ closeable
+ closeableCooldown="60"
+ id="alert-1"
+ on:actrepeat={() => {
+ alert("Repeat requested");
+ }}
+ actions={[{ id: "repeat", text: "Try again" }]}
+ />
+</section>
+<section>
+ <h2>Closeable</h2>
+ <Alert message="This is message" closeable type="info" />
+</section>
diff --git a/code/app/src/routes/book/buttons/+page.svelte b/code/app/src/routes/book/buttons/+page.svelte
new file mode 100644
index 0000000..19ba163
--- /dev/null
+++ b/code/app/src/routes/book/buttons/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import Button from "$lib/components/button.svelte";
+</script>
+
+<section>
+ <h2>Primary</h2>
+ <Button kind="primary" text="Small" size="sm" />
+ <Button kind="primary" text="Medium/Default" />
+ <Button kind="primary" text="Large" size="lg" />
+ <Button kind="primary" text="Extra large" size="xl" />
+</section>
+<section>
+ <h2>Secondary</h2>
+ <Button kind="secondary" text="Click me!" />
+</section>
+<section>
+ <h2>White</h2>
+ <Button kind="white" text="Click me!" />
+</section>
+<section>
+ <h2>Loading</h2>
+ <Button kind="primary" loading={true} text="Wait" />
+</section>
diff --git a/code/app/src/routes/book/inputs/+page.svelte b/code/app/src/routes/book/inputs/+page.svelte
new file mode 100644
index 0000000..a693f69
--- /dev/null
+++ b/code/app/src/routes/book/inputs/+page.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ import Input from "$lib/components/input.svelte";
+ import { DatabaseIcon } from "$lib/components/icons";
+</script>
+
+<section>
+ <h2>Default</h2>
+ <Input label="Input me" placeholder="Hello" />
+</section>
+
+<section>
+ <h2>With icon</h2>
+ <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} />
+</section>
+
+<section>
+ <h2>With corner hint</h2>
+ <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" />
+</section>
+
+<section>
+ <h2>Disabled</h2>
+ <Input label="No" placeholder="Sorry" disabled />
+</section>
+
+<section>
+ <h2>Errored</h2>
+ <Input
+ label="No"
+ placeholder="Sorry"
+ errorText="That's not right"
+ icon={DatabaseIcon}
+ />
+</section>
+
+<section>
+ <h2>Help</h2>
+ <Input label="Go ahead" placeholder="Write here" helpText="Write above" />
+</section>
+<section>
+ <h2>Addon</h2>
+ <Input
+ label="Go ahead"
+ placeholder="Write here"
+ helpText="Write above"
+ addon="To the right"
+ />
+</section>
diff --git a/code/app/src/routes/book/toggles/+page.svelte b/code/app/src/routes/book/toggles/+page.svelte
new file mode 100644
index 0000000..94228b4
--- /dev/null
+++ b/code/app/src/routes/book/toggles/+page.svelte
@@ -0,0 +1,27 @@
+<script>
+ import Switch from "$lib/components/switch.svelte";
+</script>
+
+<section>
+ <h2>Default</h2>
+ <Switch />
+</section>
+<section>
+ <h2>Short</h2>
+ <Switch type="short" />
+</section>
+<section>
+ <h2>Icon</h2>
+ <Switch type="icon" />
+</section>
+<section>
+ <h2>Label / Description</h2>
+ <div class="max-w-md">
+ <Switch label="Label" description="Some text" />
+ </div>
+</section>
+
+<section>
+ <h2>Label / Description (right aligned)</h2>
+ <Switch label="Label" description="Some text" rightAlignedLabelDescription />
+</section> \ No newline at end of file
diff --git a/code/app/static/favicon.ico b/code/app/static/favicon.ico
new file mode 100644
index 0000000..6848441
--- /dev/null
+++ b/code/app/static/favicon.ico
Binary files differ
diff --git a/code/app/svelte.config.js b/code/app/svelte.config.js
new file mode 100644
index 0000000..3dff752
--- /dev/null
+++ b/code/app/svelte.config.js
@@ -0,0 +1,24 @@
+import adapter from "@sveltejs/adapter-node";
+import preprocess from "svelte-preprocess";
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: [
+ preprocess({
+ postcss: true,
+ }),
+ ],
+ kit: {
+ adapter: adapter(),
+ alias: {
+ "$actions": "src/actions",
+ "$lib": "src/lib",
+ "$routes": "src/routes",
+ },
+ prerender: {
+ enabled: false,
+ }
+ },
+};
+
+export default config;
diff --git a/code/app/tailwind.config.cjs b/code/app/tailwind.config.cjs
new file mode 100644
index 0000000..2f80e55
--- /dev/null
+++ b/code/app/tailwind.config.cjs
@@ -0,0 +1,135 @@
+const defaultColors = require("tailwindcss/colors");
+
+const refactoringUiPalette4 = {
+ "blue": {
+ "50": "#DCEEFB",
+ "100": "#B6E0FE",
+ "200": "#84C5F4",
+ "300": "#62B0E8",
+ "400": "#4098D7",
+ "500": "#2680C2",
+ "600": "#186FAF",
+ "700": "#0F609B",
+ "800": "#0A558C",
+ "900": "#003E6B",
+ },
+ "red": {
+ "50": "#FFEEEE",
+ "100": "#FACDCD",
+ "200": "#F29B9B",
+ "300": "#E66A6A",
+ "400": "#D64545",
+ "500": "#BA2525",
+ "600": "#A61B1B",
+ "700": "#911111",
+ "800": "#780A0A",
+ "900": "#610404",
+ },
+ "yellow": {
+ "50": "#FFFAEB",
+ "100": "#FCEFC7",
+ "200": "#F8E3A3",
+ "300": "#F9DA8B",
+ "400": "#F7D070",
+ "500": "#E9B949",
+ "600": "#C99A2E",
+ "700": "#A27C1A",
+ "800": "#7C5E10",
+ "900": "#513C06",
+ },
+ "purple": {
+ "50": "#EAE2F8",
+ "100": "#CFBCF2",
+ "200": "#A081D9",
+ "300": "#8662C7",
+ "400": "#724BB7",
+ "500": "#653CAD",
+ "600": "#51279B",
+ "700": "#421987",
+ "800": "#34126F",
+ "900": "#240754",
+ },
+ "blue-grey": {
+ "50": "#F0F4F8",
+ "100": "#D9E2EC",
+ "200": "#BCCCDC",
+ "300": "#9FB3C8",
+ "400": "#829AB1",
+ "500": "#627D98",
+ "600": "#486581",
+ "700": "#334E68",
+ "800": "#243B53",
+ "900": "#102A43",
+ },
+ "teal": {
+ "50": "#EFFCF6",
+ "100": "#C6F7E2",
+ "200": "#8EEDC7",
+ "300": "#65D6AD",
+ "400": "#3EBD93",
+ "500": "#27AB83",
+ "600": "#199473",
+ "700": "#147D64",
+ "800": "#0C6B58",
+ "900": "#014D40",
+ }
+}
+
+const config = {
+ content: ["./src/**/*.{html,js,svelte,ts}"],
+ theme: {
+ colors: {
+ "blue": refactoringUiPalette4.blue,
+ "red": refactoringUiPalette4.red,
+ "yellow": refactoringUiPalette4.yellow,
+ "purple": refactoringUiPalette4.purple,
+ "teal": refactoringUiPalette4.teal,
+ "green": refactoringUiPalette4.teal,
+ "gray": defaultColors.gray,
+ "white": defaultColors.white
+ }
+ },
+ plugins: [
+ require("@tailwindcss/forms"),
+ ],
+ safelist: [
+ "bg-blue-50",
+ "bg-yellow-50",
+ "bg-red-50",
+ "bg-green-50",
+ "text-blue-400",
+ "text-yellow-400",
+ "text-red-400",
+ "text-green-400",
+ "text-blue-800",
+ "text-yellow-800",
+ "text-red-800",
+ "text-green-800",
+ "text-blue-700",
+ "text-yellow-700",
+ "text-red-700",
+ "text-green-700",
+ "text-blue-500",
+ "text-yellow-500",
+ "text-red-500",
+ "text-green-500",
+ "hover:text-blue-600",
+ "hover:text-yellow-600",
+ "hover:text-red-600",
+ "hover:text-green-600",
+ "hover:bg-blue-100",
+ "hover:bg-yellow-100",
+ "hover:bg-red-100",
+ "hover:bg-green-100",
+ "focus:ring-blue-600",
+ "focus:ring-yellow-600",
+ "focus:ring-red-600",
+ "focus:ring-green-600",
+ "focus:ring-offset-blue-50",
+ "focus:ring-offset-yellow-50",
+ "focus:ring-offset-red-50",
+ "focus:ring-offset-green-50",
+ ]
+};
+
+module.exports = config;
diff --git a/code/app/tsconfig.json b/code/app/tsconfig.json
new file mode 100644
index 0000000..01d0864
--- /dev/null
+++ b/code/app/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "sourceMap": false,
+ }
+} \ No newline at end of file
diff --git a/code/app/vite.config.js b/code/app/vite.config.js
new file mode 100644
index 0000000..f777f75
--- /dev/null
+++ b/code/app/vite.config.js
@@ -0,0 +1,14 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+
+/** @type {import('vite').UserConfig} */
+const config = {
+ plugins: [sveltekit()],
+ build: { target: "es2020" },
+ optimizeDeps: {
+ esbuildOptions: {
+ target: "es2020"
+ }
+ }
+};
+
+export default config;
diff --git a/code/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs b/code/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs
new file mode 100644
index 0000000..10525fd
--- /dev/null
+++ b/code/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs
@@ -0,0 +1,23 @@
+using IOL.GreatOffice.IntegrationTests.Helpers;
+using Xunit;
+
+namespace IOL.GreatOffice.IntegrationTests.ApplicationTests;
+
+public class LoginPageTests : IClassFixture<WebServerFixture>
+{
+ private readonly WebServerFixture _fixture;
+
+ public LoginPageTests(WebServerFixture fixture) {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task LoginPageTestsRenders() {
+ var page = await _fixture.Browser.NewPageAsync();
+ await page.GotoAsync(_fixture.BaseUrl);
+
+ var actual = await page.TextContentAsync(Element.ByName("Page Title"));
+
+ Assert.Equal("Welcome", actual);
+ }
+}
diff --git a/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs b/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs
new file mode 100644
index 0000000..da83cc3
--- /dev/null
+++ b/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs
@@ -0,0 +1,6 @@
+namespace IOL.GreatOffice.IntegrationTests.Helpers;
+
+public static class Element
+{
+ public static string ByName(string name) => $"[pw-name='{name}']";
+}
diff --git a/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs b/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs
new file mode 100644
index 0000000..080fa9f
--- /dev/null
+++ b/code/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs
@@ -0,0 +1,48 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Playwright;
+using Xunit;
+using Program = IOL.GreatOffice.Api.Program;
+
+namespace IOL.GreatOffice.IntegrationTests.Helpers;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public class WebServerFixture : IAsyncLifetime, IDisposable
+{
+ private readonly WebApplication Host;
+ private IPlaywright Playwright { get; set; }
+ public IBrowser Browser { get; private set; }
+ public string BaseUrl { get; } = $"https://localhost:{GetRandomUnusedPort()}";
+
+ public WebServerFixture() {
+ Host = Program.CreateWebApplication(Program.CreateAppBuilder(default));
+ }
+
+ public async Task InitializeAsync() {
+ Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ Browser = await Playwright.Chromium.LaunchAsync();
+ await Host.StartAsync();
+ }
+
+ public async Task DisposeAsync() {
+ await Host.StopAsync();
+ await Host.DisposeAsync();
+ Playwright?.Dispose();
+ }
+
+ public void Dispose() {
+ Host.StopAsync();
+ Host.DisposeAsync();
+ Playwright?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static int GetRandomUnusedPort() {
+ var listener = new TcpListener(IPAddress.Any, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/code/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj b/code/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj
new file mode 100644
index 0000000..0376a10
--- /dev/null
+++ b/code/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj
@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <ImplicitUsings>true</ImplicitUsings>
+ <TargetFramework>net6.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>disable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
+ <PackageReference Include="Microsoft.Playwright" Version="1.22.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\server\IOL.GreatOffice.Api.csproj" />
+ <ProjectReference Include="..\..\server\src\IOL.GreatOffice.Api.csproj" />
+ </ItemGroup>
+</Project>