Merge branch 'main' into admin-frontend
# Conflicts: # .gitignore
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
name: Deploy to IIS
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
LOCAL_PUBLISH: ${{ github.workspace }}/publish
|
||||||
|
MOUNT_POINT: /mnt/iis-deploy
|
||||||
|
SHARE: //10.0.0.148/wwwroot
|
||||||
|
TARGET_SUBDIR: approval_route
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
|
- name: Publish (local)
|
||||||
|
run: dotnet publish backend/src/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj -c Release -o "$LOCAL_PUBLISH"
|
||||||
|
|
||||||
|
- name: Install cifs-utils
|
||||||
|
run: |
|
||||||
|
if command -v mount.cifs >/dev/null 2>&1 && command -v rsync >/dev/null 2>&1; then
|
||||||
|
echo "cifs-utils and rsync already installed, skipping apt-get"
|
||||||
|
else
|
||||||
|
sudo apt-get update && sudo apt-get install -y cifs-utils rsync
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Mount IIS share
|
||||||
|
env:
|
||||||
|
DEPLOY_CREDS_B64: ${{ secrets.DEPLOY_CREDS_B64 }}
|
||||||
|
run: |
|
||||||
|
echo "$DEPLOY_CREDS_B64" | base64 -d > /tmp/creds
|
||||||
|
|
||||||
|
chmod 600 /tmp/creds
|
||||||
|
|
||||||
|
echo "Credentials file created"
|
||||||
|
|
||||||
|
sudo mkdir -p "$MOUNT_POINT"
|
||||||
|
|
||||||
|
sudo mount -t cifs "$SHARE" "$MOUNT_POINT" \
|
||||||
|
-o credentials=/tmp/creds,vers=3.0,sec=ntlmssp,uid=$(id -u),gid=$(id -g),file_mode=0777,dir_mode=0777
|
||||||
|
|
||||||
|
- name: Take site offline
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p "$MOUNT_POINT/$TARGET_SUBDIR"
|
||||||
|
echo "<h1>Deploying, please wait...</h1>" > "$MOUNT_POINT/$TARGET_SUBDIR/app_offline.htm"
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
- name: Sync files to IIS
|
||||||
|
run: |
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude 'app_offline.htm' \
|
||||||
|
--exclude 'logs/' \
|
||||||
|
--exclude 'inventory/' \
|
||||||
|
"$LOCAL_PUBLISH/" "$MOUNT_POINT/$TARGET_SUBDIR/"
|
||||||
|
|
||||||
|
- name: Bring site online
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
rm -f "$MOUNT_POINT/$TARGET_SUBDIR/app_offline.htm" || true
|
||||||
|
|
||||||
|
- name: Unmount share
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
sudo umount "$MOUNT_POINT" || true
|
||||||
|
|
||||||
|
- name: Final check
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "=== FINAL ==="
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
|
- name: Install PostgreSQL
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y postgresql
|
||||||
|
sudo pg_ctlcluster 16 main start
|
||||||
|
sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;"
|
||||||
|
sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;"
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
||||||
|
run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
|
||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
lib/
|
lib/
|
||||||
dist/
|
dist/
|
||||||
temp/
|
temp/
|
||||||
*.sppkg
|
*.sppkg
|
||||||
.heft/
|
.heft/
|
||||||
sharepoint/solution/
|
sharepoint/solution/
|
||||||
|
.vs/
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="UserContentModel">
|
<component name="UserContentModel">
|
||||||
<attachedFolders />
|
<attachedFolders>
|
||||||
|
<Path>../.gitea/workflows</Path>
|
||||||
|
</attachedFolders>
|
||||||
<explicitIncludes />
|
<explicitIncludes />
|
||||||
<explicitExcludes />
|
<explicitExcludes />
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "Program.cs",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ViewState": "AgIAABgAAAAAAAAAAAAAAB4AAAAmAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
|
||||||
|
"WhenOpened": "2026-07-28T06:33:51.834Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 1,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABoAAAAAAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T06:02:34.015Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|c:\\users\\trishina.mariya\\documents\\projects\\tnsu-approval-route\\backend\\src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{4697B342-BE47-435B-B854-75F9277AD769}|src\\Tnsu.ApprovalRoute.Api\\Tnsu.ApprovalRoute.Api.csproj|solutionrelative:src\\tnsu.approvalroute.api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "Program.cs",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\Program.cs",
|
||||||
|
"ViewState": "AgIAABUAAAAAAAAAAAAAADgAAAABAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
|
||||||
|
"WhenOpened": "2026-07-28T06:33:51.834Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 1,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABoAAAAAAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T06:02:34.015Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,12 +5,24 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
|
||||||
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||||
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="MediatR" Version="14.2.0" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
|
||||||
|
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.IntegrationTests", "tests\Tnsu.ApprovalRoute.IntegrationTests\Tnsu.ApprovalRoute.IntegrationTests.csproj", "{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -71,6 +75,18 @@ Global
|
|||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
|
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -80,5 +96,6 @@ Global
|
|||||||
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ExpandedNodes": [
|
||||||
|
"",
|
||||||
|
"\\Tnsu.ApprovalRoute.Api",
|
||||||
|
"\\Tnsu.ApprovalRoute.Application"
|
||||||
|
],
|
||||||
|
"SelectedNode": "\\Tnsu.ApprovalRoute.Application",
|
||||||
|
"PreviewInSolutionExplorer": false
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABUAAAAXAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T05:52:49.686Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"Version": 1,
|
||||||
|
"WorkspaceRootPath": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\",
|
||||||
|
"Documents": [
|
||||||
|
{
|
||||||
|
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}",
|
||||||
|
"RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:Tnsu.ApprovalRoute.Api\\appsettings.json||{90A6B3A7-C1A3-4009-A288-E2FF89E96FA0}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DocumentGroupContainers": [
|
||||||
|
{
|
||||||
|
"Orientation": 0,
|
||||||
|
"VerticalTabListWidth": 256,
|
||||||
|
"DocumentGroups": [
|
||||||
|
{
|
||||||
|
"DockedWidth": 200,
|
||||||
|
"SelectedChildIndex": 0,
|
||||||
|
"Children": [
|
||||||
|
{
|
||||||
|
"$type": "Document",
|
||||||
|
"DocumentIndex": 0,
|
||||||
|
"Title": "appsettings.json",
|
||||||
|
"DocumentMoniker": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeDocumentMoniker": "Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ToolTip": "C:\\Users\\trishina.mariya\\Documents\\Projects\\tnsu-approval-route\\backend\\src\\Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"RelativeToolTip": "Tnsu.ApprovalRoute.Api\\appsettings.json",
|
||||||
|
"ViewState": "AgIAAAAAAAAAAAAAAAAAABUAAAAXAAAAAAAAAA==",
|
||||||
|
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
|
||||||
|
"WhenOpened": "2026-07-28T05:52:49.686Z",
|
||||||
|
"EditorCaption": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSchemes
|
||||||
|
{
|
||||||
|
public const string Dev = "Dev";
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public static class AuthSetup
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var tenantId = configuration["Entra:TenantId"];
|
||||||
|
var audience = configuration["Entra:Audience"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
|
||||||
|
{
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidIssuers =
|
||||||
|
[
|
||||||
|
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||||
|
$"https://sts.windows.net/{tenantId}/"
|
||||||
|
],
|
||||||
|
ValidAudiences = [audience, $"api://{audience}"]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddAuthentication(AuthSchemes.Dev)
|
||||||
|
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser
|
||||||
|
{
|
||||||
|
public string? Email =>
|
||||||
|
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
|
||||||
|
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username");
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
|
||||||
|
public sealed class DevAuthHandler(
|
||||||
|
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
IConfiguration configuration)
|
||||||
|
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
|
||||||
|
?? configuration["Dev:DefaultUserEmail"]
|
||||||
|
?? "dev@tnsu.kz";
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
|
||||||
|
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
|
||||||
|
|
||||||
|
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
public sealed record ApiResponse<T>(T Data);
|
||||||
|
|
||||||
|
public sealed record ApiError(IReadOnlyList<string> Messages);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
public static class ResultExtensions
|
||||||
|
{
|
||||||
|
public static IActionResult ToActionResult<T>(this Result<T> result)
|
||||||
|
{
|
||||||
|
if (result.IsFailed)
|
||||||
|
return result.Errors[0].ToActionResult();
|
||||||
|
|
||||||
|
return new OkObjectResult(new ApiResponse<T>(result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IActionResult ToActionResult(this Result result)
|
||||||
|
{
|
||||||
|
if (result.IsFailed)
|
||||||
|
return result.Errors[0].ToActionResult();
|
||||||
|
|
||||||
|
return new NoContentResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IActionResult ToActionResult(this IError error) => error switch
|
||||||
|
{
|
||||||
|
NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
|
||||||
|
ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
|
||||||
|
ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
|
||||||
|
_ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/request-types")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-routes/{routeId:int}/steps")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List(int routeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(routeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int routeId, int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(routeId, id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(routeId, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(routeId, id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int routeId, int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(routeId, id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/template-routes")]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("resolve")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
|
||||||
|
(await service.ResolveAsync(request, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
|
||||||
|
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
|
||||||
|
(await service.GetAsync(id, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.CreateAsync(body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.UpdateAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
|
||||||
|
public async Task<IActionResult> Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) =>
|
||||||
|
(await service.PatchAsync(id, body, ct)).ToActionResult();
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
|
||||||
|
(await service.DeleteAsync(id, ct)).ToActionResult();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Common;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Api.Filters;
|
||||||
|
|
||||||
|
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
foreach (var argument in context.ActionArguments.Values)
|
||||||
|
{
|
||||||
|
if (argument is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType());
|
||||||
|
if (serviceProvider.GetService(validatorType) is not IValidator validator)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var validationContext = new ValidationContext<object>(argument);
|
||||||
|
var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted);
|
||||||
|
if (result.IsValid)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
context.Result = new BadRequestObjectResult(
|
||||||
|
new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,85 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Extensions.Hosting.AsyncInitialization;
|
using Extensions.Hosting.AsyncInitialization;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.OpenApi;
|
||||||
|
using Scalar.AspNetCore;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Auth;
|
||||||
|
using Tnsu.ApprovalRoute.Api.Filters;
|
||||||
using Tnsu.ApprovalRoute.Application;
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure;
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
|
const string CorsPolicyName = "Frontend";
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
var corsAllowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(CorsPolicyName, policy => policy
|
||||||
|
.WithOrigins(corsAllowedOrigins)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod());
|
||||||
|
});
|
||||||
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddApprovalRouteApplication();
|
builder.Services.AddApprovalRouteApplication();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddApprovalRouteAuth(builder.Configuration);
|
||||||
|
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
|
||||||
|
builder.Services.AddOpenApi(options =>
|
||||||
|
{
|
||||||
|
options.AddDocumentTransformer((document, _, _) =>
|
||||||
|
{
|
||||||
|
document.Components ??= new OpenApiComponents();
|
||||||
|
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
|
||||||
|
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
Scheme = "bearer",
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
Description = "Введите JWT токен"
|
||||||
|
};
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
options.AddOperationTransformer((operation, context, _) =>
|
||||||
|
{
|
||||||
|
operation.Security =
|
||||||
|
[
|
||||||
|
new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
[new OpenApiSecuritySchemeReference("Bearer", context.Document)] = []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
});
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
|
||||||
|
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
app.MapOpenApi();
|
||||||
app.MapOpenApi();
|
|
||||||
|
app.MapScalarApiReference(options =>
|
||||||
|
{
|
||||||
|
options
|
||||||
|
.WithTitle("Tnsu Approval Route")
|
||||||
|
.AddPreferredSecuritySchemes("Bearer")
|
||||||
|
.AddHttpAuthentication("Bearer", _ => { });
|
||||||
|
});
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
app.UseCors(CorsPolicyName);
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
await app.InitAndRunAsync();
|
await app.InitAndRunAsync();
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:5080",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
<PackageReference Include="Extensions.Hosting.AsyncInitialization" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="MediatR" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,6 +6,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
|
"Postgres": "Host=db-master.tnsu.kz;Database=tnsu_approval_route;Username=dbuser;Password=p}{6{Pa]{JQwP1H"
|
||||||
|
},
|
||||||
|
"Entra": {
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
|
||||||
|
},
|
||||||
|
"Dev": {
|
||||||
|
"DefaultUserEmail": "dev@tnsu.kz"
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"https://tnsukz.sharepoint.com",
|
||||||
|
"https://localhost:4321"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TnsuPortal": {
|
||||||
|
"BaseUrl": "https://api.tnsu.kz",
|
||||||
|
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
|
||||||
|
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"Scope": "",
|
||||||
|
"DirectorTansuEmail": "a.samayeva@tnsu.kz"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,9 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using MediatR;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Application;
|
namespace Tnsu.ApprovalRoute.Application;
|
||||||
|
|
||||||
@@ -8,7 +11,12 @@ public static class ApplicationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
|
services.AddScoped<RequestTypeService>();
|
||||||
|
services.AddScoped<TemplateRouteService>();
|
||||||
|
services.AddScoped<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
|
||||||
|
public sealed class NotFoundError(string entity, object id)
|
||||||
|
: Error($"{entity} с id {id} не найден.");
|
||||||
|
|
||||||
|
public sealed class ValidationError(string message) : Error(message);
|
||||||
|
|
||||||
|
public sealed class ConflictError(string message) : Error(message);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
public interface ICurrentUser
|
||||||
|
{
|
||||||
|
string? Email { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed record RequestTypeDto(int Id, string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record CreateRequestTypeRequest(string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
|
||||||
|
|
||||||
|
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class RequestTypeService(ApprovalRouteDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.RequestTypes.AsNoTracking()
|
||||||
|
.OrderBy(x => x.Name)
|
||||||
|
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> GetAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl };
|
||||||
|
db.RequestTypes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> UpdateAsync(int id, UpdateRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
entity.Name = request.Name;
|
||||||
|
entity.PageUrl = request.PageUrl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<RequestTypeDto>> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
entity.Name = request.Name ?? entity.Name;
|
||||||
|
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Тип заявки", id));
|
||||||
|
|
||||||
|
db.RequestTypes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
|
||||||
|
public sealed class CreateRequestTypeRequestValidator : AbstractValidator<CreateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public CreateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<UpdateRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public UpdateRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRequestTypeRequest>
|
||||||
|
{
|
||||||
|
public PatchRequestTypeRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.Name is not null, () =>
|
||||||
|
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
|
||||||
|
When(x => x.PageUrl is not null, () =>
|
||||||
|
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed record TemplateRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
int RouteId,
|
||||||
|
bool AllowReassignToOtherApprovers,
|
||||||
|
string? Warning = null);
|
||||||
|
|
||||||
|
public sealed record CreateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateRouteStepRequest(
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus Status,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteStepRequest(
|
||||||
|
int? OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
ProjectRole? ProjectRole,
|
||||||
|
HeadType? HeadType,
|
||||||
|
RouteStepStatus? Status,
|
||||||
|
bool? AllowReassignToOtherApprovers);
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.Where(x => x.RouteId == routeId)
|
||||||
|
.OrderBy(x => x.OrderNo)
|
||||||
|
.Select(x => new TemplateRouteStepDto(
|
||||||
|
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
var entity = new TemplateRouteStep
|
||||||
|
{
|
||||||
|
RouteId = route.Id,
|
||||||
|
OrderNo = r.OrderNo,
|
||||||
|
ExecutorEmail = r.ExecutorEmail,
|
||||||
|
Position = r.Position,
|
||||||
|
ProjectRole = r.ProjectRole,
|
||||||
|
HeadType = r.HeadType,
|
||||||
|
Status = r.Status,
|
||||||
|
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers
|
||||||
|
};
|
||||||
|
db.TemplateRouteSteps.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
|
||||||
|
int routeId, int id, UpdateTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo;
|
||||||
|
entity.ExecutorEmail = r.ExecutorEmail;
|
||||||
|
entity.Position = r.Position;
|
||||||
|
entity.ProjectRole = r.ProjectRole;
|
||||||
|
entity.HeadType = r.HeadType;
|
||||||
|
entity.Status = r.Status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
|
||||||
|
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
|
||||||
|
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
|
||||||
|
var position = r.Position ?? entity.Position;
|
||||||
|
var projectRole = r.ProjectRole ?? entity.ProjectRole;
|
||||||
|
var headType = r.HeadType ?? entity.HeadType;
|
||||||
|
var status = r.Status ?? entity.Status;
|
||||||
|
|
||||||
|
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
|
||||||
|
|
||||||
|
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
|
||||||
|
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(
|
||||||
|
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
|
||||||
|
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
|
||||||
|
|
||||||
|
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
|
||||||
|
entity.ExecutorEmail = executorEmail;
|
||||||
|
entity.Position = position;
|
||||||
|
entity.ProjectRole = projectRole;
|
||||||
|
entity.HeadType = headType;
|
||||||
|
entity.Status = status;
|
||||||
|
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Шаг маршрута", id));
|
||||||
|
|
||||||
|
db.TemplateRouteSteps.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
|
||||||
|
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRouteSteps.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RouteId == routeId &&
|
||||||
|
x.ExecutorEmail == executorEmail &&
|
||||||
|
x.Position == position &&
|
||||||
|
x.ProjectRole == projectRole &&
|
||||||
|
x.HeadType == headType &&
|
||||||
|
x.Status == status,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
|
||||||
|
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? warning = null;
|
||||||
|
if (route.ByProject)
|
||||||
|
{
|
||||||
|
var hasProjectRoleStep = await db.TemplateRouteSteps
|
||||||
|
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
|
||||||
|
if (!hasProjectRoleStep)
|
||||||
|
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ToDto(entity) with { Warning = warning };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
|
||||||
|
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers);
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
|
||||||
|
public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<CreateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public CreateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteStepRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
|
||||||
|
.WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class TemplateRouteStepFields
|
||||||
|
{
|
||||||
|
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
|
||||||
|
(string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) +
|
||||||
|
(string.IsNullOrWhiteSpace(position) ? 0 : 1) +
|
||||||
|
(projectRole is null or ProjectRole.None ? 0 : 1) +
|
||||||
|
(headType is null or HeadType.None ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed record ResolvedRouteStepDto(
|
||||||
|
int Id,
|
||||||
|
int OrderNo,
|
||||||
|
string? ExecutorEmail,
|
||||||
|
string? Position,
|
||||||
|
bool AllowReassignToOtherApprovers);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteResultDto(
|
||||||
|
int RouteId,
|
||||||
|
RouteType RouteType,
|
||||||
|
bool RequiresControl,
|
||||||
|
IReadOnlyList<ResolvedRouteStepDto> Steps);
|
||||||
|
|
||||||
|
public sealed record ResolveTemplateRouteRequest(
|
||||||
|
int RequestTypeId,
|
||||||
|
string? UserEmail,
|
||||||
|
Guid? ProjectId,
|
||||||
|
RouteStepStatus Status);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed record TemplateRouteDto(
|
||||||
|
int Id,
|
||||||
|
RouteType RouteType,
|
||||||
|
int RequestTypeId,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record CreateTemplateRouteRequest(
|
||||||
|
RouteType RouteType,
|
||||||
|
int RequestTypeId,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record UpdateTemplateRouteRequest(
|
||||||
|
RouteType RouteType,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid CompanyId,
|
||||||
|
bool ByProject,
|
||||||
|
bool RequiresControl);
|
||||||
|
|
||||||
|
public sealed record PatchTemplateRouteRequest(
|
||||||
|
RouteType? RouteType,
|
||||||
|
Guid? DepartmentId,
|
||||||
|
Guid? CompanyId,
|
||||||
|
bool? ByProject,
|
||||||
|
bool? RequiresControl);
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Errors;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteService(
|
||||||
|
ApprovalRouteDbContext db,
|
||||||
|
ITnsuPortalClient portalClient,
|
||||||
|
ICurrentUser currentUser,
|
||||||
|
IOptions<TnsuPortalOptions> portalOptions)
|
||||||
|
{
|
||||||
|
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
|
||||||
|
if (requestTypeId is { } id)
|
||||||
|
query = query.Where(x => x.RequestTypeId == id);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.Select(x => new TemplateRouteDto(
|
||||||
|
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
return entity is null
|
||||||
|
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
|
||||||
|
: Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
|
||||||
|
if (!requestTypeExists)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
var entity = new TemplateRoute
|
||||||
|
{
|
||||||
|
RouteType = r.RouteType,
|
||||||
|
RequestTypeId = r.RequestTypeId,
|
||||||
|
DepartmentId = r.DepartmentId,
|
||||||
|
CompanyId = r.CompanyId,
|
||||||
|
ByProject = r.ByProject,
|
||||||
|
RequiresControl = r.RequiresControl
|
||||||
|
};
|
||||||
|
db.TemplateRoutes.Add(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.RouteType = r.RouteType;
|
||||||
|
entity.DepartmentId = r.DepartmentId;
|
||||||
|
entity.CompanyId = r.CompanyId;
|
||||||
|
entity.ByProject = r.ByProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
var departmentId = r.DepartmentId ?? entity.DepartmentId;
|
||||||
|
var companyId = r.CompanyId ?? entity.CompanyId;
|
||||||
|
var byProject = r.ByProject ?? entity.ByProject;
|
||||||
|
|
||||||
|
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
|
||||||
|
if (duplicate)
|
||||||
|
return Result.Fail<TemplateRouteDto>(new ConflictError(
|
||||||
|
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
|
||||||
|
|
||||||
|
entity.RouteType = r.RouteType ?? entity.RouteType;
|
||||||
|
entity.DepartmentId = departmentId;
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.ByProject = byProject;
|
||||||
|
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok(ToDto(entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (entity is null)
|
||||||
|
return Result.Fail(new NotFoundError("Маршрут", id));
|
||||||
|
|
||||||
|
db.TemplateRoutes.Remove(entity);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
|
||||||
|
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
|
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
|
||||||
|
|
||||||
|
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
|
||||||
|
if (!requestTypeExists)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
|
||||||
|
|
||||||
|
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
|
||||||
|
if (employee is null)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(
|
||||||
|
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
|
||||||
|
|
||||||
|
var byProject = request.ProjectId is not null;
|
||||||
|
|
||||||
|
var route = await db.TemplateRoutes.AsNoTracking()
|
||||||
|
.Include(x => x.Steps)
|
||||||
|
.Where(x =>
|
||||||
|
x.RequestTypeId == request.RequestTypeId &&
|
||||||
|
x.CompanyId == employee.CompanyId &&
|
||||||
|
x.ByProject == byProject &&
|
||||||
|
x.DepartmentId == employee.DepartmentId)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
|
?? await db.TemplateRoutes.AsNoTracking()
|
||||||
|
.Include(x => x.Steps)
|
||||||
|
.Where(x =>
|
||||||
|
x.RequestTypeId == request.RequestTypeId &&
|
||||||
|
x.CompanyId == employee.CompanyId &&
|
||||||
|
x.ByProject == byProject &&
|
||||||
|
x.DepartmentId == null)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (route is null)
|
||||||
|
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
|
||||||
|
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
|
||||||
|
request.RequestTypeId));
|
||||||
|
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
|
||||||
|
if (request.ProjectId is { } projectId)
|
||||||
|
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
|
||||||
|
|
||||||
|
var matchingSteps = route.Steps
|
||||||
|
.Where(s => s.Status == request.Status)
|
||||||
|
.OrderBy(s => s.OrderNo)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var steps = new List<ResolvedRouteStepDto>();
|
||||||
|
foreach (var step in matchingSteps)
|
||||||
|
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
|
||||||
|
|
||||||
|
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||||
|
|
||||||
|
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> HasDuplicateAsync(
|
||||||
|
int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
|
||||||
|
db.TemplateRoutes.AnyAsync(x =>
|
||||||
|
x.Id != excludeId &&
|
||||||
|
x.RequestTypeId == requestTypeId &&
|
||||||
|
x.DepartmentId == departmentId &&
|
||||||
|
x.CompanyId == companyId &&
|
||||||
|
x.ByProject == byProject,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||||
|
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||||
|
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string? resolvedEmail;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
|
||||||
|
resolvedEmail = step.ExecutorEmail;
|
||||||
|
else if (!string.IsNullOrWhiteSpace(step.Position))
|
||||||
|
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
|
||||||
|
else if (step.ProjectRole is not (null or ProjectRole.None))
|
||||||
|
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
|
||||||
|
else if (step.HeadType is not (null or HeadType.None))
|
||||||
|
resolvedEmail = step.HeadType switch
|
||||||
|
{
|
||||||
|
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
|
||||||
|
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
|
||||||
|
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
else
|
||||||
|
resolvedEmail = null;
|
||||||
|
|
||||||
|
string? resolvedPosition = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(resolvedEmail))
|
||||||
|
{
|
||||||
|
var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
|
||||||
|
resolvedPosition = resolvedEmployee?.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||||
|
{
|
||||||
|
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var result = new List<ResolvedRouteStepDto>();
|
||||||
|
|
||||||
|
foreach (var step in steps)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Add(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
|
||||||
|
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
|
||||||
|
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public CreateTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<UpdateTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public UpdateTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public PatchTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
When(x => x.CompanyId is not null, () =>
|
||||||
|
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
|
||||||
|
{
|
||||||
|
public ResolveTemplateRouteRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MediatR" />
|
<PackageReference Include="FluentResults" />
|
||||||
|
<PackageReference Include="FluentValidation" />
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
public class RequestType
|
public class RequestType
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string PageUrl { get; set; } = string.Empty;
|
public string PageUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
public ICollection<Route> Routes { get; set; } = new List<Route>();
|
public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
|
|
||||||
public class Route
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
|
||||||
public Guid RequestTypeId { get; set; }
|
|
||||||
|
|
||||||
// Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД.
|
|
||||||
public Guid DepartmentId { get; set; }
|
|
||||||
public Guid CompanyId { get; set; }
|
|
||||||
public bool ByProject { get; set; }
|
|
||||||
|
|
||||||
public bool RequiresControl { get; set; }
|
|
||||||
|
|
||||||
public RequestType? RequestType { get; set; }
|
|
||||||
public ICollection<RouteStep> Steps { get; set; } = new List<RouteStep>();
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
using Tnsu.ApprovalRoute.Domain.Enums;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
|
|
||||||
public class RouteStep
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
public int OrderNo { get; set; }
|
|
||||||
|
|
||||||
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
|
|
||||||
public string ExecutorEmail { get; set; } = string.Empty;
|
|
||||||
public string Position { get; set; } = string.Empty;
|
|
||||||
public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
|
|
||||||
|
|
||||||
public RouteStepStatus Status { get; set; } = RouteStepStatus.Draft;
|
|
||||||
public Guid RouteId { get; set; }
|
|
||||||
public bool AllowReassignToOtherApprovers { get; set; }
|
|
||||||
|
|
||||||
public Route? Route { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class TemplateRoute
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public RouteType RouteType { get; set; } = RouteType.Sequential;
|
||||||
|
public int RequestTypeId { get; set; }
|
||||||
|
|
||||||
|
public Guid? DepartmentId { get; set; }
|
||||||
|
public Guid CompanyId { get; set; }
|
||||||
|
public bool ByProject { get; set; }
|
||||||
|
|
||||||
|
public bool RequiresControl { get; set; }
|
||||||
|
|
||||||
|
public RequestType? RequestType { get; set; }
|
||||||
|
public ICollection<TemplateRouteStep> Steps { get; set; } = new List<TemplateRouteStep>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Domain.Entities;
|
||||||
|
|
||||||
|
public class TemplateRouteStep
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int OrderNo { get; set; }
|
||||||
|
|
||||||
|
public string? ExecutorEmail { get; set; }
|
||||||
|
public string? Position { get; set; }
|
||||||
|
public ProjectRole? ProjectRole { get; set; }
|
||||||
|
public HeadType? HeadType { get; set; }
|
||||||
|
|
||||||
|
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
|
||||||
|
public int RouteId { get; set; }
|
||||||
|
public bool AllowReassignToOtherApprovers { get; set; }
|
||||||
|
|
||||||
|
public TemplateRoute? Route { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
public enum HeadType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Chief,
|
||||||
|
DirectionHead,
|
||||||
|
DirectorTansu
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
public enum ProjectRole
|
public enum ProjectRole
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Manager,
|
ProjectManager,
|
||||||
Engineer,
|
Planner,
|
||||||
Observer
|
ManagerHSE,
|
||||||
|
Security
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
|
|||||||
public enum RouteStepStatus
|
public enum RouteStepStatus
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Draft,
|
Approval,
|
||||||
OnApproval,
|
Execution
|
||||||
Approved,
|
|
||||||
InExecution,
|
|
||||||
Completed,
|
|
||||||
Returned,
|
|
||||||
Closed
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.Infrastructure;
|
namespace Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
|
||||||
@@ -11,11 +13,21 @@ public static class InfrastructureServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddApprovalRouteInfrastructure(
|
public static IServiceCollection AddApprovalRouteInfrastructure(
|
||||||
this IServiceCollection services, IConfiguration configuration)
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
services.AddDbContext<ApprovalRouteDbContext>(opt =>
|
services.AddDbContext<ApprovalRouteDbContext>(opt =>
|
||||||
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention());
|
||||||
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
services.AddAsyncInitializer<DatabaseMigrationInitializer>();
|
||||||
|
|
||||||
|
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
|
||||||
|
services.AddHttpClient<TnsuPortalTokenProvider>();
|
||||||
|
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
|
||||||
|
client.BaseAddress = new Uri(options.BaseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
public DbSet<RequestType> RequestTypes => Set<RequestType>();
|
||||||
public DbSet<Route> Routes => Set<Route>();
|
public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
|
||||||
public DbSet<RouteStep> RouteSteps => Set<RouteStep>();
|
public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
|
|||||||
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Route>(e =>
|
modelBuilder.Entity<TemplateRoute>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
|
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
|
||||||
|
e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RouteStep>(e =>
|
modelBuilder.Entity<TemplateRouteStep>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired();
|
e.Property(x => x.ExecutorEmail).HasMaxLength(256);
|
||||||
e.Property(x => x.Position).HasMaxLength(128).IsRequired();
|
e.Property(x => x.Position).HasMaxLength(128);
|
||||||
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
|
||||||
|
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
|
||||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
|
||||||
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
|
||||||
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
e.HasIndex(x => new { x.RouteId, x.OrderNo });
|
||||||
|
|||||||
+39
-30
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
|||||||
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApprovalRouteDbContext))]
|
[DbContext(typeof(ApprovalRouteDbContext))]
|
||||||
[Migration("20260722130455_InitialCreate")]
|
[Migration("20260727103117_InitialCreate")]
|
||||||
partial class InitialCreate
|
partial class InitialCreate
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -50,13 +52,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("ByProject")
|
b.Property<bool>("ByProject")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("by_project");
|
.HasColumnName("by_project");
|
||||||
@@ -65,12 +69,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("company_id");
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid?>("DepartmentId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("department_id");
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
b.Property<Guid>("RequestTypeId")
|
b.Property<int>("RequestTypeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("request_type_id");
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
b.Property<bool>("RequiresControl")
|
b.Property<bool>("RequiresControl")
|
||||||
@@ -84,49 +88,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("route_type");
|
.HasColumnName("route_type");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_routes");
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId")
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
.HasDatabaseName("ix_routes_request_type_id");
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
b.ToTable("routes", (string)null);
|
b.ToTable("template_routes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
b.Property<int>("OrderNo")
|
b.Property<int>("OrderNo")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
b.Property<Guid>("RouteId")
|
b.Property<int>("RouteId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("route_id");
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
@@ -136,34 +145,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("status");
|
.HasColumnName("status");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_route_steps");
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
b.HasIndex("RouteId", "OrderNo")
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
.HasDatabaseName("ix_route_steps_route_id_order_no");
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
b.ToTable("route_steps", (string)null);
|
b.ToTable("template_route_steps", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Routes")
|
.WithMany("Routes")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("RouteId")
|
.HasForeignKey("RouteId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
@@ -173,7 +182,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Steps");
|
b.Navigation("Steps");
|
||||||
});
|
});
|
||||||
+29
-23
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -15,7 +16,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
name: "request_types",
|
name: "request_types",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
|
||||||
},
|
},
|
||||||
@@ -25,22 +27,23 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "routes",
|
name: "template_routes",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
request_type_id = table.Column<Guid>(type: "uuid", nullable: false),
|
request_type_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
department_id = table.Column<Guid>(type: "uuid", nullable: false),
|
department_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
company_id = table.Column<Guid>(type: "uuid", nullable: false),
|
company_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
by_project = table.Column<bool>(type: "boolean", nullable: false),
|
by_project = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
requires_control = table.Column<bool>(type: "boolean", nullable: false)
|
requires_control = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_routes", x => x.id);
|
table.PrimaryKey("pk_template_routes", x => x.id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_routes_request_types_request_type_id",
|
name: "fk_template_routes_request_types_request_type_id",
|
||||||
column: x => x.request_type_id,
|
column: x => x.request_type_id,
|
||||||
principalTable: "request_types",
|
principalTable: "request_types",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
@@ -48,48 +51,51 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "route_steps",
|
name: "template_route_steps",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
order_no = table.Column<int>(type: "integer", nullable: false),
|
order_no = table.Column<int>(type: "integer", nullable: false),
|
||||||
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
route_id = table.Column<Guid>(type: "uuid", nullable: false),
|
route_id = table.Column<int>(type: "integer", nullable: false),
|
||||||
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
|
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("pk_route_steps", x => x.id);
|
table.PrimaryKey("pk_template_route_steps", x => x.id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "fk_route_steps_routes_route_id",
|
name: "fk_template_route_steps_template_routes_route_id",
|
||||||
column: x => x.route_id,
|
column: x => x.route_id,
|
||||||
principalTable: "routes",
|
principalTable: "template_routes",
|
||||||
principalColumn: "id",
|
principalColumn: "id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_route_steps_route_id_order_no",
|
name: "ix_template_route_steps_route_id_order_no",
|
||||||
table: "route_steps",
|
table: "template_route_steps",
|
||||||
columns: new[] { "route_id", "order_no" });
|
columns: new[] { "route_id", "order_no" });
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "ix_routes_request_type_id",
|
name: "ix_template_routes_request_type_id_department_id_company_id_by",
|
||||||
table: "routes",
|
table: "template_routes",
|
||||||
column: "request_type_id");
|
columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
|
||||||
|
unique: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "route_steps");
|
name: "template_route_steps");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "routes");
|
name: "template_routes");
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "request_types");
|
name: "request_types");
|
||||||
+38
-29
@@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
@@ -47,13 +49,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("request_types", (string)null);
|
b.ToTable("request_types", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("ByProject")
|
b.Property<bool>("ByProject")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("by_project");
|
.HasColumnName("by_project");
|
||||||
@@ -62,12 +66,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("company_id");
|
.HasColumnName("company_id");
|
||||||
|
|
||||||
b.Property<Guid>("DepartmentId")
|
b.Property<Guid?>("DepartmentId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("department_id");
|
.HasColumnName("department_id");
|
||||||
|
|
||||||
b.Property<Guid>("RequestTypeId")
|
b.Property<int>("RequestTypeId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("request_type_id");
|
.HasColumnName("request_type_id");
|
||||||
|
|
||||||
b.Property<bool>("RequiresControl")
|
b.Property<bool>("RequiresControl")
|
||||||
@@ -81,49 +85,54 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("route_type");
|
.HasColumnName("route_type");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_routes");
|
.HasName("pk_template_routes");
|
||||||
|
|
||||||
b.HasIndex("RequestTypeId")
|
b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
|
||||||
.HasDatabaseName("ix_routes_request_type_id");
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
|
||||||
|
|
||||||
b.ToTable("routes", (string)null);
|
b.ToTable("template_routes", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<bool>("AllowReassignToOtherApprovers")
|
b.Property<bool>("AllowReassignToOtherApprovers")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("allow_reassign_to_other_approvers");
|
.HasColumnName("allow_reassign_to_other_approvers");
|
||||||
|
|
||||||
b.Property<string>("ExecutorEmail")
|
b.Property<string>("ExecutorEmail")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)")
|
.HasColumnType("character varying(256)")
|
||||||
.HasColumnName("executor_email");
|
.HasColumnName("executor_email");
|
||||||
|
|
||||||
|
b.Property<string>("HeadType")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("head_type");
|
||||||
|
|
||||||
b.Property<int>("OrderNo")
|
b.Property<int>("OrderNo")
|
||||||
.HasColumnType("integer")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("order_no");
|
.HasColumnName("order_no");
|
||||||
|
|
||||||
b.Property<string>("Position")
|
b.Property<string>("Position")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(128)
|
.HasMaxLength(128)
|
||||||
.HasColumnType("character varying(128)")
|
.HasColumnType("character varying(128)")
|
||||||
.HasColumnName("position");
|
.HasColumnName("position");
|
||||||
|
|
||||||
b.Property<string>("ProjectRole")
|
b.Property<string>("ProjectRole")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(32)
|
.HasMaxLength(32)
|
||||||
.HasColumnType("character varying(32)")
|
.HasColumnType("character varying(32)")
|
||||||
.HasColumnName("project_role");
|
.HasColumnName("project_role");
|
||||||
|
|
||||||
b.Property<Guid>("RouteId")
|
b.Property<int>("RouteId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("integer")
|
||||||
.HasColumnName("route_id");
|
.HasColumnName("route_id");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
@@ -133,34 +142,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("status");
|
.HasColumnName("status");
|
||||||
|
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_route_steps");
|
.HasName("pk_template_route_steps");
|
||||||
|
|
||||||
b.HasIndex("RouteId", "OrderNo")
|
b.HasIndex("RouteId", "OrderNo")
|
||||||
.HasDatabaseName("ix_route_steps_route_id_order_no");
|
.HasDatabaseName("ix_template_route_steps_route_id_order_no");
|
||||||
|
|
||||||
b.ToTable("route_steps", (string)null);
|
b.ToTable("template_route_steps", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
|
||||||
.WithMany("Routes")
|
.WithMany("Routes")
|
||||||
.HasForeignKey("RequestTypeId")
|
.HasForeignKey("RequestTypeId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_routes_request_types_request_type_id");
|
.HasConstraintName("fk_template_routes_request_types_request_type_id");
|
||||||
|
|
||||||
b.Navigation("RequestType");
|
b.Navigation("RequestType");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route")
|
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
|
||||||
.WithMany("Steps")
|
.WithMany("Steps")
|
||||||
.HasForeignKey("RouteId")
|
.HasForeignKey("RouteId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_route_steps_routes_route_id");
|
.HasConstraintName("fk_template_route_steps_template_routes_route_id");
|
||||||
|
|
||||||
b.Navigation("Route");
|
b.Navigation("Route");
|
||||||
});
|
});
|
||||||
@@ -170,7 +179,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Routes");
|
b.Navigation("Routes");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b =>
|
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Steps");
|
b.Navigation("Steps");
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,5 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -12,6 +11,9 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||||
|
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class HttpTnsuPortalClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
TnsuPortalTokenProvider tokenProvider,
|
||||||
|
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
|
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
|
$"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
|
||||||
|
if (employee is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var employee = await GetAsync<EmployeeResponse>(
|
||||||
|
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
|
||||||
|
|
||||||
|
return employee?.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var manager = await GetAsync<ManagerResponse>(
|
||||||
|
$"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken);
|
||||||
|
|
||||||
|
return manager?.ManagerEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
|
||||||
|
if (project is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var executors = new List<ProjectExecutorInfo>();
|
||||||
|
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
|
||||||
|
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
|
||||||
|
AddIfPresent(executors, ProjectRole.ManagerHSE, project.ManagerHSE);
|
||||||
|
AddIfPresent(executors, ProjectRole.Security, project.Security);
|
||||||
|
|
||||||
|
return executors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
|
||||||
|
await AuthorizeAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
|
executors.Add(new ProjectExecutorInfo(role, email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(incoming))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", incoming);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record EmployeeResponse(
|
||||||
|
[property: JsonPropertyName("email")] string? Email,
|
||||||
|
[property: JsonPropertyName("company")] string? Company,
|
||||||
|
[property: JsonPropertyName("companyId")] Guid? CompanyId,
|
||||||
|
[property: JsonPropertyName("departmentId")] Guid? DepartmentId,
|
||||||
|
[property: JsonPropertyName("position")] string? Position);
|
||||||
|
|
||||||
|
private sealed record ManagerResponse(
|
||||||
|
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
|
||||||
|
|
||||||
|
private sealed record ProjectResponse(
|
||||||
|
[property: JsonPropertyName("projectManager")] string? ProjectManager,
|
||||||
|
[property: JsonPropertyName("planner")] string? Planner,
|
||||||
|
[property: JsonPropertyName("managerHSE")] string? ManagerHSE,
|
||||||
|
[property: JsonPropertyName("security")] string? Security);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public interface ITnsuPortalClient
|
||||||
|
{
|
||||||
|
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
|
||||||
|
|
||||||
|
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class TnsuPortalOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "https://api.tnsu.kz";
|
||||||
|
public string TenantId { get; set; } = string.Empty;
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
public string Scope { get; set; } = string.Empty;
|
||||||
|
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
|
||||||
|
{
|
||||||
|
private string? _cachedToken;
|
||||||
|
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt)
|
||||||
|
return _cachedToken;
|
||||||
|
|
||||||
|
var opts = options.Value;
|
||||||
|
var response = await httpClient.PostAsync(
|
||||||
|
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "client_credentials",
|
||||||
|
["client_id"] = opts.ClientId,
|
||||||
|
["client_secret"] = opts.ClientSecret,
|
||||||
|
["scope"] = opts.Scope
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("Не удалось получить токен для api.tnsu.kz.");
|
||||||
|
|
||||||
|
_cachedToken = payload.AccessToken;
|
||||||
|
_expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn - 60);
|
||||||
|
|
||||||
|
return _cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record TokenResponse(
|
||||||
|
[property: JsonPropertyName("access_token")] string AccessToken,
|
||||||
|
[property: JsonPropertyName("expires_in")] int ExpiresIn);
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private readonly RequestTypeService _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<RequestTypeService>();
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Name.ShouldBe("Vacation");
|
||||||
|
created.Value.PageUrl.ShouldBe("/vacation");
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.Id.ShouldBe(created.Value.Id);
|
||||||
|
fetched.Value.Name.ShouldBe("Vacation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Получение несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task GetAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.GetAsync(999999, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление типа заявки изменяет имя и url страницы")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.Name.ShouldBe("New name");
|
||||||
|
updated.Value.PageUrl.ShouldBe("/new");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task UpdateAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.UpdateAsync(
|
||||||
|
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.Name.ShouldBe("Patched name");
|
||||||
|
patched.Value.PageUrl.ShouldBe("/original");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_UnknownId_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.PatchAsync(
|
||||||
|
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
|
||||||
|
public async Task ListAsync_IncludesCreatedEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var list = await _service.ListAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
list.IsSuccess.ShouldBeTrue();
|
||||||
|
list.Value.ShouldContain(x => x.Id == created.Value.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+400
@@ -0,0 +1,400 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Moq;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||||
|
private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly ServiceProvider _services;
|
||||||
|
private readonly TemplateRouteService _service;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
_connectionString = fixture.ConnectionString;
|
||||||
|
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
||||||
|
|
||||||
|
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
_service = _services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateRequestTypeAsync(string name) =>
|
||||||
|
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Vacation");
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.RequestTypeId.ShouldBe(requestTypeId);
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.CompanyId.ShouldBe(CompanyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
|
||||||
|
public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")]
|
||||||
|
public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Business trip");
|
||||||
|
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
|
||||||
|
var first = await _service.CreateAsync(request, CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var duplicate = await _service.CreateAsync(request, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
updated.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Patch route request");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
|
||||||
|
patched.Value.RequiresControl.ShouldBeTrue();
|
||||||
|
patched.Value.CompanyId.ShouldBe(CompanyId);
|
||||||
|
patched.Value.DepartmentId.ShouldBe(DepartmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesEntity()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Remote work");
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")]
|
||||||
|
public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")]
|
||||||
|
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("dup@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.Count.ShouldBe(2);
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
|
||||||
|
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
|
||||||
|
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
|
||||||
|
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Project request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с должностью подставляет email сотрудника, найденного по должности и компании инициатора")]
|
||||||
|
public async Task ResolveAsync_ByPosition_UsesEmployeeFoundByPositionAndCompany()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("lead@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz");
|
||||||
|
resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position"));
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("chief@tnsu.kz");
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")]
|
||||||
|
public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var portalClient = new Mock<ITnsuPortalClient>();
|
||||||
|
portalClient
|
||||||
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position"));
|
||||||
|
|
||||||
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||||
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
|
||||||
|
var requestTypeId = (await requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id;
|
||||||
|
var route = await routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
await stepService.CreateAsync(
|
||||||
|
route.Value.Id,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await routeService.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.Steps.ShouldHaveSingleItem();
|
||||||
|
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
|
||||||
|
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
|
||||||
|
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
|
||||||
|
var route = await _service.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsSuccess.ShouldBeTrue();
|
||||||
|
resolved.Value.RouteId.ShouldBe(route.Value.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
|
||||||
|
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var resolved = await _service.ResolveAsync(
|
||||||
|
new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
resolved.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Shouldly;
|
||||||
|
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
|
||||||
|
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
|
||||||
|
using Tnsu.ApprovalRoute.Domain.Enums;
|
||||||
|
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||||
|
|
||||||
|
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||||
|
{
|
||||||
|
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||||||
|
|
||||||
|
private readonly TemplateRouteStepService _service;
|
||||||
|
private readonly TemplateRouteService _routeService;
|
||||||
|
private readonly RequestTypeService _requestTypeService;
|
||||||
|
|
||||||
|
public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture)
|
||||||
|
{
|
||||||
|
var services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||||
|
_service = services.GetRequiredService<TemplateRouteStepService>();
|
||||||
|
_routeService = services.GetRequiredService<TemplateRouteService>();
|
||||||
|
_requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject)
|
||||||
|
{
|
||||||
|
var requestTypeId = (await _requestTypeService.CreateAsync(
|
||||||
|
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
|
||||||
|
|
||||||
|
var route = await _routeService.CreateAsync(
|
||||||
|
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
return route.Value.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")]
|
||||||
|
public async Task CreateAsync_UnknownRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
999999,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")]
|
||||||
|
public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Non project route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var result = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")]
|
||||||
|
public async Task CreateAsync_DuplicateStep_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
||||||
|
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false);
|
||||||
|
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
||||||
|
first.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var duplicate = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
duplicate.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
||||||
|
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Head type only route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.HeadType.ShouldBe(HeadType.Chief);
|
||||||
|
created.Value.ExecutorEmail.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
|
||||||
|
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Simple route", byProject: false);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsSuccess.ShouldBeTrue();
|
||||||
|
fetched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("By-project route without role", byProject: true);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Warning.ShouldNotBeNull();
|
||||||
|
created.Value.Warning.ShouldContain("ролью в проекте");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
|
||||||
|
public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("By-project route with role", byProject: true);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
created.IsSuccess.ShouldBeTrue();
|
||||||
|
created.Value.Warning.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]
|
||||||
|
public async Task UpdateAsync_ChangesFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var updated = await _service.UpdateAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
updated.IsSuccess.ShouldBeTrue();
|
||||||
|
updated.Value.ExecutorEmail.ShouldBe("b@b.kz");
|
||||||
|
updated.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч шага маршрута меняет только переданные поля, остальные остаются прежними")]
|
||||||
|
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
patched.Value.OrderNo.ShouldBe(2);
|
||||||
|
patched.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Патч, после которого заполнено больше одного из полей исполнителя, возвращает неуспешный результат")]
|
||||||
|
public async Task PatchAsync_ResultsInMoreThanOneFilledField_ReturnsFailedResult()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
||||||
|
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var patched = await _service.PatchAsync(
|
||||||
|
routeId,
|
||||||
|
created.Value.Id,
|
||||||
|
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
patched.IsSuccess.ShouldBeTrue();
|
||||||
|
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
|
||||||
|
public async Task DeleteAsync_RemovesStep()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||||||
|
var created = await _service.CreateAsync(
|
||||||
|
routeId,
|
||||||
|
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Action
|
||||||
|
var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
deleted.IsSuccess.ShouldBeTrue();
|
||||||
|
|
||||||
|
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||||||
|
fetched.IsFailed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Npgsql;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
public sealed class PostgresDatabaseFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
|
||||||
|
private readonly NpgsqlConnectionStringBuilder _adminConnectionBuilder = new(TestSettings.BaseConnectionString);
|
||||||
|
|
||||||
|
public string ConnectionString { get; private set; } = string.Empty;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await using (var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString))
|
||||||
|
{
|
||||||
|
await adminConnection.OpenAsync();
|
||||||
|
await using var createCommand = new NpgsqlCommand($"CREATE DATABASE \"{_databaseName}\"", adminConnection);
|
||||||
|
await createCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionBuilder.ConnectionString)
|
||||||
|
{
|
||||||
|
Database = _databaseName
|
||||||
|
}.ConnectionString;
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApprovalRouteDbContext>()
|
||||||
|
.UseNpgsql(ConnectionString)
|
||||||
|
.UseSnakeCaseNamingConvention()
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var db = new ApprovalRouteDbContext(options);
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await using var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString);
|
||||||
|
await adminConnection.OpenAsync();
|
||||||
|
|
||||||
|
await using (var terminateCommand = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||||
|
WHERE datname = @databaseName AND pid <> pg_backend_pid();
|
||||||
|
""",
|
||||||
|
adminConnection))
|
||||||
|
{
|
||||||
|
terminateCommand.Parameters.AddWithValue("databaseName", _databaseName);
|
||||||
|
await terminateCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var dropCommand = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{_databaseName}\"", adminConnection);
|
||||||
|
await dropCommand.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal sealed class TestCurrentUser(string? email = null) : ICurrentUser
|
||||||
|
{
|
||||||
|
public string? Email { get; } = email;
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tnsu.ApprovalRoute.Application;
|
||||||
|
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure;
|
||||||
|
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||||
|
|
||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal static class TestServiceProviderFactory
|
||||||
|
{
|
||||||
|
public static ServiceProvider Build(
|
||||||
|
string connectionString,
|
||||||
|
string? currentUserEmail = "initiator@tnsu.kz",
|
||||||
|
ITnsuPortalClient? portalClient = null)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ConnectionStrings:Postgres"] = connectionString
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddApprovalRouteInfrastructure(configuration);
|
||||||
|
services.AddApprovalRouteApplication();
|
||||||
|
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));
|
||||||
|
|
||||||
|
if (portalClient is not null)
|
||||||
|
services.AddSingleton(portalClient);
|
||||||
|
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
internal static class TestSettings
|
||||||
|
{
|
||||||
|
public static string BaseConnectionString =>
|
||||||
|
Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION")
|
||||||
|
?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route";
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="Shouldly" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
Reference in New Issue
Block a user