diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..5da8b86
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -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 "
Deploying, please wait...
" > "$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
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..4fce20c
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index fb6f982..a065ba9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,8 @@
-node_modules/
+node_modules/
lib/
dist/
temp/
*.sppkg
.heft/
sharepoint/solution/
+.vs/
\ No newline at end of file
diff --git a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
index 7b08163..4b0e0c4 100644
--- a/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
+++ b/backend/.idea/.idea.Tnsu.ApprovalRoute/.idea/indexLayout.xml
@@ -1,7 +1,9 @@
-
+
+ ../.gitea/workflows
+
diff --git a/backend/.vs/ProjectEvaluation/tnsu.approvalroute.metadata.v10.bin b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.metadata.v10.bin
new file mode 100644
index 0000000..bd37c9f
Binary files /dev/null and b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.metadata.v10.bin differ
diff --git a/backend/.vs/ProjectEvaluation/tnsu.approvalroute.projects.v10.bin b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.projects.v10.bin
new file mode 100644
index 0000000..67458af
Binary files /dev/null and b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.projects.v10.bin differ
diff --git a/backend/.vs/ProjectEvaluation/tnsu.approvalroute.strings.v10.bin b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.strings.v10.bin
new file mode 100644
index 0000000..6de9369
Binary files /dev/null and b/backend/.vs/ProjectEvaluation/tnsu.approvalroute.strings.v10.bin differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/DesignTimeBuild/.dtbcache.v2 b/backend/.vs/Tnsu.ApprovalRoute/DesignTimeBuild/.dtbcache.v2
new file mode 100644
index 0000000..3e20ee6
Binary files /dev/null and b/backend/.vs/Tnsu.ApprovalRoute/DesignTimeBuild/.dtbcache.v2 differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/1b10d1d6-0e83-4eea-8ec0-92128f18981e.vsidx b/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/1b10d1d6-0e83-4eea-8ec0-92128f18981e.vsidx
new file mode 100644
index 0000000..c403b3f
Binary files /dev/null and b/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/1b10d1d6-0e83-4eea-8ec0-92128f18981e.vsidx differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/df22629b-6284-4caa-bb11-28440ffc85dc.vsidx b/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/df22629b-6284-4caa-bb11-28440ffc85dc.vsidx
new file mode 100644
index 0000000..5c60904
Binary files /dev/null and b/backend/.vs/Tnsu.ApprovalRoute/FileContentIndex/df22629b-6284-4caa-bb11-28440ffc85dc.vsidx differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/config/applicationhost.config b/backend/.vs/Tnsu.ApprovalRoute/config/applicationhost.config
new file mode 100644
index 0000000..223dc4c
--- /dev/null
+++ b/backend/.vs/Tnsu.ApprovalRoute/config/applicationhost.config
@@ -0,0 +1,1011 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/.vs/Tnsu.ApprovalRoute/v18/.futdcache.v2 b/backend/.vs/Tnsu.ApprovalRoute/v18/.futdcache.v2
new file mode 100644
index 0000000..a2736d9
Binary files /dev/null and b/backend/.vs/Tnsu.ApprovalRoute/v18/.futdcache.v2 differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/v18/.suo b/backend/.vs/Tnsu.ApprovalRoute/v18/.suo
new file mode 100644
index 0000000..ae1a75c
Binary files /dev/null and b/backend/.vs/Tnsu.ApprovalRoute/v18/.suo differ
diff --git a/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.backup.json b/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.backup.json
new file mode 100644
index 0000000..1c48b5c
--- /dev/null
+++ b/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.backup.json
@@ -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": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.json b/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.json
new file mode 100644
index 0000000..edbcc27
--- /dev/null
+++ b/backend/.vs/Tnsu.ApprovalRoute/v18/DocumentLayout.json
@@ -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": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index 0217988..64c8bfc 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -5,12 +5,24 @@
+
+
+
+
+
+
+
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/Tnsu.ApprovalRoute.sln b/backend/Tnsu.ApprovalRoute.sln
index e505b17..ee88a58 100644
--- a/backend/Tnsu.ApprovalRoute.sln
+++ b/backend/Tnsu.ApprovalRoute.sln
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,5 +96,6 @@ Global
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {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}
+ {F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/backend/src/.vs/VSWorkspaceState.json b/backend/src/.vs/VSWorkspaceState.json
new file mode 100644
index 0000000..a35ce30
--- /dev/null
+++ b/backend/src/.vs/VSWorkspaceState.json
@@ -0,0 +1,9 @@
+{
+ "ExpandedNodes": [
+ "",
+ "\\Tnsu.ApprovalRoute.Api",
+ "\\Tnsu.ApprovalRoute.Application"
+ ],
+ "SelectedNode": "\\Tnsu.ApprovalRoute.Application",
+ "PreviewInSolutionExplorer": false
+}
\ No newline at end of file
diff --git a/backend/src/.vs/slnx.sqlite b/backend/src/.vs/slnx.sqlite
new file mode 100644
index 0000000..43f81fe
Binary files /dev/null and b/backend/src/.vs/slnx.sqlite differ
diff --git a/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx b/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx
new file mode 100644
index 0000000..78115d3
Binary files /dev/null and b/backend/src/.vs/src.slnx/FileContentIndex/c41e12f7-4a3d-4c40-9653-41a24091003e.vsidx differ
diff --git a/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx b/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx
new file mode 100644
index 0000000..d4cbc0d
Binary files /dev/null and b/backend/src/.vs/src.slnx/FileContentIndex/f9a35f81-13ae-4547-9684-90f51c188103.vsidx differ
diff --git a/backend/src/.vs/src.slnx/config/applicationhost.config b/backend/src/.vs/src.slnx/config/applicationhost.config
new file mode 100644
index 0000000..223dc4c
--- /dev/null
+++ b/backend/src/.vs/src.slnx/config/applicationhost.config
@@ -0,0 +1,1011 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/.vs/src.slnx/v18/.wsuo b/backend/src/.vs/src.slnx/v18/.wsuo
new file mode 100644
index 0000000..0476f63
Binary files /dev/null and b/backend/src/.vs/src.slnx/v18/.wsuo differ
diff --git a/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json b/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json
new file mode 100644
index 0000000..8712180
--- /dev/null
+++ b/backend/src/.vs/src.slnx/v18/DocumentLayout.backup.json
@@ -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": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/backend/src/.vs/src.slnx/v18/DocumentLayout.json b/backend/src/.vs/src.slnx/v18/DocumentLayout.json
new file mode 100644
index 0000000..8712180
--- /dev/null
+++ b/backend/src/.vs/src.slnx/v18/DocumentLayout.json
@@ -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": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
new file mode 100644
index 0000000..331bf3d
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
@@ -0,0 +1,6 @@
+namespace Tnsu.ApprovalRoute.Api.Auth;
+
+public static class AuthSchemes
+{
+ public const string Dev = "Dev";
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
new file mode 100644
index 0000000..e72b17c
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
@@ -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(AuthSchemes.Dev, _ => { });
+ }
+
+ services.AddAuthorization();
+
+ return services;
+ }
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs
new file mode 100644
index 0000000..42e31cd
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/CurrentUserAccessor.cs
@@ -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");
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
new file mode 100644
index 0000000..8086a82
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
@@ -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 options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IConfiguration configuration)
+ : AuthenticationHandler(options, logger, encoder)
+{
+ protected override Task 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));
+ }
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs
new file mode 100644
index 0000000..4a9a560
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ApiResponse.cs
@@ -0,0 +1,5 @@
+namespace Tnsu.ApprovalRoute.Api.Common;
+
+public sealed record ApiResponse(T Data);
+
+public sealed record ApiError(IReadOnlyList Messages);
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs
new file mode 100644
index 0000000..9935ac3
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Common/ResultExtensions.cs
@@ -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(this Result result)
+ {
+ if (result.IsFailed)
+ return result.Errors[0].ToActionResult();
+
+ return new OkObjectResult(new ApiResponse(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 }
+ };
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs
new file mode 100644
index 0000000..65bb312
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs
@@ -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>), StatusCodes.Status200OK)]
+ public async Task List(CancellationToken ct) =>
+ (await service.ListAsync(ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int id, CancellationToken ct) =>
+ (await service.GetAsync(id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ public async Task Create(CreateRequestTypeRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task 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 Delete(int id, CancellationToken ct) =>
+ (await service.DeleteAsync(id, ct)).ToActionResult();
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs
new file mode 100644
index 0000000..0f74e73
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs
@@ -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>), StatusCodes.Status200OK)]
+ public async Task List(int routeId, CancellationToken ct) =>
+ (await service.ListAsync(routeId, ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int routeId, int id, CancellationToken ct) =>
+ (await service.GetAsync(routeId, id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(routeId, body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task 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 Delete(int routeId, int id, CancellationToken ct) =>
+ (await service.DeleteAsync(routeId, id, ct)).ToActionResult();
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs
new file mode 100644
index 0000000..8b38e22
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs
@@ -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), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
+ (await service.ResolveAsync(request, ct)).ToActionResult();
+
+ [HttpGet]
+ [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)]
+ public async Task List([FromQuery] int? requestTypeId, CancellationToken ct) =>
+ (await service.ListAsync(requestTypeId, ct)).ToActionResult();
+
+ [HttpGet("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ public async Task Get(int id, CancellationToken ct) =>
+ (await service.GetAsync(id, ct)).ToActionResult();
+
+ [HttpPost]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
+ (await service.CreateAsync(body, ct)).ToActionResult();
+
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
+ (await service.UpdateAsync(id, body, ct)).ToActionResult();
+
+ [HttpPatch("{id:int}")]
+ [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
+ [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
+ public async Task 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 Delete(int id, CancellationToken ct) =>
+ (await service.DeleteAsync(id, ct)).ToActionResult();
+}
diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs
new file mode 100644
index 0000000..d447132
--- /dev/null
+++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs
@@ -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