diff --git a/.dockerignore b/.dockerignore
index af50df1a9..bfde9bf96 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -19,7 +19,8 @@
**/node_modules
**/npm-debug.log
**/obj
+**/artifacts/publish
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
-README.md
\ No newline at end of file
+README.md
diff --git a/.github/workflows/deploy-branch.yaml b/.github/workflows/deploy-branch.yaml
index f8a199eff..5746a2520 100644
--- a/.github/workflows/deploy-branch.yaml
+++ b/.github/workflows/deploy-branch.yaml
@@ -41,10 +41,18 @@ jobs:
version: ${{ needs.set-version.outputs.version }}
label-latest: false
+ build-fw-headless:
+ name: Build FwHeadless
+ needs: [ set-version ]
+ uses: ./.github/workflows/lexbox-fw-headless.yaml
+ with:
+ version: ${{ needs.set-version.outputs.version }}
+ label-latest: false
+
deploy:
name: Deploy Develop
uses: ./.github/workflows/deploy.yaml
- needs: [ build-api, build-ui, build-hgweb, set-version ]
+ needs: [ build-api, build-ui, build-hgweb, build-fw-headless, set-version ]
secrets: inherit
with:
version: ${{ needs.set-version.outputs.version }}
diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
index 0ab79e53e..45d5e8045 100644
--- a/.github/workflows/deploy.yaml
+++ b/.github/workflows/deploy.yaml
@@ -50,6 +50,7 @@ jobs:
url: https://${{ inputs.deploy-domain }}
outputs:
api-version: ${{ steps.get-api-version.outputs.result }}
+ fw-headless-version: ${{ steps.get-fw-headless-version.outputs.result }}
ui-version: ${{ steps.get-ui-version.outputs.result }}
steps:
- name: Checkout lexbox repo
@@ -80,6 +81,11 @@ jobs:
id: get-api-version
with:
cmd: yq '.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-api").newTag' "fleet/${{ inputs.k8s-environment }}/kustomization.yaml"
+ - name: Get FwHeadless version
+ uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2
+ id: get-fw-headless-version
+ with:
+ cmd: yq '.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-fw-headless").newTag' "fleet/${{ inputs.k8s-environment }}/kustomization.yaml"
- name: Get UI version
uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2
id: get-ui-version
diff --git a/.github/workflows/develop-fw-headless.yaml b/.github/workflows/develop-fw-headless.yaml
new file mode 100644
index 000000000..6037f51a2
--- /dev/null
+++ b/.github/workflows/develop-fw-headless.yaml
@@ -0,0 +1,72 @@
+name: Develop FwHeadless CI/CD
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'backend/FwHeadless/**'
+ - 'backend/FixFwData/**'
+ - 'backend/FwLite/FwDataMiniLcmBridge/**'
+ - 'backend/FwLite/LcmCrdt/**'
+ - 'backend/FwLite/MiniLcm/**'
+ - 'backend/FwLiteProjectSync/FwLiteProjectSync/**'
+ - 'backend/LexCore/**'
+ - 'backend/LexData/**'
+ - '.github/workflows/lexbox-fw-headless.yaml'
+ - '.github/workflows/deploy.yaml'
+ - 'deployment/base/fw-headless-deployment.yaml'
+ branches:
+ - develop
+ pull_request:
+ paths:
+ - 'backend/FwHeadless/**'
+ - 'backend/FixFwData/**'
+ - 'backend/FwLite/FwDataMiniLcmBridge/**'
+ - 'backend/FwLite/LcmCrdt/**'
+ - 'backend/FwLite/MiniLcm/**'
+ - 'backend/FwLiteProjectSync/FwLiteProjectSync/**'
+ - 'backend/LexCore/**'
+ - 'backend/LexData/**'
+ - '.github/workflows/lexbox-fw-headless.yaml'
+ - '.github/workflows/deploy.yaml'
+ - 'deployment/base/fw-headless-deployment.yaml'
+ branches:
+ - develop
+
+jobs:
+ set-version:
+ name: Set Version
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.setVersion.outputs.VERSION }}
+ steps:
+ - name: Set Version
+ id: setVersion
+ # set version to date in vYYYY-MM-DD-commitSha format
+ run: |
+ shortSha=$(echo ${{ github.sha }} | cut -c1-8)
+ echo "VERSION=v$(date --rfc-3339=date)-$shortSha" >> ${GITHUB_OUTPUT}
+ build-fw-headless:
+ name: Build FwHeadless
+ needs: set-version
+ uses: ./.github/workflows/lexbox-fw-headless.yaml
+ with:
+ version: ${{ needs.set-version.outputs.version }}
+ deploy-fw-headless:
+ name: Deploy FwHeadless
+ if: ${{github.ref == 'refs/heads/develop'}}
+ needs: [ build-fw-headless, set-version ]
+ uses: ./.github/workflows/deploy.yaml
+ secrets: inherit
+ with:
+ version: ${{ needs.set-version.outputs.version }}
+ image: 'ghcr.io/sillsdev/lexbox-fw-headless'
+ k8s-environment: develop
+ deploy-domain: lexbox.dev.languagetechnology.org
+
+ # TODO: Run FwHeadless tests once we have developed them, but we don't need to run the whole integration test suite if only FwHeadless changes are being pushed
+ # integration-test-gha:
+ # name: GHA integration tests
+ # needs: [build-fw-headless, set-version]
+ # uses: ./.github/workflows/integration-test-gha.yaml
+ # with:
+ # lexbox-fw-headless-tag: ${{ needs.set-version.outputs.version }}
diff --git a/.github/workflows/integration-test-gha.yaml b/.github/workflows/integration-test-gha.yaml
index 8161dcb22..98a5dbe86 100644
--- a/.github/workflows/integration-test-gha.yaml
+++ b/.github/workflows/integration-test-gha.yaml
@@ -39,7 +39,16 @@ jobs:
uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2
with:
cmd: yq eval -i '(.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-api").newTag) = "${{ inputs.lexbox-api-tag }}"' "./deployment/gha/kustomization.yaml"
- # It's also possible that hgweb and/or ui image may have changed; if so, pull them and update kustomization.yaml for them as well
+ # It's also possible that hgweb, fw-headless, and/or ui image may have changed; if so, pull them and update kustomization.yaml for them as well
+ - name: Pull fw-headless if updated
+ id: fw-headless_image
+ continue-on-error: true
+ run: docker pull ghcr.io/sillsdev/lexbox-fw-headless:${{ inputs.lexbox-api-tag }}
+ - name: Update image fw-headless version
+ if: ${{ steps.fw-headless_image.outcome == 'success' }}
+ uses: mikefarah/yq@0b34c9a00de1c575a34eea05af1d956a525c4fc1 # v4.34.2
+ with:
+ cmd: yq eval -i '(.images.[] | select(.name == "ghcr.io/sillsdev/lexbox-fw-headless").newTag) = "${{ inputs.lexbox-api-tag }}"' "./deployment/gha/kustomization.yaml"
- name: Pull hgweb if updated
id: hgweb_image
continue-on-error: true
diff --git a/.github/workflows/lexbox-fw-headless.yaml b/.github/workflows/lexbox-fw-headless.yaml
new file mode 100644
index 000000000..3d49847ea
--- /dev/null
+++ b/.github/workflows/lexbox-fw-headless.yaml
@@ -0,0 +1,99 @@
+name: Build FwHeadless
+
+# https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#on
+on:
+ workflow_call:
+ inputs:
+ version:
+ description: 'The version of the image to build'
+ required: true
+ type: string
+ label-latest:
+ description: 'The label to apply to the latest image'
+ type: boolean
+ default: false
+
+env:
+ IMAGE_NAME: ghcr.io/sillsdev/lexbox-fw-headless
+
+
+jobs:
+ publish-fw-headless:
+ timeout-minutes: 60
+ runs-on: ubuntu-latest
+
+ # postgres db is for automated tests
+ # services:
+ # postgres:
+ # image: postgres:15-alpine
+ # env:
+ # POSTGRES_PASSWORD: 972b722e63f549938d07bd8c4ee5086c
+ # POSTGRES_DB: lexbox-tests
+ # # Set health checks to wait until postgres has started
+ # options: >-
+ # --health-cmd pg_isready
+ # --health-interval 10s
+ # --health-timeout 5s
+ # --health-retries 5
+ # ports:
+ # # Maps tcp port 5432 on service container to the host
+ # - 5433:5432
+
+ env:
+ # https://docs.docker.com/develop/develop-images/build_enhancements/
+ DOCKER_BUILDKIT: 1
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.x'
+ - name: Dotnet build
+ run: dotnet build backend/FwHeadless/FwHeadless.csproj
+ # TODO: Write FwHeadless unit tests, probably based on existing sync tests
+ # - name: Unit tests
+ # run: dotnet test backend/FwHeadless/FwHeadless.csproj --logger:"xunit;LogFileName={assembly}.results.xml" --results-directory ./test-results --filter "Category!=Integration&Category!=FlakyIntegration" --blame-hang-timeout 10m
+ # - name: Publish unit test results
+ # uses: EnricoMi/publish-unit-test-result-action@8885e273a4343cd7b48eaa72428dea0c3067ea98 # v2.14.0
+ # if: always()
+ # with:
+ # check_name: C# Unit Tests
+ # files: ./test-results/*.xml
+ # - name: Upload test results
+ # if: always()
+ # uses: actions/upload-artifact@v4
+ # with:
+ # name: dotnet-unit-test-results
+ # path: ./test-results
+
+ - name: Docker meta
+ id: meta
+ if: ${{ !env.ACT }}
+ uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1
+ with:
+ images: ${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=raw,enable=${{ inputs.label-latest }},value=latest
+ type=raw,value=${{ inputs.version }}
+
+ - name: ghcr.io login
+ uses: docker/login-action@e92390c5fb421da1463c202d546fed0ec5c39f20 # v3.1.0
+ if: ${{ !env.ACT }}
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: docker/build-push-action@2cdde995de11925a030ce8070c3d77a52ffcf1c0 # v5.3.0
+ with:
+ context: backend
+ file: backend/FwHeadless/Dockerfile
+ build-args: |
+ APP_VERSION=${{ inputs.version }}
+ push: ${{ !env.ACT && github.repository == 'sillsdev/languageforge-lexbox' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/release-pipeline.yaml b/.github/workflows/release-pipeline.yaml
index 92450db98..006e32548 100644
--- a/.github/workflows/release-pipeline.yaml
+++ b/.github/workflows/release-pipeline.yaml
@@ -43,10 +43,18 @@ jobs:
version: ${{ needs.set-version.outputs.version }}
label-latest: true
+ build-fw-headless:
+ name: Build fw-headless
+ needs: [ set-version ]
+ uses: ./.github/workflows/lexbox-fw-headless.yaml
+ with:
+ version: ${{ needs.set-version.outputs.version }}
+ label-latest: true
+
deploy:
name: Deploy Staging
uses: ./.github/workflows/deploy.yaml
- needs: [ build-api, build-ui, build-hgweb, set-version ]
+ needs: [ build-api, build-ui, build-hgweb, build-fw-headless, set-version ]
secrets: inherit
with:
version: ${{ needs.set-version.outputs.version }}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 13510c966..7f6e14c33 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -8,6 +8,7 @@
"redhat.vscode-yaml",
"task.vscode-task",
"dbaeumer.vscode-eslint",
- "katjanakosic.vscode-json5"
+ "katjanakosic.vscode-json5",
+ "tilt-dev.tiltfile"
]
}
diff --git a/LexBox.sln b/LexBox.sln
index 22a2bad17..612c0e95e 100644
--- a/LexBox.sln
+++ b/LexBox.sln
@@ -51,7 +51,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LcmDebugger", "backend\LfNe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MiniLcm.Tests", "backend\FwLite\MiniLcm.Tests\MiniLcm.Tests.csproj", "{00AE5440-0E36-4488-935B-5B11301BA57D}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CrdtMerge", "backend\CrdtMerge\CrdtMerge.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwHeadless", "backend\FwHeadless\FwHeadless.csproj", "{ECBA46AB-AF87-4D4D-9716-FD77264B817F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
diff --git a/README.md b/README.md
index 598419a5f..5e48f2ecc 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,6 @@ files related to a specific service should be in a folder named after the servic
There are some exceptions:
* `LexBox.sln` visual studio expects the sln to be at the root of the repo and can make things difficult otherwise
-Other files, like `skaffold.yaml`, should be at the root of the repo, because they're related to all services.
-
## Development
### Prerequisites
@@ -29,11 +27,10 @@ Other files, like `skaffold.yaml`, should be at the root of the repo, because th
* linux: `sudo snap install task --classic` or other options on their website
* mac: `brew install go-task/tap/go-task`
* via npm: `npm install -g @go-task/cli`
- * install [Skaffold](https://skaffold.dev/docs/install/#standalone-binary) and add it to your path
- * the file you will download is **NOT** an installer, just a standalone runnable .exe (on Windows) or binary (on Linux)
+ * install [Tilt](https://docs.tilt.dev/) and add it to your path
* on Linux, a good practice is to create `$HOME/.local/bin` and put binaries there; most distributions automatically add `$HOME/.local/bin` to your path if it exists
- * don't forget to run `chmod +x $HOME/.local/bin/skaffold`
- * on Windows, we suggest creating a `bin` folder in your home folder. Put the Skaffold binary there, then do the following:
+ * don't forget to run `chmod +x $HOME/.local/bin/tilt`
+ * on Windows, we suggest creating a `bin` folder in your home folder. Put the Tilt binary there, then do the following:
* go to your System properties, click the **Advanced** tab, and click **Environment Variables...**
* Click the Path variable (in either User or System, User is recommended) and click the **Edit...** button
* Add `C:\Users\YOUR_USER_NAME\bin` to the list and click **OK**
diff --git a/Taskfile.yml b/Taskfile.yml
index ca2e265ab..933eeff18 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -64,18 +64,15 @@ tasks:
# k8s
up:
interactive: true
+ aliases: [backend-up]
cmds:
- - skaffold dev --cleanup=false --kube-context docker-desktop
+ - tilt up
# dev
infra-up:
- desc: 'Starts infrastructure for our ui and api, if you want port forwarding use k8s:infra-forward'
+ desc: 'Starts infrastructure for our ui and api, does not forward ports for api, if you want port forwarding use k8s:infra-forward'
cmds:
- - skaffold run --cleanup=false --kube-context docker-desktop --profile=infra
- backend-up:
- desc: Starts entire backend for our ui (including the LexBox api)
- cmds:
- - skaffold dev --cleanup=false --kube-context docker-desktop --profile=no-frontend
+ - tilt up -- --lexbox-api-local
ui-dev:
aliases: [ ui ]
diff --git a/Tiltfile b/Tiltfile
new file mode 100644
index 000000000..0584c454a
--- /dev/null
+++ b/Tiltfile
@@ -0,0 +1,110 @@
+#!python
+# version_settings() enforces a minimum Tilt version
+# https://docs.tilt.dev/api.html#api.version_settings
+version_settings(constraint='>=0.33.20')
+secret_settings(disable_scrub=True)
+config.define_bool("lexbox-api-local")
+cfg = config.parse()
+forward_lexbox = not cfg.get("lexbox-api-local", False)
+
+docker_build(
+ 'local-dev-init',
+ 'data'
+)
+
+docker_build(
+ 'ghcr.io/sillsdev/lexbox-api',
+ context='backend',
+ dockerfile='./backend/LexBoxApi/dev.Dockerfile',
+ only=['.'],
+ ignore=['FwHeadless'],
+ live_update=[
+ sync('backend', '/src/backend')
+ ]
+)
+
+docker_build(
+ 'ghcr.io/sillsdev/lexbox-fw-headless',
+ context='backend',
+ dockerfile='./backend/FwHeadless/dev.Dockerfile',
+ only=['.'],
+ ignore=['LexBoxApi'],
+ live_update=[
+ sync('backend', '/src/backend')
+ ]
+)
+
+docker_build(
+ 'ghcr.io/sillsdev/lexbox-ui',
+ context='frontend',
+ dockerfile='./frontend/dev.Dockerfile',
+ only=['.'],
+ live_update=[
+ sync('frontend', '/app'),
+ ]
+)
+
+docker_build(
+ 'ghcr.io/sillsdev/lexbox-hgweb',
+ context='hgweb',
+ dockerfile='./hgweb/Dockerfile',
+ only=['.'],
+ build_args={"APP_VERSION": "dockerDev"},
+ live_update=[
+ sync('hgweb/repos', '/var/hg/repos'),
+ ]
+)
+
+k8s_yaml(kustomize('./deployment/local-dev'))
+allow_k8s_contexts('docker-desktop')
+
+lexbox_ports = [
+ port_forward(1080, name='maildev'),
+ port_forward(18888, name='aspire'),
+ port_forward(4318, name='otel') #otel
+]
+if forward_lexbox:
+ lexbox_ports = [port_forward(5158, name='lexbox-api', link_path='/api/swagger')] + lexbox_ports
+
+k8s_resource(
+ 'lexbox',
+ labels=['app'],
+ resource_deps=['db'],
+ port_forwards=lexbox_ports
+)
+k8s_resource(
+ 'ui',
+ labels=['app'],
+ links=[link('http://localhost', 'ui')]
+)
+k8s_resource(
+ 'fw-headless',
+ labels=['app'],
+ resource_deps=['db'],
+ port_forwards=[
+ port_forward(5275, 80, name='fw-headless')
+ ]
+)
+k8s_resource(
+ 'hg',
+ labels=['app'],
+ port_forwards=[
+ port_forward(8088, name='hg'),
+ port_forward(8034, 80, name='hg-resumable'),
+ ]
+)
+k8s_resource(
+ 'db',
+ port_forwards=[
+ port_forward(5433, 5432, name='db'),
+ ],
+ labels=["db"]
+)
+k8s_resource(
+ 'pgadmin',
+ resource_deps=['db'],
+ port_forwards=[
+ port_forward(4810, name='pgadmin'),
+ ],
+ labels=['db']
+)
diff --git a/backend/.dockerignore b/backend/.dockerignore
index 6ee9e3764..c2f36060e 100644
--- a/backend/.dockerignore
+++ b/backend/.dockerignore
@@ -2,3 +2,9 @@
**/obj
*Dockerfile
Testing
+**/artifacts/publish
+CrdtMerge/Mercurial
+CrdtMerge/MercurialExtensions
+*.sqlite
+*.sqlite-shm
+*.sqlite-wal
diff --git a/backend/FwHeadless/Dockerfile b/backend/FwHeadless/Dockerfile
new file mode 100644
index 000000000..022c350c4
--- /dev/null
+++ b/backend/FwHeadless/Dockerfile
@@ -0,0 +1,34 @@
+# syntax=docker/dockerfile:1
+FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
+WORKDIR /app
+EXPOSE 80
+EXPOSE 443
+
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+
+COPY . .
+RUN --mount=type=cache,target=/root/.nuget/packages dotnet restore "FwHeadless/FwHeadless.csproj"
+
+ARG APP_VERSION
+LABEL version=$APP_VERSION
+
+RUN --mount=type=cache,target=/root/.nuget/packages dotnet build /p:InformationalVersion=$APP_VERSION "FwHeadless/FwHeadless.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN --mount=type=cache,target=/root/.nuget/packages dotnet publish /p:InformationalVersion=$APP_VERSION "FwHeadless/FwHeadless.csproj" -c Release -o /app/publish
+
+FROM base AS final
+RUN mkdir -p /var/lib/fw-headless /var/www/.local/share && chown -R www-data:www-data /var/lib/fw-headless /var/www/.local/share
+RUN apt-get update \
+ && apt-get install --yes --no-install-recommends tini iputils-ping python3 \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=publish /app/publish .
+# Ensure Mercurial exec bit was not stripped by dotnet CLI tools
+RUN chmod +x Mercurial/hg && chmod +x Mercurial/chg 2>/dev/null || true
+# Fix up mercurial.ini path to fixutf8
+RUN sed -i -e 's/fixutf8 = \/FwHeadless/fixutf8 = \/app/' Mercurial/mercurial.ini
+USER www-data:www-data
+ENV XDG_DATA_HOME=/var/www/.local/share
+ENTRYPOINT ["tini", "--"]
+CMD ["dotnet", "FwHeadless.dll"]
diff --git a/backend/CrdtMerge/CrdtMerge.csproj b/backend/FwHeadless/FwHeadless.csproj
similarity index 95%
rename from backend/CrdtMerge/CrdtMerge.csproj
rename to backend/FwHeadless/FwHeadless.csproj
index 0380eb189..335b1e00b 100644
--- a/backend/CrdtMerge/CrdtMerge.csproj
+++ b/backend/FwHeadless/FwHeadless.csproj
@@ -24,7 +24,7 @@
-
-
+
+
diff --git a/backend/CrdtMerge/CrdtMergeConfig.cs b/backend/FwHeadless/FwHeadlessConfig.cs
similarity index 90%
rename from backend/CrdtMerge/CrdtMergeConfig.cs
rename to backend/FwHeadless/FwHeadlessConfig.cs
index 8d21a36b1..ec664817e 100644
--- a/backend/CrdtMerge/CrdtMergeConfig.cs
+++ b/backend/FwHeadless/FwHeadlessConfig.cs
@@ -1,8 +1,8 @@
using System.ComponentModel.DataAnnotations;
-namespace CrdtMerge;
+namespace FwHeadless;
-public class CrdtMergeConfig
+public class FwHeadlessConfig
{
[Required, Url, RegularExpression(@"^.+/$", ErrorMessage = "Must end with '/'")]
public required string LexboxUrl { get; init; }
diff --git a/backend/CrdtMerge/CrdtMergeKernel.cs b/backend/FwHeadless/FwHeadlessKernel.cs
similarity index 69%
rename from backend/CrdtMerge/CrdtMergeKernel.cs
rename to backend/FwHeadless/FwHeadlessKernel.cs
index 0c312c074..9a0d42b95 100644
--- a/backend/CrdtMerge/CrdtMergeKernel.cs
+++ b/backend/FwHeadless/FwHeadlessKernel.cs
@@ -2,16 +2,16 @@
using FwLiteProjectSync;
using LcmCrdt;
-namespace CrdtMerge;
+namespace FwHeadless;
-public static class CrdtMergeKernel
+public static class FwHeadlessKernel
{
- public static void AddCrdtMerge(this IServiceCollection services)
+ public static void AddFwHeadless(this IServiceCollection services)
{
services
.AddLogging(builder => builder.AddConsole().AddDebug().AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning));
- services.AddOptions()
- .BindConfiguration("SendReceiveConfig")
+ services.AddOptions()
+ .BindConfiguration("FwHeadlessConfig")
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddScoped();
diff --git a/backend/CrdtMerge/Program.cs b/backend/FwHeadless/Program.cs
similarity index 95%
rename from backend/CrdtMerge/Program.cs
rename to backend/FwHeadless/Program.cs
index e2604a9ec..261dedd9b 100644
--- a/backend/CrdtMerge/Program.cs
+++ b/backend/FwHeadless/Program.cs
@@ -1,4 +1,4 @@
-using CrdtMerge;
+using FwHeadless;
using FwDataMiniLcmBridge;
using FwLiteProjectSync;
using LcmCrdt;
@@ -14,12 +14,14 @@
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
+builder.Services.AddHealthChecks();
+
builder.Services.AddLexData(
autoApplyMigrations: false,
useOpenIddict: false
);
-builder.Services.AddCrdtMerge();
+builder.Services.AddFwHeadless();
var app = builder.Build();
@@ -33,6 +35,8 @@
app.UseHttpsRedirection();
+app.MapHealthChecks("/api/healthz");
+
app.MapPost("/sync", ExecuteMergeRequest);
app.Run();
@@ -41,7 +45,7 @@
ILogger logger,
IServiceProvider services,
SendReceiveService srService,
- IOptions config,
+ IOptions config,
FwDataFactory fwDataFactory,
ProjectsService projectsService,
ProjectLookupService projectLookupService,
diff --git a/backend/CrdtMerge/ProjectLookupService.cs b/backend/FwHeadless/ProjectLookupService.cs
similarity index 94%
rename from backend/CrdtMerge/ProjectLookupService.cs
rename to backend/FwHeadless/ProjectLookupService.cs
index ac8244e99..9cb8cb971 100644
--- a/backend/CrdtMerge/ProjectLookupService.cs
+++ b/backend/FwHeadless/ProjectLookupService.cs
@@ -1,7 +1,7 @@
using LexData;
using Microsoft.EntityFrameworkCore;
-namespace CrdtMerge;
+namespace FwHeadless;
public class ProjectLookupService(LexBoxDbContext dbContext)
{
diff --git a/backend/CrdtMerge/Properties/launchSettings.json b/backend/FwHeadless/Properties/launchSettings.json
similarity index 100%
rename from backend/CrdtMerge/Properties/launchSettings.json
rename to backend/FwHeadless/Properties/launchSettings.json
diff --git a/backend/CrdtMerge/SendReceiveHelpers.cs b/backend/FwHeadless/SendReceiveHelpers.cs
similarity index 96%
rename from backend/CrdtMerge/SendReceiveHelpers.cs
rename to backend/FwHeadless/SendReceiveHelpers.cs
index 8eb1404c0..9c8ac9494 100644
--- a/backend/CrdtMerge/SendReceiveHelpers.cs
+++ b/backend/FwHeadless/SendReceiveHelpers.cs
@@ -1,7 +1,7 @@
using FwDataMiniLcmBridge;
using SIL.Progress;
-namespace CrdtMerge;
+namespace FwHeadless;
public static class SendReceiveHelpers
{
@@ -12,7 +12,7 @@ public record ProjectPath(string Code, string Dir)
public record SendReceiveAuth(string Username, string Password)
{
- public SendReceiveAuth(CrdtMergeConfig config) : this(config.LexboxUsername, config.LexboxPassword) { }
+ public SendReceiveAuth(FwHeadlessConfig config) : this(config.LexboxUsername, config.LexboxPassword) { }
};
public record LfMergeBridgeResult(string Output, string ProgressMessages);
diff --git a/backend/CrdtMerge/SendReceiveService.cs b/backend/FwHeadless/SendReceiveService.cs
similarity index 91%
rename from backend/CrdtMerge/SendReceiveService.cs
rename to backend/FwHeadless/SendReceiveService.cs
index 14db577ae..37023dc8f 100644
--- a/backend/CrdtMerge/SendReceiveService.cs
+++ b/backend/FwHeadless/SendReceiveService.cs
@@ -1,9 +1,9 @@
using FwDataMiniLcmBridge;
using Microsoft.Extensions.Options;
-namespace CrdtMerge;
+namespace FwHeadless;
-public class SendReceiveService(IOptions config)
+public class SendReceiveService(IOptions config)
{
public SendReceiveHelpers.LfMergeBridgeResult SendReceive(FwDataProject project, string? projectCode, string? commitMessage = null)
{
diff --git a/backend/CrdtMerge/appsettings.Development.json b/backend/FwHeadless/appsettings.Development.json
similarity index 95%
rename from backend/CrdtMerge/appsettings.Development.json
rename to backend/FwHeadless/appsettings.Development.json
index 0995c2046..22a96c2a5 100644
--- a/backend/CrdtMerge/appsettings.Development.json
+++ b/backend/FwHeadless/appsettings.Development.json
@@ -1,5 +1,5 @@
{
- "SendReceiveConfig": {
+ "FwHeadlessConfig": {
"ProjectStorageRoot": "../../hgweb/repos",
"LexboxUrl": "http://localhost/",
"LexboxUsername": "admin",
diff --git a/backend/CrdtMerge/appsettings.json b/backend/FwHeadless/appsettings.json
similarity index 87%
rename from backend/CrdtMerge/appsettings.json
rename to backend/FwHeadless/appsettings.json
index d766d8006..d56001975 100644
--- a/backend/CrdtMerge/appsettings.json
+++ b/backend/FwHeadless/appsettings.json
@@ -1,5 +1,5 @@
{
- "CrdtMergeConfig": {
+ "FwHeadlessConfig": {
"LexboxUsername": null
},
"Logging": {
diff --git a/backend/FwHeadless/dev.Dockerfile b/backend/FwHeadless/dev.Dockerfile
new file mode 100644
index 000000000..db60a422c
--- /dev/null
+++ b/backend/FwHeadless/dev.Dockerfile
@@ -0,0 +1,48 @@
+# syntax=docker/dockerfile:1
+FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
+EXPOSE 80
+EXPOSE 443
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt update && apt-get --no-install-recommends install -y tini iputils-ping python3
+RUN mkdir -p /var/lib/fw-headless /var/www/.local/share && chown -R www-data:www-data /var/lib/fw-headless /var/www/
+USER www-data:www-data
+WORKDIR /src/backend
+# Uncomment line below if second COPY fails
+# RUN mkdir -p FwLite && chown www-data:www-data FwLite
+# Copy the main source project files
+COPY --chown=www-data:www-data *.sln FwHeadless/FwHeadless.csproj FixFwData/FixFwData.csproj LexCore/LexCore.csproj LexData/LexData.csproj ./
+# move them into the proper sub folders, based on the name of the project
+RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p ${dir}/ && mv -v $file ${dir}/; done
+# Do the same for csproj files in slightly different hierarchies
+COPY --chown=www-data:www-data harmony/src/*/*.csproj ./
+RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/ && mv -v $file harmony/src/${dir}/; done
+COPY --chown=www-data:www-data harmony/src/Directory.Build.props ./harmony/src/
+COPY --chown=www-data:www-data FwLite/FwDataMiniLcmBridge/FwDataMiniLcmBridge.csproj FwLite/LcmCrdt/LcmCrdt.csproj FwLite/MiniLcm/MiniLcm.csproj FwLite/FwLiteProjectSync/FwLiteProjectSync.csproj ./
+RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done
+
+ARG CACHE_LOCATION=/src/dotnet-cache
+RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \
+cp -r $CACHE_LOCATION/.local $CACHE_LOCATION/.nuget /var/www/ || true
+
+# Now that all csproj files are in place, restore them
+RUN dotnet restore FwHeadless/FwHeadless.csproj
+
+#the cache needs to be stored in the image,
+#so we can't use the cache on the restore command, so we back it up to the cache here
+
+RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \
+ cp -r /var/www/.local /var/www/.nuget $CACHE_LOCATION/
+
+COPY --chown=www-data:www-data . .
+WORKDIR /src/backend/FwHeadless
+#build here so that the build is run before container start, need to make sure the property is set both here
+#and in the CMD command, otherwise it will rebuild every time the container starts
+RUN dotnet build --property:InformationalVersion=dockerDev
+
+#ensures the shutdown happens quickly
+ENTRYPOINT ["tini", "--"]
+
+ENV ASPNETCORE_ENVIRONMENT=Development
+# no need to restore because we already restored as part of building the image
+CMD dotnet watch run --property:InformationalVersion=dockerDev --no-restore --non-interactive
diff --git a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs
index a86c8440b..0489e7243 100644
--- a/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs
+++ b/backend/LexBoxApi/GraphQL/CustomTypes/IsLanguageForgeProjectDataLoader.cs
@@ -20,7 +20,7 @@ public IsLanguageForgeProjectDataLoader(
IBatchScheduler batchScheduler,
[FromKeyedServices(ResiliencePolicyName)]
ResiliencePipeline> resiliencePipeline,
- DataLoaderOptions? options = null)
+ DataLoaderOptions options)
: base(batchScheduler, options)
{
_resiliencePipeline = resiliencePipeline;
diff --git a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
index 33b75e1f5..abd27c078 100644
--- a/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
+++ b/backend/LexBoxApi/GraphQL/GraphQlSetupKernel.cs
@@ -1,10 +1,7 @@
using DataAnnotatedModelValidations;
using HotChocolate.Diagnostics;
-using LexBoxApi.Auth;
using LexBoxApi.GraphQL.CustomFilters;
using LexBoxApi.Services;
-using LexBoxApi.Services.Email;
-using LexCore.ServiceInterfaces;
using LexData;
namespace LexBoxApi.GraphQL;
@@ -18,15 +15,14 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
if (forceGenerateSchema || env.IsDevelopment())
services.AddHostedService();
- services.AddGraphQLServer()
+ services
+ .AddGraphQLServer()
+ .ModifyCostOptions(options =>
+ {
+ // See: https://github.com/sillsdev/languageforge-lexbox/issues/1179
+ options.EnforceCostLimits = false;
+ })
.InitializeOnStartup()
- .RegisterDbContext()
- .RegisterService()
- .RegisterService()
- .RegisterService()
- .RegisterService()
- .RegisterService()
- .RegisterService()
.AddDataAnnotationsValidator()
.AddSorting(descriptor =>
{
@@ -39,11 +35,11 @@ public static void AddLexGraphQL(this IServiceCollection services, IHostEnvironm
descriptor.AddDeterministicInvariantContainsFilter();
})
.AddProjections()
- .SetPagingOptions(new()
+ .ModifyPagingOptions(options =>
{
- DefaultPageSize = 100,
- MaxPageSize = 1000,
- IncludeTotalCount = true
+ options.DefaultPageSize = 100;
+ options.MaxPageSize = 1000;
+ options.IncludeTotalCount = true;
})
.AddAuthorization()
.AddLexBoxApiTypes()
diff --git a/backend/LexBoxApi/GraphQL/LexQueries.cs b/backend/LexBoxApi/GraphQL/LexQueries.cs
index bd70ef7af..442fcd8f3 100644
--- a/backend/LexBoxApi/GraphQL/LexQueries.cs
+++ b/backend/LexBoxApi/GraphQL/LexQueries.cs
@@ -162,7 +162,11 @@ public IQueryable UsersInMyOrg(LexBoxDbContext context, LoggedInContext lo
IPermissionService permissionService,
IResolverContext context)
{
- var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(context).SingleOrDefaultAsync();
+ //todo remove this workaround once the issue is fixed
+ var projectContext =
+ context.GetLocalStateOrDefault("HotChocolate.Data.Projections.ProxyContext") ??
+ context;
+ var org = await dbContext.Orgs.Where(o => o.Id == orgId).AsNoTracking().Project(projectContext).SingleOrDefaultAsync();
if (org is null) return org;
// Site admins and org admins can see everything
if (permissionService.CanEditOrg(orgId)) return org;
diff --git a/backend/LexBoxApi/GraphQL/OrgMutations.cs b/backend/LexBoxApi/GraphQL/OrgMutations.cs
index df62a3995..54c71f6ce 100644
--- a/backend/LexBoxApi/GraphQL/OrgMutations.cs
+++ b/backend/LexBoxApi/GraphQL/OrgMutations.cs
@@ -21,6 +21,7 @@ public class OrgMutations
[UseMutationConvention]
[UseFirstOrDefault]
[UseProjection]
+ [RefreshJwt]
public async Task> CreateOrganization(string name,
LexBoxDbContext dbContext,
LoggedInContext loggedInContext,
@@ -65,7 +66,7 @@ public async Task DeleteOrg(Guid orgId,
public async Task> AddProjectToOrg(
LexBoxDbContext dbContext,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
Guid orgId,
Guid projectId)
{
@@ -99,7 +100,7 @@ public async Task> AddProjectToOrg(
public async Task AddProjectsToOrg(
LexBoxDbContext dbContext,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
IResolverContext resolverContext,
Guid orgId,
Guid[] projectIds)
@@ -138,18 +139,17 @@ public async Task> AddProjectToOrg(
public async Task> RemoveProjectFromOrg(
LexBoxDbContext dbContext,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
Guid orgId,
Guid projectId)
{
var org = await dbContext.Orgs.Include(o => o.Members).SingleOrDefaultAsync(o => o.Id == orgId);
NotFoundException.ThrowIfNull(org);
- permissionService.AssertCanAddProjectToOrg(org);
var project = await dbContext.Projects.Where(p => p.Id == projectId)
.Include(p => p.Organizations)
.SingleOrDefaultAsync();
NotFoundException.ThrowIfNull(project);
- await permissionService.AssertCanManageProject(projectId);
+ await permissionService.AssertCanRemoveProjectFromOrg(org, projectId);
var foundOrg = project.Organizations.FirstOrDefault(o => o.Id == orgId);
if (foundOrg is not null)
{
@@ -185,7 +185,7 @@ public async Task> SetOrgMemberRole(
OrgRole role,
string emailOrUsername,
bool canInvite,
- [Service] IEmailService emailService)
+ IEmailService emailService)
{
var org = await dbContext.Orgs.FindAsync(orgId);
NotFoundException.ThrowIfNull(org);
@@ -250,6 +250,30 @@ public async Task> ChangeOrgMemberRole(
return dbContext.Orgs.Where(o => o.Id == orgId);
}
+ [Error]
+ [Error]
+ [UseMutationConvention]
+ [RefreshJwt]
+ public async Task LeaveOrg(
+ Guid orgId,
+ LoggedInContext loggedInContext,
+ LexBoxDbContext dbContext)
+ {
+ var org = await dbContext.Orgs.Where(p => p.Id == orgId)
+ .Include(p => p.Members)
+ .SingleOrDefaultAsync();
+ NotFoundException.ThrowIfNull(org);
+ var member = org.Members.FirstOrDefault(u => u.UserId == loggedInContext.User.Id);
+ if (member is null) return org;
+ if (member.Role == OrgRole.Admin && org.Members.Count(m => m.Role == OrgRole.Admin) == 1)
+ {
+ throw new LastMemberCantLeaveException();
+ }
+ org.Members.Remove(member);
+ await dbContext.SaveChangesAsync();
+ return org;
+ }
+
private async Task UpdateOrgMemberRole(LexBoxDbContext dbContext, Organization org, OrgRole? role, Guid userId)
{
var member = org.Members.FirstOrDefault(m => m.UserId == userId);
diff --git a/backend/LexBoxApi/GraphQL/ProjectMutations.cs b/backend/LexBoxApi/GraphQL/ProjectMutations.cs
index 3ceeeb163..ccdb9046e 100644
--- a/backend/LexBoxApi/GraphQL/ProjectMutations.cs
+++ b/backend/LexBoxApi/GraphQL/ProjectMutations.cs
@@ -37,8 +37,8 @@ public record CreateProjectResponse(Guid? Id, CreateProjectResult Result);
LoggedInContext loggedInContext,
IPermissionService permissionService,
CreateProjectInput input,
- [Service] ProjectService projectService,
- [Service] IEmailService emailService)
+ ProjectService projectService,
+ IEmailService emailService)
{
if (!loggedInContext.User.IsAdmin)
{
@@ -73,7 +73,7 @@ public async Task> AddProjectMember(
LoggedInContext loggedInContext,
AddProjectMemberInput input,
LexBoxDbContext dbContext,
- [Service] IEmailService emailService)
+ IEmailService emailService)
{
await permissionService.AssertCanManageProject(input.ProjectId);
var project = await dbContext.Projects.FindAsync(input.ProjectId);
@@ -248,7 +248,7 @@ public async Task> AskToJoinProject(
LoggedInContext loggedInContext,
Guid projectId,
LexBoxDbContext dbContext,
- [Service] IEmailService emailService)
+ IEmailService emailService)
{
await permissionService.AssertCanAskToJoinProject(projectId);
@@ -320,7 +320,7 @@ public async Task> ChangeProjectDescription(ChangeProjectDes
[UseProjection]
public async Task> SetProjectConfidentiality(SetProjectConfidentialityInput input,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
await permissionService.AssertCanManageProject(input.ProjectId);
@@ -342,7 +342,7 @@ public async Task> SetProjectConfidentiality(SetProjectConfi
public async Task> SetRetentionPolicy(
SetRetentionPolicyInput input,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
await permissionService.AssertCanManageProject(input.ProjectId);
@@ -363,7 +363,7 @@ public async Task> SetRetentionPolicy(
[UseProjection]
public async Task> UpdateProjectLexEntryCount(string code,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
var projectId = await projectService.LookupProjectId(code);
@@ -382,7 +382,7 @@ public async Task> UpdateProjectLexEntryCount(string code,
[UseProjection]
public async Task> UpdateProjectLanguageList(string code,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
var projectId = await projectService.LookupProjectId(code);
@@ -401,7 +401,7 @@ public async Task> UpdateProjectLanguageList(string code,
[UseProjection]
public async Task> UpdateLangProjectId(string code,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
var projectId = await projectService.LookupProjectId(code);
@@ -420,7 +420,7 @@ public async Task> UpdateLangProjectId(string code,
[UseProjection]
public async Task> UpdateFLExModelVersion(string code,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext)
{
var projectId = await projectService.LookupProjectId(code);
@@ -499,7 +499,7 @@ public async Task DeleteDraftProject(
public async Task> SoftDeleteProject(
Guid projectId,
IPermissionService permissionService,
- [Service] ProjectService projectService,
+ ProjectService projectService,
LexBoxDbContext dbContext,
IHgService hgService)
{
diff --git a/backend/LexBoxApi/LexBoxApi.csproj b/backend/LexBoxApi/LexBoxApi.csproj
index e1d319dc9..baffc9fb9 100644
--- a/backend/LexBoxApi/LexBoxApi.csproj
+++ b/backend/LexBoxApi/LexBoxApi.csproj
@@ -14,16 +14,16 @@
-
-
-
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
-
+
+
+
+
diff --git a/backend/LexBoxApi/Program.cs b/backend/LexBoxApi/Program.cs
index 904dee824..a25d36415 100644
--- a/backend/LexBoxApi/Program.cs
+++ b/backend/LexBoxApi/Program.cs
@@ -3,6 +3,7 @@
using System.Text.Json.Serialization;
using AppAny.Quartz.EntityFrameworkCore.Migrations;
using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL;
+using HotChocolate.AspNetCore;
using LexBoxApi;
using LexBoxApi.Auth;
using LexBoxApi.Auth.Attributes;
@@ -167,7 +168,7 @@
app.UseAuthentication();
app.UseAuthorization();
app.MapSecurityTxt();
-app.MapBananaCakePop("/api/graphql/ui").AllowAnonymous();
+app.MapNitroApp("/api/graphql/ui").WithOptions(new (){ServeMode = GraphQLToolServeMode.Embedded}).AllowAnonymous();
if (app.Environment.IsDevelopment())
//required for vite to generate types
app.MapGraphQLSchema("/api/graphql/schema.graphql").AllowAnonymous();
diff --git a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
index 712286921..ec291a45a 100644
--- a/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
+++ b/backend/LexBoxApi/Services/DevGqlSchemaWriterService.cs
@@ -1,5 +1,10 @@
using HotChocolate.Execution;
+using LexBoxApi.Auth;
using LexBoxApi.GraphQL;
+using LexBoxApi.GraphQL.CustomTypes;
+using LexBoxApi.Services.Email;
+using LexCore.ServiceInterfaces;
+using LexData;
using Microsoft.Extensions.Hosting.Internal;
namespace LexBoxApi.Services;
@@ -18,9 +23,17 @@ public static bool IsSchemaGenerationRequest(string[] args)
public static async Task GenerateGqlSchema(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
- builder.Services.AddLogging();
- builder.Services.AddSingleton();
- builder.Services.AddLexGraphQL(builder.Environment, true);
+ builder.Services
+ .AddLogging()
+ .AddSingleton()
+ .AddScoped()
+ .AddScoped()
+ .AddScoped()
+ .AddScoped((services) => new LoggedInContext(null!, null!))
+ .AddScoped((services) => new LexBoxDbContext(null!, null!))
+ .AddScoped()
+ .AddScoped()
+ .AddLexGraphQL(builder.Environment, true);
var host = builder.Build();
await host.StartAsync();
await host.StopAsync();
diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs
index fcaec39e5..83b1d158c 100644
--- a/backend/LexBoxApi/Services/PermissionService.cs
+++ b/backend/LexBoxApi/Services/PermissionService.cs
@@ -196,10 +196,15 @@ public void AssertHasProjectRequestPermission()
if (!HasProjectRequestPermission()) throw new UnauthorizedAccessException();
}
+ public bool CanCreateOrg()
+ {
+ return User is {Role: UserRole.admin};
+ }
+
public void AssertCanCreateOrg()
{
//todo adjust permission
- if (!HasProjectCreatePermission()) throw new UnauthorizedAccessException();
+ if (!CanCreateOrg()) throw new UnauthorizedAccessException();
}
public bool IsOrgMember(Guid orgId)
@@ -234,4 +239,13 @@ public void AssertCanAddProjectToOrg(Organization org)
if (org.Members.Any(m => m.UserId == User.Id)) return;
throw new UnauthorizedAccessException();
}
+
+ public async ValueTask AssertCanRemoveProjectFromOrg(Organization org, Guid projectId)
+ {
+ // Org managers can kick projects out and project managers can pull projects out
+ if (!CanEditOrg(org.Id) && !await CanManageProject(projectId))
+ {
+ throw new UnauthorizedAccessException();
+ }
+ }
}
diff --git a/backend/LexBoxApi/dev.Dockerfile b/backend/LexBoxApi/dev.Dockerfile
index 8913eef77..bbedcbe09 100644
--- a/backend/LexBoxApi/dev.Dockerfile
+++ b/backend/LexBoxApi/dev.Dockerfile
@@ -18,9 +18,20 @@ RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p harmony/src/${dir}/
COPY harmony/src/Directory.Build.props ./harmony/src/
COPY FwLite/*/*.csproj ./
RUN for file in $(ls *.csproj); do dir=${file%.*}; mkdir -p FwLite/${dir}/ && mv -v $file FwLite/${dir}/; done
+
+ARG CACHE_LOCATION=/src/dotnet-cache
+RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \
+cp -r $CACHE_LOCATION/.local $CACHE_LOCATION/.nuget /var/www/ || true
+
# Now that all csproj files are in place, restore them
RUN dotnet restore FixFwData/FixFwData.csproj; dotnet restore LexBoxApi/LexBoxApi.csproj
+#the cache needs to be stored in the image,
+#so we can't use the cache on the restore command, so we back it up to the cache here
+
+RUN --mount=type=cache,target=$CACHE_LOCATION,uid=33,gid=33 \
+ cp -r /var/www/.local /var/www/.nuget $CACHE_LOCATION/
+
COPY --chown=www-data . .
WORKDIR /src/backend/LexBoxApi
#build here so that the build is run before container start, need to make sure the property is set both here
@@ -34,4 +45,4 @@ ENTRYPOINT ["tini", "--"]
# no need to restore because we already restored as part of building the image
ENV ASPNETCORE_ENVIRONMENT=Development
ENV DOTNET_URLS=http://0.0.0.0:5158
-CMD dotnet watch --no-hot-reload run --property:InformationalVersion=dockerDev --no-restore
+CMD dotnet watch run --property:InformationalVersion=dockerDev --no-restore --non-interactive
diff --git a/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs b/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs
index e5a509f6c..d050e09ca 100644
--- a/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs
+++ b/backend/LexCore/Exceptions/LastMemberCantLeaveException.cs
@@ -2,7 +2,7 @@
public class LastMemberCantLeaveException : Exception
{
- public LastMemberCantLeaveException() : base("The last member of a project can't leave the project.")
+ public LastMemberCantLeaveException() : base("The last member can't leave.")
{
}
}
diff --git a/backend/LexCore/ServiceInterfaces/IPermissionService.cs b/backend/LexCore/ServiceInterfaces/IPermissionService.cs
index dec18d5bd..13e488fc1 100644
--- a/backend/LexCore/ServiceInterfaces/IPermissionService.cs
+++ b/backend/LexCore/ServiceInterfaces/IPermissionService.cs
@@ -43,4 +43,5 @@ public interface IPermissionService
void AssertCanEditOrg(Organization org);
void AssertCanEditOrg(Guid orgId);
void AssertCanAddProjectToOrg(Organization org);
+ ValueTask AssertCanRemoveProjectFromOrg(Organization org, Guid projectId);
}
diff --git a/backend/LexData/DbStartupService.cs b/backend/LexData/DbStartupService.cs
index 9f6108362..c75abf841 100644
--- a/backend/LexData/DbStartupService.cs
+++ b/backend/LexData/DbStartupService.cs
@@ -96,6 +96,10 @@ private async Task TryMigrate(DbContext dbContext, CancellationToken cance
{
return false;
}
+ catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TryAgain)
+ {
+ return false;
+ }
}
public Task StopAsync(CancellationToken cancellationToken)
diff --git a/backend/LexData/Entities/OrganizationEntityConfiguration.cs b/backend/LexData/Entities/OrganizationEntityConfiguration.cs
index b8eda4636..07774376b 100644
--- a/backend/LexData/Entities/OrganizationEntityConfiguration.cs
+++ b/backend/LexData/Entities/OrganizationEntityConfiguration.cs
@@ -12,6 +12,7 @@ public override void Configure(EntityTypeBuilder builder)
base.Configure(builder);
builder.ToTable("Orgs");
builder.HasIndex(o => o.Name).IsUnique();
+ builder.Property(u => u.Name).UseCollation(LexBoxDbContext.CaseInsensitiveCollation);
builder.HasMany(o => o.Members)
.WithOne(m => m.Organization)
.HasForeignKey(m => m.OrgId)
diff --git a/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs
new file mode 100644
index 000000000..af4e8120d
--- /dev/null
+++ b/backend/LexData/Migrations/20241023132940_Make org names case insensitive.Designer.cs
@@ -0,0 +1,1403 @@
+//
+using System;
+using System.Collections.Generic;
+using LexData;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace LexData.Migrations
+{
+ [DbContext(typeof(LexBoxDbContext))]
+ [Migration("20241023132940_Make org names case insensitive")]
+ partial class Makeorgnamescaseinsensitive
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Npgsql:CollationDefinition:case_insensitive", "und-u-ks-level2,und-u-ks-level2,icu,False")
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("BlobData")
+ .HasColumnType("bytea")
+ .HasColumnName("blob_data");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_blob_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("calendar_name");
+
+ b.Property("Calendar")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("calendar");
+
+ b.HasKey("SchedulerName", "CalendarName");
+
+ b.ToTable("qrtz_calendars", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("cron_expression");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("time_zone_id");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_cron_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("EntryId")
+ .HasColumnType("text")
+ .HasColumnName("entry_id");
+
+ b.Property("FiredTime")
+ .HasColumnType("bigint")
+ .HasColumnName("fired_time");
+
+ b.Property("InstanceName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("instance_name");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("is_nonconcurrent");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("requests_recovery");
+
+ b.Property("ScheduledTime")
+ .HasColumnType("bigint")
+ .HasColumnName("sched_time");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("state");
+
+ b.Property("TriggerGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("TriggerName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.HasKey("SchedulerName", "EntryId");
+
+ b.HasIndex("InstanceName")
+ .HasDatabaseName("idx_qrtz_ft_trig_inst_name");
+
+ b.HasIndex("JobGroup")
+ .HasDatabaseName("idx_qrtz_ft_job_group");
+
+ b.HasIndex("JobName")
+ .HasDatabaseName("idx_qrtz_ft_job_name");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("idx_qrtz_ft_job_req_recovery");
+
+ b.HasIndex("TriggerGroup")
+ .HasDatabaseName("idx_qrtz_ft_trig_group");
+
+ b.HasIndex("TriggerName")
+ .HasDatabaseName("idx_qrtz_ft_trig_name");
+
+ b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
+ .HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
+
+ b.ToTable("qrtz_fired_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsDurable")
+ .HasColumnType("bool")
+ .HasColumnName("is_durable");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("is_nonconcurrent");
+
+ b.Property("IsUpdateData")
+ .HasColumnType("bool")
+ .HasColumnName("is_update_data");
+
+ b.Property("JobClassName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_class_name");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("job_data");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("requests_recovery");
+
+ b.HasKey("SchedulerName", "JobName", "JobGroup");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("idx_qrtz_j_req_recovery");
+
+ b.ToTable("qrtz_job_details", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("LockName")
+ .HasColumnType("text")
+ .HasColumnName("lock_name");
+
+ b.HasKey("SchedulerName", "LockName");
+
+ b.ToTable("qrtz_locks", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.HasKey("SchedulerName", "TriggerGroup");
+
+ b.ToTable("qrtz_paused_trigger_grps", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("InstanceName")
+ .HasColumnType("text")
+ .HasColumnName("instance_name");
+
+ b.Property("CheckInInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("checkin_interval");
+
+ b.Property("LastCheckInTime")
+ .HasColumnType("bigint")
+ .HasColumnName("last_checkin_time");
+
+ b.HasKey("SchedulerName", "InstanceName");
+
+ b.ToTable("qrtz_scheduler_state", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("BooleanProperty1")
+ .HasColumnType("bool")
+ .HasColumnName("bool_prop_1");
+
+ b.Property("BooleanProperty2")
+ .HasColumnType("bool")
+ .HasColumnName("bool_prop_2");
+
+ b.Property("DecimalProperty1")
+ .HasColumnType("numeric")
+ .HasColumnName("dec_prop_1");
+
+ b.Property("DecimalProperty2")
+ .HasColumnType("numeric")
+ .HasColumnName("dec_prop_2");
+
+ b.Property("IntegerProperty1")
+ .HasColumnType("integer")
+ .HasColumnName("int_prop_1");
+
+ b.Property("IntegerProperty2")
+ .HasColumnType("integer")
+ .HasColumnName("int_prop_2");
+
+ b.Property("LongProperty1")
+ .HasColumnType("bigint")
+ .HasColumnName("long_prop_1");
+
+ b.Property("LongProperty2")
+ .HasColumnType("bigint")
+ .HasColumnName("long_prop_2");
+
+ b.Property("StringProperty1")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_1");
+
+ b.Property("StringProperty2")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_2");
+
+ b.Property("StringProperty3")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_3");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("time_zone_id");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_simprop_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("RepeatCount")
+ .HasColumnType("bigint")
+ .HasColumnName("repeat_count");
+
+ b.Property("RepeatInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("repeat_interval");
+
+ b.Property("TimesTriggered")
+ .HasColumnType("bigint")
+ .HasColumnName("times_triggered");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_simple_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("calendar_name");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("EndTime")
+ .HasColumnType("bigint")
+ .HasColumnName("end_time");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("job_data");
+
+ b.Property("JobGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("JobName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("MisfireInstruction")
+ .HasColumnType("smallint")
+ .HasColumnName("misfire_instr");
+
+ b.Property("NextFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("next_fire_time");
+
+ b.Property("PreviousFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("prev_fire_time");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("StartTime")
+ .HasColumnType("bigint")
+ .HasColumnName("start_time");
+
+ b.Property("TriggerState")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_state");
+
+ b.Property("TriggerType")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_type");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.HasIndex("NextFireTime")
+ .HasDatabaseName("idx_qrtz_t_next_fire_time");
+
+ b.HasIndex("TriggerState")
+ .HasDatabaseName("idx_qrtz_t_state");
+
+ b.HasIndex("NextFireTime", "TriggerState")
+ .HasDatabaseName("idx_qrtz_t_nft_st");
+
+ b.HasIndex("SchedulerName", "JobName", "JobGroup");
+
+ b.ToTable("qrtz_triggers", "quartz");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.DraftProject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsConfidential")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("OrgId")
+ .HasColumnType("uuid");
+
+ b.Property("ProjectManagerId")
+ .HasColumnType("uuid");
+
+ b.Property("RetentionPolicy")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("ProjectManagerId");
+
+ b.ToTable("DraftProjects");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.FlexProjectMetadata", b =>
+ {
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("FlexModelVersion")
+ .HasColumnType("integer");
+
+ b.Property("LangProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("LexEntryCount")
+ .HasColumnType("integer");
+
+ b.HasKey("ProjectId");
+
+ b.ToTable("FlexProjectMetadata");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.OrgMember", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("OrgId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OrgId");
+
+ b.HasIndex("UserId", "OrgId")
+ .IsUnique();
+
+ b.ToTable("OrgMembers", (string)null);
+ });
+
+ modelBuilder.Entity("LexCore.Entities.OrgProjects", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("OrgId")
+ .HasColumnType("uuid");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("OrgId", "ProjectId")
+ .IsUnique();
+
+ b.ToTable("OrgProjects");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.Organization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .UseCollation("case_insensitive");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Orgs", (string)null);
+ });
+
+ modelBuilder.Entity("LexCore.Entities.Project", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("DeletedDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("IsConfidential")
+ .HasColumnType("boolean");
+
+ b.Property("LastCommit")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MigratedDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid");
+
+ b.Property("ProjectOrigin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.Property("ResetStatus")
+ .HasColumnType("integer");
+
+ b.Property("RetentionPolicy")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Projects");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.ProjectUsers", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("ProjectId")
+ .HasColumnType("uuid");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ProjectId");
+
+ b.HasIndex("UserId", "ProjectId")
+ .IsUnique();
+
+ b.ToTable("ProjectUsers");
+ });
+
+ modelBuilder.Entity("LexCore.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CanCreateProjects")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedById")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Email")
+ .HasColumnType("text")
+ .UseCollation("case_insensitive");
+
+ b.Property("EmailVerified")
+ .HasColumnType("boolean");
+
+ b.Property("GoogleId")
+ .HasColumnType("text");
+
+ b.Property("IsAdmin")
+ .HasColumnType("boolean");
+
+ b.Property("LastActive")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LocalizationCode")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("en");
+
+ b.Property("Locked")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PasswordStrength")
+ .HasColumnType("integer");
+
+ b.Property("Salt")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedDate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("now()");
+
+ b.Property("Username")
+ .HasColumnType("text")
+ .UseCollation("case_insensitive");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text");
+
+ b.Property("ApplicationType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ClientId")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ClientSecret")
+ .HasColumnType("text");
+
+ b.Property("ClientType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ConsentType")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("DisplayNames")
+ .HasColumnType("text");
+
+ b.Property("JsonWebKeySet")
+ .HasColumnType("text");
+
+ b.Property("Permissions")
+ .HasColumnType("text");
+
+ b.Property("PostLogoutRedirectUris")
+ .HasColumnType("text");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RedirectUris")
+ .HasColumnType("text");
+
+ b.Property("Requirements")
+ .HasColumnType("text");
+
+ b.Property("Settings")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId")
+ .IsUnique();
+
+ b.ToTable("OpenIddictApplications", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text");
+
+ b.Property("ApplicationId")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CreationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("Scopes")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Subject")
+ .HasMaxLength(400)
+ .HasColumnType("character varying(400)");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+ b.ToTable("OpenIddictAuthorizations", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Descriptions")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("DisplayNames")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("Resources")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("OpenIddictScopes", (string)null);
+ });
+
+ modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text");
+
+ b.Property("ApplicationId")
+ .HasColumnType("text");
+
+ b.Property("AuthorizationId")
+ .HasColumnType("text");
+
+ b.Property("ConcurrencyToken")
+ .IsConcurrencyToken()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("CreationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpirationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Payload")
+ .HasColumnType("text");
+
+ b.Property("Properties")
+ .HasColumnType("text");
+
+ b.Property("RedemptionDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReferenceId")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Status")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("Subject")
+ .HasMaxLength(400)
+ .HasColumnType("character varying(400)");
+
+ b.Property("Type")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuthorizationId");
+
+ b.HasIndex("ReferenceId")
+ .IsUnique();
+
+ b.HasIndex("ApplicationId", "Status", "Subject", "Type");
+
+ b.ToTable("OpenIddictTokens", (string)null);
+ });
+
+ modelBuilder.Entity("SIL.Harmony.Core.ServerCommit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property