diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..f55f22a
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,126 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(dotnet add *)",
+ "Bash(echo \"Moq added \\($?\\)\")",
+ "Bash(echo \"Mvc.Testing added \\($?\\)\")",
+ "Bash(echo \"EF InMemory added \\($?\\)\")",
+ "Bash(dotnet build *)",
+ "Bash(dotnet test *)",
+ "Bash(dotnet ef *)",
+ "Bash(sqllocaldb info *)",
+ "Bash(npm run *)",
+ "Bash(curl -sk -o /dev/null -w \"backend /api/auth/me → HTTP %{http_code}\\\\n\" https://localhost:7443/api/auth/me)",
+ "Bash(curl -sk -o /dev/null -w 'GET / → HTTP %{http_code}\\\\n' https://localhost:4200/)",
+ "Bash(curl -sk -o /dev/null -w 'HTTP %{http_code}\\\\n' https://localhost:4200/api/auth/me)",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/proxy_cookies.txt -X POST https://localhost:4200/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}' -D - -o /dev/null)",
+ "Bash(echo \"exit=$?\")",
+ "Bash(MEM=\"C:/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory\"; ls -la \"$MEM\" 2>/dev/null && echo \"--- MEMORY.md ---\" && cat \"$MEM/MEMORY.md\" 2>/dev/null || echo \"no MEMORY.md yet\")",
+ "Read(//c/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory/**)",
+ "Bash(echo \"=== remaining in memory dir ===\")",
+ "Bash(ls -A C:/Users/beny314/.claude/projects/c--Source-ahk-github-automation/memory)",
+ "Bash(echo \"\\(empty\\)\")",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_admin.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}')",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_admin.txt https://localhost:7443/api/admin/courses)",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}')",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt https://localhost:7443/api/viaubc01/probe/notes)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/c_inst.txt -o /dev/null -w 'cross-course viaubb01 → %{http_code} \\(expect 403\\)\\\\n' https://localhost:7443/api/viaubb01/probe/notes)",
+ "PowerShell(sqlcmd -S \"\\(localdb\\)\\\\MSSQLLocalDB\" -d AhkWeb -Q $q -h -1 -W)",
+ "Bash(dotnet new *)",
+ "Bash(rm -f Ahk.Web.Services/Class1.cs)",
+ "Bash(dotnet sln *)",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/statuses)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/grades)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubb01/grades/csv)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/adm.txt https://localhost:7443/api/viaubc01/grades)",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -X POST https://localhost:7443/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"instructor\",\"password\":\"Instructor123!\"}' -o /dev/null)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -o /dev/null -w ' /api/viaubb01/grades -> %{http_code} \\(expect 403\\)\\\\n' https://localhost:7443/api/viaubb01/grades)",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/ins.txt -o /dev/null -w ' /api/viaubc01/statuses -> %{http_code} \\(expect 200\\)\\\\n' https://localhost:7443/api/viaubc01/statuses)",
+ "Bash(curl -sk -c C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/p.txt -X POST https://localhost:4200/api/auth/login -H 'Content-Type: application/json' -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null -w 'login via proxy -> %{http_code}\\\\n')",
+ "Bash(curl -sk -b C:/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/8d87f62d-6109-437b-91ea-1cb12635e036/scratchpad/p.txt https://localhost:4200/api/viaubb01/statuses -w '\\\\nHTTP %{http_code}\\\\n')",
+ "Bash(grep -rl \"MapUniqueJsonKey\" \"C:/Program Files/dotnet/packs/Microsoft.AspNetCore.App.Ref/\" 2>/dev/null | head -3 *)",
+ "Read(//c/Program Files/dotnet/packs/Microsoft.AspNetCore.App.Ref/**)",
+ "Bash(DLL=\"C:/Program Files/dotnet/shared/Microsoft.AspNetCore.App/10.0.7/Microsoft.AspNetCore.Authentication.OAuth.dll\" *)",
+ "Read(//c/Program Files/dotnet/shared/Microsoft.AspNetCore.App/10.0.7/**)",
+ "Bash(ls ~/.nuget/packages/ 2>/dev/null | grep -i -E \"octokit|github\" ; echo \"---\"; cd ahk-frontend && cat package.json)",
+ "Read(//c/Users/beny314/.nuget/packages/**)",
+ "Read(//c/Users/beny314/.nuget/packages/octokit/**)",
+ "Read(//c/Users/beny314/.nuget/packages/microsoft.extensions.http/**)",
+ "Read(//c/Program Files/Microsoft SQL Server//**)",
+ "Bash(cp Ahk.Web.Services/Ahk.Web.Services.csproj /tmp/svc.bak)",
+ "Bash(ls -la ~/.claude/ 2>/dev/null | head -40; echo \"=== plugins ===\"; ls -la ~/.claude/plugins/ 2>/dev/null | head -40)",
+ "Read(//c/Users/beny314/.claude/**)",
+ "Read(//c/Users/beny314/.claude/plugins/**)",
+ "Bash(cd ~/.claude/plugins && cat known_marketplaces.json && echo \"=== installed ===\" && cat installed_plugins.json && echo \"=== marketplaces/ ===\" && ls marketplaces/ && echo \"=== cache/ ===\" && ls cache/ 2>/dev/null && echo \"=== repos/ ===\" && ls repos/ 2>/dev/null)",
+ "Bash(python -c ' *)",
+ "Bash(dotnet run *)",
+ "Bash(curl -sk https://localhost:7443/swagger/v1/swagger.json -o /dev/null)",
+ "Bash(curl -sk https://localhost:7443/swagger/v1/swagger.json -o /dev/null -w \"%{http_code}\\\\n\")",
+ "Bash(rm -f c.txt)",
+ "Bash(curl -sk -c c.txt -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}')",
+ "Bash(python -m json.tool)",
+ "Bash(curl -sk -b c.txt https://localhost:7443/api/admin/health)",
+ "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; \"freed\")",
+ "Bash(python -)",
+ "Bash(curl -sk https://localhost:4200/ -o /dev/null)",
+ "PowerShell(foreach \\($p in 7443,4200\\) { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } }; \"ports freed\")",
+ "PowerShell(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue)",
+ "Bash(sqlcmd -S \"\\(localdb\\)\\\\MSSQLLocalDB\" -d AhkWeb -Q \"SET NOCOUNT ON; SELECT Id, UserName, NormalizedUserName, LockoutEnd, AccessFailedCount, LockoutEnabled, CASE WHEN PasswordHash IS NULL THEN 'NULL' ELSE 'set' END AS Pwd FROM AspNetUsers;\" -W -s\"|\")",
+ "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
+ "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\"}' -o /dev/null -w \"HTTP %{http_code}\\\\n\")",
+ "Bash(curl -sk -X POST https://localhost:7443/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"nope\"}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
+ "Bash(rm -f v.txt)",
+ "Bash(curl -sk -c v.txt -X POST https://localhost:4200/api/auth/login -H \"Content-Type: application/json\" -d '{\"userName\":\"admin\",\"password\":\"Admin123!\",\"rememberMe\":true}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
+ "PowerShell(foreach \\($p in 7443,4200\\) { Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } }; Start-Sleep -Milliseconds 500; $left = Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue; if \\($null -eq $left\\) { \"both ports free\" } else { $left | Select-Object LocalPort, OwningProcess })",
+ "PowerShell(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Milliseconds 500; if \\($null -eq \\(Get-NetTCPConnection -LocalPort 7443,4200 -State Listen -ErrorAction SilentlyContinue\\)\\) { \"both ports free\" })",
+ "Bash(grep -nE \"^[0-9.]+ [0-9.]+ [0-9.]+ [0-9.]+ \\(k|K\\)$\")",
+ "Bash(sort -u -t: -k2)",
+ "Bash(python3 -)",
+ "Bash(python -c \"import yaml,sys; yaml.safe_load\\(open\\('.github/workflows/ahk-web-deploy.yaml'\\)\\); print\\('YAML OK'\\)\")",
+ "Bash(cd c:/Source/ahk-github-automation/ahk-backend && dotnet publish Ahk.Web.Server/Ahk.Web.Server.csproj -p:PublishProfile=Mezga -o /tmp/ahk-webconfig-test 2>&1 | tail -3 && echo \"=== generated web.config ===\" && cat /tmp/ahk-webconfig-test/web.config 2>/dev/null || echo \"NO web.config generated\")",
+ "Read(//tmp/ahk-webconfig-test/**)",
+ "Bash(rm -rf /tmp/ahk-webconfig-test && cd c:/Source/ahk-github-automation/ahk-backend && dotnet publish Ahk.Web.Server/Ahk.Web.Server.csproj -p:PublishProfile=Mezga -o /tmp/ahk-webconfig-test 2>&1 | tail -2 && echo \"=== published web.config ===\" && cat /tmp/ahk-webconfig-test/web.config)",
+ "Bash(rm -rf /tmp/ahk-webconfig-test && echo cleaned)",
+ "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; \"backend stopped\")",
+ "PowerShell(Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }; \"checked\")",
+ "Bash(python -c \"import yaml; d=yaml.safe_load\\(open\\('.github/workflows/ahk-web-deploy.yaml'\\)\\); print\\('YAML OK'\\); print\\('inputs:', d['on']['workflow_dispatch']['inputs']\\)\")",
+ "Bash(awk '{print length\\($0\\)}')",
+ "Bash(where.exe rsync *)",
+ "Bash(wsl.exe rsync *)",
+ "Bash(wsl.exe -l -v)",
+ "Bash(wsl.exe bash -c ' *)",
+ "Bash(npm start *)",
+ "Bash(node shot.mjs)",
+ "Read(//c/Users/beny314/AppData/Local/Temp/claude/c--Source-ahk-github-automation/f74052ba-9e93-4760-8fe5-7c53f23ddecb/scratchpad/**)",
+ "Bash(sed 's#/admin/help/github?org=ahk-viaubc01#/admin/courses/1#; s/help-page.png/course-editor.png/' shot.mjs)",
+ "Bash(node shot2.mjs)",
+ "PowerShell(foreach \\($port in 7443,4200\\) { try { Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } catch {} }; Write-Output \"stopped\")",
+ "Bash(node -e \"const {readFileSync}=require\\('fs'\\); const b=readFileSync\\('help-page.png'\\); console.log\\('png bytes:', b.length\\);\")",
+ "Bash(node shot3.mjs)",
+ "PowerShell(try { Get-NetTCPConnection -LocalPort 7443 -State Listen -ErrorAction Stop | Select-Object -ExpandProperty OwningProcess -Unique | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue } } catch {}; Write-Output \"backend stopped\")",
+ "Bash(node shot4.mjs)",
+ "Bash(wsl.exe bash *)",
+ "Bash(node shot5.mjs)",
+ "Bash(git add *)",
+ "Bash(git commit *)",
+ "Bash(curl -sk -o /dev/null -w \"\" https://localhost:7443/swagger/v1/swagger.json)",
+ "Bash(break)",
+ "Bash(curl -sk -X POST https://localhost:7443/api/integrations/github -H 'Content-Type: application/json' -d '{}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
+ "Bash(curl -sk -X POST https://localhost:7443/api/integrations/evaluation-result -H 'Content-Type: application/json' -d '{}' -w \"\\\\nHTTP %{http_code}\\\\n\")",
+ "Bash(node -e ' *)",
+ "Bash(python3 -c ' *)",
+ "Bash(MSYS_NO_PATHCONV=1 wsl.exe bash __TRACKED_VAR__/harness.sh __TRACKED_VAR__/apply-step.sh)",
+ "Bash(MSYS_NO_PATHCONV=1 wsl.exe bash __TRACKED_VAR__/harness-edge.sh __TRACKED_VAR__/apply-step.sh)"
+ ],
+ "additionalDirectories": [
+ "\\tmp",
+ "C:\\Users\\beny314\\AppData\\Local\\Temp\\claude\\c--Source-ahk-github-automation\\f74052ba-9e93-4760-8fe5-7c53f23ddecb\\scratchpad"
+ ]
+ },
+ "enabledPlugins": {
+ "frontend-design@claude-plugins-official": true,
+ "claude-md-management@claude-plugins-official": true,
+ "security-guidance@claude-plugins-official": true
+ }
+}
diff --git a/.github/workflows/ahk-web-deploy.yaml b/.github/workflows/ahk-web-deploy.yaml
index 901c1c6..28dfbb9 100644
--- a/.github/workflows/ahk-web-deploy.yaml
+++ b/.github/workflows/ahk-web-deploy.yaml
@@ -1,7 +1,13 @@
name: AHK Web - Deploy to Mezga
# Manual trigger only: this deploys to the on-prem IIS server behind the BME VPN.
-on: [workflow_dispatch]
+on:
+ workflow_dispatch:
+ inputs:
+ force_full:
+ description: "Ignore the stored deployment manifest and copy everything (use after a manual change on the server)"
+ type: boolean
+ default: false
env:
DOTNET_VERSION: "10.0.x"
@@ -12,6 +18,10 @@ env:
FRONTEND_DIR: ahk-frontend
OFFLINE_PAGE: ahk-backend/Ahk.Web.Server/app_offline.htm
MOUNT_POINT: /mnt/deploy
+ # Lives at the CIFS mount's root — a sibling of wwwroot/, the .exe and appsettings.json, never inside
+ # the ASP.NET Core app's own static-file root, so it is never web-reachable and never touched by the
+ # targeted copy/delete steps below.
+ MANIFEST_NAME: .deploy-manifest.sha256
jobs:
deploy:
@@ -63,9 +73,6 @@ jobs:
mkdir -p publish/wwwroot
cp -r ${{ env.FRONTEND_DIR }}/dist/ahk-frontend/browser/. publish/wwwroot/
- - name: Stage offline page alongside the publish output
- run: cp ${{ env.OFFLINE_PAGE }} publish/app_offline.htm
-
- name: Generate idempotent migration script (artifact only — applied manually)
run: |
dotnet tool install --global dotnet-ef
@@ -93,6 +100,11 @@ jobs:
publish/appsettings.json > publish/appsettings.json.tmp
mv publish/appsettings.json.tmp publish/appsettings.json
+ # Last content-mutating step before the diff: hashing anything after this point (e.g. re-injecting
+ # config) would make the manifest describe a tree that no longer matches what gets copied.
+ - name: Compute local deployment manifest
+ run: (cd publish && find . -type f -printf '%P\0' | sort -z | xargs -0 sha256sum) > new.manifest
+
# ---- D. VPN (SSTP) up ----
- name: Install SSTP client
@@ -105,11 +117,6 @@ jobs:
VPN_USER: ${{ vars.VPN_USER }}
VPN_PASSWORD: ${{ secrets.VPN_PASSWORD }}
run: |
- # sstpc is a daemon that never returns. Background it AND redirect its stdout/stderr to a
- # logfile: otherwise the backgrounded process keeps this step's output pipe open and the step
- # hangs forever despite the '&'. No --cert-warn — the endpoint's cert validates against the
- # system CA store. No 'defaultroute' — it is inert here (the runner's eth0 default wins), which
- # is why internal traffic was not tunneled; the explicit host route in the next step handles it.
sudo sstpc --log-stderr \
--user "$VPN_USER" --password "$VPN_PASSWORD" \
"$VPN_ADDRESS" \
@@ -131,16 +138,13 @@ jobs:
env:
VPN_ROUTES: ${{ vars.VPN_ROUTES }}
run: |
- # Split tunnel: only the configured internal hosts (e.g. mezga 152.66.188.8/32) go through
- # ppp0. The runner->GitHub control channel and the public ahk.aut.bme.hu warm-up stay on the
- # runner's default route. Automates the manual `ip route replace` from the first live run.
for cidr in $VPN_ROUTES; do
echo "Routing $cidr via ppp0"
sudo ip route replace "$cidr" dev ppp0
done
ip route show
- # ---- E. Mount share, swap site ----
+ # ---- E. Mount share, diff against last deploy, swap site ----
- name: Mount deployment share
env:
@@ -150,36 +154,203 @@ jobs:
FILE_PASSWORD: ${{ secrets.FILE_PASSWORD }}
run: |
sudo mkdir -p "$MOUNT_POINT"
- sudo mount -t cifs "$DEPLOYMENT_PATH" "$MOUNT_POINT" \
- -o username="$FILE_USERNAME",domain="$FILE_USERDOMAIN",password="$FILE_PASSWORD",vers=3.0,uid=$(id -u),gid=$(id -g)
+ # Pass credentials via a file, not inline -o. mount.cifs splits -o on commas, so a comma or
+ # other special char in the password is parsed as an option separator, silently truncating the
+ # password and failing auth with mount error(13) Permission denied. The file sidesteps that.
+ CREDS=$(mktemp)
+ chmod 600 "$CREDS"
+ {
+ echo "username=$FILE_USERNAME"
+ echo "password=$FILE_PASSWORD"
+ echo "domain=$FILE_USERDOMAIN"
+ } > "$CREDS"
+ if ! sudo mount -t cifs "$DEPLOYMENT_PATH" "$MOUNT_POINT" \
+ -o credentials="$CREDS",vers=3.0,sec=ntlmssp,uid=$(id -u),gid=$(id -g); then
+ echo "::error::CIFS mount failed; recent kernel (dmesg) messages:"
+ sudo dmesg | tail -30 || true
+ rm -f "$CREDS"
+ exit 1
+ fi
+ rm -f "$CREDS"
+
+ # Reads exactly one small file from the share — regardless of link speed this is fast, unlike
+ # reading file content to compare it (the actual reason the old rsync mirror was slow).
+ - name: Fetch previous deployment manifest
+ env:
+ FORCE_FULL: ${{ inputs.force_full }}
+ run: |
+ if [ "$FORCE_FULL" = "true" ]; then
+ echo "force_full requested — treating the stored manifest as empty (full re-copy)."
+ : > old.manifest
+ elif [ -f "$MOUNT_POINT/$MANIFEST_NAME" ]; then
+ cp "$MOUNT_POINT/$MANIFEST_NAME" old.manifest
+ else
+ echo "No manifest found on the share yet — treating this as a first-time full deploy."
+ : > old.manifest
+ fi
+
+ # Pure local text processing — no network cost. sha256sum's format is 64 hex chars + two spaces +
+ # path, so "cut -c 67-" reliably extracts the path column regardless of spaces in a filename.
+ - name: Diff manifests
+ id: diff
+ run: |
+ sort old.manifest -o old.sorted
+ sort new.manifest -o new.sorted
+
+ # Changed or added: whole lines (hash+path) present in the new build but not the old one —
+ # covers both "hash changed at an existing path" and "brand-new path".
+ comm -13 old.sorted new.sorted | cut -c 67- > changed.list
+
+ # Removed: paths that existed before and are entirely absent from the new build — this is what
+ # correctly prunes Angular's abandoned content-hashed chunk files, unlike a size comparison.
+ cut -c 67- old.manifest | sort -u > old.paths
+ cut -c 67- new.manifest | sort -u > new.paths
+ comm -23 old.paths new.paths > removed.list
+
+ changed=$(wc -l < changed.list)
+ removed=$(wc -l < removed.list)
+ echo "Changed/added: $changed, removed: $removed"
+
+ if [ "$changed" -eq 0 ] && [ "$removed" -eq 0 ]; then
+ echo "has_changes=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "has_changes=true" >> "$GITHUB_OUTPUT"
+ fi
- name: Stop site (drop app_offline.htm)
+ if: steps.diff.outputs.has_changes == 'true'
run: |
cp "$OFFLINE_PAGE" "$MOUNT_POINT/app_offline.htm"
- # Give IIS a moment to notice and release file locks before mirroring.
+ # Give IIS a moment to notice and release file locks before copying.
sleep 5
- - name: Mirror new files (keep app_offline.htm)
+ # Copies exactly the changed/added files (preserving their relative subdirectories) and deletes
+ # exactly the removed ones. --ignore-times/--whole-file/--inplace stop rsync from doing any of its
+ # own (CIFS-costly) comparison or delta-transfer logic — we already know these files need moving.
+ - name: Apply changed files
+ if: steps.diff.outputs.has_changes == 'true'
run: |
- ok=0
- for i in $(seq 1 10); do
- if rsync -a --delete --exclude 'app_offline.htm' publish/ "$MOUNT_POINT/"; then
- ok=1; break
+ set -o pipefail
+
+ changed_count=$(wc -l < changed.list)
+ removed_count=$(wc -l < removed.list)
+
+ # Bytes about to cross the tunnel. A self-contained publish is a handful of very large files
+ # plus a long tail, so "which files" explains a slow run far better than "how many".
+ changed_bytes=0
+ if [ -s changed.list ]; then
+ changed_bytes=$(sed 's|^|publish/|' changed.list | tr '\n' '\0' \
+ | xargs -0 -r stat -c %s 2>/dev/null | awk '{s+=$1} END {print s+0}')
+ fi
+
+ echo "Copying $changed_count file(s), $(numfmt --to=iec --suffix=B "$changed_bytes"); deleting $removed_count."
+
+ if [ -s changed.list ]; then
+ echo "::group::Ten largest files in this transfer"
+ sed 's|^|publish/|' changed.list | tr '\n' '\0' \
+ | xargs -0 -r stat -c '%s %n' 2>/dev/null \
+ | sort -rn | head -10 \
+ | awk '{ size=$1; $1=""; sub(/^ /,""); printf "%10s %s\n", size, $0 }' \
+ | numfmt --to=iec --suffix=B --field=1 || true
+ echo "::endgroup::"
+ fi
+
+ # Millisecond resolution: a fast deploy still has to report a throughput figure, otherwise
+ # there is no baseline to compare a slow one against.
+ copy_ms=0
+ attempts=0
+ if [ -s changed.list ]; then
+ ok=0
+ copy_start=$(date +%s%3N)
+ for i in $(seq 1 10); do
+ attempts=$i
+ echo "::group::rsync attempt $i"
+ attempt_start=$(date +%s%3N)
+ # --out-format stamps every file with the wall-clock time it landed, which is what turns
+ # "the deploy was slow" into "it stalled for 90s on this one file". --stats reports the
+ # effective transfer rate over the tunnel.
+ if rsync -R --files-from=changed.list --ignore-times --whole-file --inplace \
+ --no-perms --no-owner --no-group --no-times \
+ --stats --human-readable --out-format='%t %12l %n' \
+ publish/ "$MOUNT_POINT/"; then
+ ok=1
+ echo "Attempt $i succeeded in $(( ($(date +%s%3N) - attempt_start) / 1000 ))s"
+ echo "::endgroup::"
+ break
+ fi
+ echo "::endgroup::"
+ echo "::warning::rsync attempt $i failed after $(( ($(date +%s%3N) - attempt_start) / 1000 ))s (likely a locked file); retrying..."
+ sleep 2
+ done
+ copy_ms=$(( $(date +%s%3N) - copy_start ))
+
+ if [ "$ok" -ne 1 ]; then
+ echo "::error::Copying changed files failed after $attempts attempts and $(( copy_ms / 1000 ))s"
+ exit 1
fi
- echo "rsync attempt $i failed (likely a locked file); retrying..."
- sleep 2
- done
- if [ "$ok" -ne 1 ]; then
- echo "::error::Mirroring the deployment failed after retries"
- exit 1
fi
+ # Deletes are one CIFS round-trip each, so a large prune is latency-bound rather than
+ # bandwidth-bound and is worth timing separately from the copy.
+ delete_ms=0
+ if [ -s removed.list ]; then
+ delete_start=$(date +%s%3N)
+ while IFS= read -r path; do
+ rm -f "$MOUNT_POINT/$path"
+ done < removed.list
+ delete_ms=$(( $(date +%s%3N) - delete_start ))
+ echo "Deleted $removed_count file(s) in $(( delete_ms / 1000 ))s"
+ fi
+
+ # Both rates matter, and which one is poor says where the bottleneck is: low MB/s with
+ # healthy files/s means the link is the limit; low files/s with healthy MB/s means CIFS
+ # round-trip latency is, and the fix is fewer files rather than smaller ones.
+ summary=$(awk -v n="$changed_count" -v b="$changed_bytes" -v cms="$copy_ms" \
+ -v dn="$removed_count" -v dms="$delete_ms" '
+ BEGIN {
+ mb = b / 1048576
+ cs = cms / 1000
+ ds = dms / 1000
+ printf "| Metric | Value |\n|---|---|\n"
+ printf "| Files copied | %d |\n", n
+ printf "| Bytes copied | %.1f MB |\n", mb
+ printf "| Copy time | %.1fs |\n", cs
+ if (cs > 0) {
+ printf "| Throughput | %.2f MB/s |\n", mb / cs
+ printf "| File rate | %.1f files/s |\n", n / cs
+ }
+ printf "| Files deleted | %d |\n", dn
+ printf "| Delete time | %.1fs |\n", ds
+ if (ds > 0) printf "| Delete rate | %.1f files/s |\n", dn / ds
+ printf "| Total | %.1fs |\n", cs + ds
+ }')
+
+ echo "$summary"
+ {
+ echo "### Applying changed files"
+ echo ""
+ echo "$summary"
+ if [ "$attempts" -gt 1 ]; then
+ echo ""
+ echo "⚠️ rsync needed **$attempts attempts** — retries re-send every file, so each one costs a full copy."
+ fi
+ } >> "$GITHUB_STEP_SUMMARY"
+
- name: Wake site (remove app_offline.htm)
+ if: steps.diff.outputs.has_changes == 'true'
run: rm -f "$MOUNT_POINT/app_offline.htm"
- name: Warm up and verify
run: curl --fail --show-error --retry 5 --retry-delay 3 https://ahk.aut.bme.hu/
+ # Last content-mutating step, and only reached after the copy/delete above succeeded. A run that
+ # fails partway leaves the OLD manifest in place, so the next run still sees those files as
+ # "changed" and retries them — a failed run can only ever under-count what's already been done,
+ # never understate future work.
+ - name: Publish new deployment manifest
+ if: steps.diff.outputs.has_changes == 'true'
+ run: cp new.manifest "$MOUNT_POINT/$MANIFEST_NAME"
+
# ---- F. Teardown (always) ----
- name: Unmount share
diff --git a/.github/workflows/publish-result-pr-build.yaml b/.github/workflows/publish-result-pr-build.yaml
index 3756453..58a5d39 100644
--- a/.github/workflows/publish-result-pr-build.yaml
+++ b/.github/workflows/publish-result-pr-build.yaml
@@ -5,20 +5,26 @@ on:
paths:
- "publish-results-pr/**"
+permissions:
+ contents: read
+
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
+ # setup-go v4 and later cache the module and build caches itself, keyed on the checksum file, which
+ # is why the separate actions/cache step this workflow used to carry is gone. go.sum is not at the
+ # repository root in this monorepo, so the path has to be given explicitly.
- name: Go setup
- uses: actions/setup-go@v2
+ uses: actions/setup-go@v5
with:
go-version: "1.17"
- - uses: actions/cache@v3
+ - uses: actions/cache@v2
with:
path: |
~/.cache/go-build
diff --git a/.github/workflows/publish-result-pr-docker-publish.yaml b/.github/workflows/publish-result-pr-docker-publish.yaml
index d531a98..230af3c 100644
--- a/.github/workflows/publish-result-pr-docker-publish.yaml
+++ b/.github/workflows/publish-result-pr-docker-publish.yaml
@@ -1,19 +1,84 @@
name: Publish Result PR - Docker publish
-on: [workflow_dispatch]
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: "Moving tag to publish. Student repositories resolve this at run time, so publishing it changes every course at once."
+ type: string
+ default: "v1"
+
+env:
+ # Published under bmeaut, the organization that owns this source.
+ #
+ # It previously lived in a personal namespace (ghcr.io/akosdudas/...) belonging to someone who has since
+ # left the university, so the account — and with it every course's evaluation pipeline — was outside our
+ # control. Images built here are course-critical; they belong to the organization, not to a person.
+ #
+ # ⚠️ Student repositories generated before this move still reference the old image and keep working until
+ # that account's package disappears. Migrating them means editing the `uses:` line in each repository's
+ # workflow — the same pass that sets AHK_APPURL. See ahk-backend/docs/ci-callback.md.
+ IMAGE: ghcr.io/bmeaut/ahk-publish-results-pr
jobs:
publish:
runs-on: ubuntu-latest
+ # GITHUB_TOKEN is read-only by default in many organizations; without packages:write the push fails
+ # with a permission error that reads like an authentication problem.
+ permissions:
+ contents: read
+ packages: write
+
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
+
+ # The moving tag is what every student repository resolves at run time, so overwriting it changes
+ # behaviour everywhere simultaneously and there is no built-in way back. Park the outgoing image
+ # under a fixed name first, so a bad publish is one `docker buildx imagetools create` from undone.
+ - name: Preserve the outgoing image as :${{ inputs.tag }}-previous
+ run: |
+ if docker pull "$IMAGE:${{ inputs.tag }}"; then
+ docker tag "$IMAGE:${{ inputs.tag }}" "$IMAGE:${{ inputs.tag }}-previous"
+ docker push "$IMAGE:${{ inputs.tag }}-previous"
+ echo "Previous image preserved as $IMAGE:${{ inputs.tag }}-previous"
+ else
+ echo "No existing $IMAGE:${{ inputs.tag }} — nothing to preserve (first publish under this namespace)."
+ fi
- - name: Build and push to GHCR
+ - name: Build
working-directory: publish-results-pr
run: |
- echo "${{ secrets.GITHUB_TOKEN }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
- docker build -t ghcr.io/akosdudas/ahk-publish-results-pr:v1 .
- docker push ghcr.io/akosdudas/ahk-publish-results-pr:v1
- docker logout https://ghcr.io
+ # Also tagged with the commit, which is immutable: it gives every publish a permanent rollback
+ # point, unlike the moving tag that student repositories follow.
+ docker build \
+ -t "$IMAGE:${{ inputs.tag }}" \
+ -t "$IMAGE:sha-${GITHUB_SHA::7}" \
+ .
+
+ - name: Push
+ run: |
+ docker push "$IMAGE:${{ inputs.tag }}"
+ docker push "$IMAGE:sha-${GITHUB_SHA::7}"
+
+ - name: Summary
+ run: |
+ {
+ echo "### Published"
+ echo ""
+ echo "| Tag | Purpose |"
+ echo "|---|---|"
+ echo "| \`$IMAGE:${{ inputs.tag }}\` | Moving tag student repositories follow |"
+ echo "| \`$IMAGE:sha-${GITHUB_SHA::7}\` | Immutable, this commit |"
+ echo "| \`$IMAGE:${{ inputs.tag }}-previous\` | The image this run replaced |"
+ echo ""
+ echo "Roll back with:"
+ echo ""
+ echo '```'
+ echo "docker buildx imagetools create -t $IMAGE:${{ inputs.tag }} $IMAGE:${{ inputs.tag }}-previous"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 88dbff1..bf90ad1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,5 @@
.vs/
+
+## Local deploy script (scripts/deploy-local.sh) — never commit secrets or build output
+deploy.local.json
+publish-local/
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..c15c9bb
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,6 @@
+{
+ "recommendations": [
+ "ms-dotnettools.csharp",
+ "angular.ng-template"
+ ]
+}
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..b956e0d
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,34 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Backend (ASP.NET Core)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-backend",
+ "program": "${workspaceFolder}/ahk-backend/Ahk.Web.Server/bin/Debug/net10.0/Ahk.Web.Server.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/ahk-backend/Ahk.Web.Server",
+ "stopAtEntry": false,
+ "console": "integratedTerminal",
+ "env": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "ASPNETCORE_URLS": "https://localhost:7443"
+ }
+ },
+ {
+ "name": "Frontend (Angular)",
+ "type": "node-terminal",
+ "request": "launch",
+ "command": "npm start",
+ "cwd": "${workspaceFolder}/ahk-frontend"
+ }
+ ],
+ "compounds": [
+ {
+ "name": "Full stack (backend + frontend)",
+ "configurations": ["Backend (ASP.NET Core)", "Frontend (Angular)"],
+ "stopAll": true
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..88ea0d1
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,17 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-backend",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/ahk-backend/Ahk.Web.Server/Ahk.Web.Server.csproj",
+ "/property:GenerateFullPaths=true",
+ "/consoleloggerparameters:NoSummary"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5a94a82
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,183 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Working in this repo
+
+⚠️ **Do not run git write commands.** No `git add`, `git commit`, `git checkout`, `git reset`, `git stash`, `git merge`, `git push` — the maintainer commits by hand, deliberately, and wants to review and stage the working tree themselves. Leave changes uncommitted in the working tree and say what you changed. Read-only inspection (`git status`, `git diff`, `git log`, `git show`) is fine and useful.
+
+## What this is
+
+**Ahk** = automated homework evaluation. A toolset that automates homework submission, evaluation, and grading using GitHub, GitHub Classroom, and GitHub Actions. Concept docs:
+
+The repo is a monorepo of independent applications, each in its own top-level directory with its own solution/module, README, and CI workflows. There is no root-level build — each app builds separately.
+
+It is mid-transition: the **four original apps** (`github-monitor`, `grade-management`, `review-ui`, `publish-results-pr`) are the production system today, and a **new centralized portal** (`ahk-backend` + `ahk-frontend`) is being built to replace the per-course Azure deployments with a single multi-course site at `ahk.aut.bme.hu`. The portal now has auth (local + BME OIDC), the full course-scoped **data model**, the domain **services**, the read endpoints (`grades`, `statuses`, CSV export), the **site administration surface** (course CRUD, per-course GitHub integration, CI callback tokens, staff, users/roles, health checks) with its Angular console, and a one-time Cosmos→MSSQL importer. The write-side entry points are now ported too — the **GitHub webhook receiver**, **`/ahk ok` chatops** and the **HMAC-verified CI callback** — so the portal is feature-complete and a course can run on it with no Azure Functions. The four apps stay deployed for courses that have not migrated; **a GitHub App has one webhook URL, so per course the switch is a flip, not a parallel run** (cutover checklist in `ahk-backend/docs/github-app.md`). When working in this repo, be clear which of the two systems a task targets.
+
+## The four applications and how they connect
+
+```
+Student pushes / opens PR
+ │
+ ▼
+GitHub org (managed by GitHub Classroom)
+ │ webhooks GitHub Actions runs evaluator container
+ ▼ │ produces result.txt + images
+[github-monitor] (.NET Azure Function) ▼
+ - enforces workflow rules on repos [publish-results-pr] (Go container)
+ - /ahk ok chatops approval/grading - formats results as PR comment
+ │ Azure Queue Storage messages - POSTs results to grade-management
+ │ │ HMAC-signed HTTP
+ ▼ ▼
+[grade-management] (.NET Azure Function) ◄───────┘
+ - queue-triggered: grade + status events → CosmosDB
+ - http: evaluation-result webhook, list-grades, list-statuses
+ │ HTTP API (Function master key)
+ ▼
+[review-ui] (Blazor WebAssembly, browser-only)
+ - teacher dashboard of statuses + grades
+```
+
+Key integration contracts (keep these in sync when changing either side):
+- **github-monitor → grade-management**: Azure Queue Storage. Queue names are hard-coded in the `[QueueTrigger(...)]` attributes in `grade-management/.../Functions/**` (e.g. `ahksetgrade`, `ahkconfirmautograde`, `ahkstatustracking*`) and must match what github-monitor's `Services/GradeStore` and `Services/StatusTrackingStore` write. Both sides share duplicated DTO shapes (`Services/.../Dto/*.cs` vs `Functions/.../Dto/*.cs`).
+- **publish-results-pr → grade-management**: HMAC-SHA256 signed HTTP to the `evaluation-result` webhook. The signing scheme (verb\nurl\ndate\npayload, base64 HMAC, `X-Ahk-Token`/`X-Ahk-Sha256`/`Date` headers, 10-min skew) is implemented in Go (`internal/publishtoapi`) and validated in .NET (`grade-management/.../Helpers/HmacSha256Validator.cs`). Changing one requires changing the other. See `grade-management/README.md` for the full spec.
+- **github-monitor** authenticates to GitHub as a GitHub App per-installation (`Services/GitHubClientFactory`), using Octokit.
+
+## The new centralized portal (ahk-backend + ahk-frontend)
+
+Replaces the per-course Azure Function deployments with one site whose domain data is assigned to **Courses** (each course = what used to be a separate deployment, e.g. `viaubc01`). This is **not** multitenant isolation — it's course-scoped data with membership-based authorization.
+
+- **ahk-backend** — ASP.NET Core **.NET 10** (`Ahk.Web.slnx`). Layering: `Ahk.Web.Server` (controllers/middleware/auth, thin) → `Ahk.Web.Services` (domain logic) → `Ahk.Web.Data` (EF Core + **MSSQL**, Identity, migrations, seeder, `Normalize`). `Ahk.Web.Import` is a **throwaway** Cosmos-JSON→MSSQL console importer that references Data directly and **bypasses Services** (bulk movement, not domain ops). Tests in `Ahk.Web.Server.Tests` (xUnit + Moq).
+- **No repository layer** — `DbContext` is already Unit of Work + repository. The legacy Cosmos repositories exist only because Cosmos has no composable LINQ context; that reason is gone.
+- **ahk-frontend** — **Angular 21** SPA (standalone components + signals). API clients/DTOs under `src/app/api/` are **NSwag-generated** from the backend's OpenAPI doc — never hand-edit them.
+- **Octokit is the portal's single GitHub API client**, built by `ICourseGitHubClientFactory` (`Ahk.Web.Services/GitHub/`). One exception: `CourseGitHubAppTokenProvider` stays on the named `"github"` `HttpClient` because it is the auth bootstrap (hand-rolled RS256 App JWT → installation token), not an API call, and moving it would change the permissions shape `GitHubAppInstallationHealthCheck` reads. `IGitHubRepositoryService`'s interface and its four record projections are deliberately Octokit-free — that is what lets `AssignmentInviteTests` mock it strictly.
+
+Design decisions (rationale in the architecture plan and `ahk-backend/README.md`):
+- **Auth**: cookie-based ASP.NET Identity (returns 401/403, never redirects) + an **OIDC** external provider (config `Authentication:Oidc:*`; disabled when empty). Deliberately **not** `MapIdentityApi` (no OIDC support, bearer-token focus, fixed shapes).
+- **OIDC provider is BME's Shibboleth IdP** (`https://idp.bme.hu`, client `AUTAhkClient`). Non-obvious constraints, all encoded in `OidcOptions` defaults — do not "fix" them back:
+ - Request **exactly** the registered scopes (`openid email userinfo`). `profile` is **not** registered; asking for it gets the request rejected.
+ - **PKCE off** — the IdP does not advertise `code_challenge_methods_supported`. We are a confidential client (`client_secret_post`), so PKCE is defence-in-depth only.
+ - **`ResponseMode = query`** — ASP.NET's `form_post` default makes the callback a cross-site POST, which drops the correlation cookie under `SameSite=Lax` ("Correlation failed").
+ - **No `end_session_endpoint`** is advertised, so logout is local-only; `POST /api/auth/logout` returns `{endSessionUrl}` (null today) and the SPA navigates there if set.
+ - `OpenIdConnectOptions` ships **only `DeleteClaim` actions** — claims arriving from the *userinfo* endpoint (BME sends `email`, `name`, `neptun_code`, `eduperson_scoped_affiliation` there) are silently dropped unless explicitly mapped in `Program.cs`.
+ - Persisted onto `ApplicationUser`: `NeptunCode` and `Affiliation` (multi-valued, joined with `;`), re-synced on every login by `Auth/ExternalClaimsMapper.cs`.
+ - **eduID identifies a user by Neptun code, not email/username.** `ApplicationUser.NeptunCode` is filtered-unique (blank stored as null, may repeat; any value unique). First OIDC login matches an existing account by Neptun and links the external login, so an admin-pre-created account is never duplicated (`ExternalAuthController`). Admin create/update enforce the same rule (blank→null, else `Normalize.Neptun`, 400 on clash). eduID **always refreshes email**; the claim sync never writes username.
+ - The client secret is never in config — `dotnet user-secrets` locally, `Authentication__Oidc__ClientSecret` in production.
+- **Dev mock OIDC provider** (`Ahk.Web.Server/MockOidc/`): only the production redirect URI is registered with BME, so localhost cannot use the real IdP. Enabled by `Authentication:Oidc:UseMockProvider` in Development; serves discovery/authorize/token/userinfo/JWKS at `/mock-oidc` and issues **genuinely signed** RS256 id_tokens so the real validation path runs. Switch persona with `GET /mock-oidc/persona?user=instructor|student|noclaims`.
+- **Course scoping**: path segment `/api/{course}/...`; `CourseResolutionMiddleware` resolves the `Course` and an `ICurrentCourseProvider` drives an EF Core global query filter over `ICourseScoped` entities; the `CourseMember` authorization policy gates access. Host/admin routes live under `/api/admin/...`. Machine-to-machine endpoints have **no** `{course}` segment and resolve their course from the payload/token instead: `POST /api/integrations/github` (from `repository.full_name`, via `ICourseResolutionService.ResolveByRepositoryAsync`) and `POST /api/integrations/evaluation-result` (from the `X-Ahk-Token` header). Both are `[AllowAnonymous]` — a signature is the authentication, and `Program.cs` deliberately has no `FallbackPolicy` — and both are `[ApiExplorerSettings(IgnoreApi = true)]` so NSwag emits no client for them.
+- **Site admins can open any course.** `CourseMembershipAuthorizationHandler` grants `CourseMember` to the `Admin` role, so `GET /api/auth/me` lists *every* course for an admin (flagged `viaSiteAdmin` where there is no membership row). The SPA's switcher and `courseGuard` read that one list and need no admin special case — keep it that way rather than branching on `isAdmin()` in new screens.
+- **Admin API credential rule**: stored secrets are never returned (only `has*` flags plus a last-four hint). On update, each credential field means: `null` = leave alone, `""` = clear, anything else = replace. That is what makes saving an untouched form safe.
+- **Course health checks** (`Ahk.Web.Services/Health/`): each is an `ICourseHealthCheck` registered in DI and discovered by `CourseHealthService`, so adding one is a class plus a registration line — controller and UI unchanged. Four today: webhook settings (local), GitHub access token (real call to api.github.com, 10s timeout), GitHub App installation, CI callback token. Checks must not throw; return a `Failed` result instead, or one unreachable course takes the whole dashboard down. A course's status is the worst of its checks.
+- **Dev**: both run HTTPS. The Angular dev proxy (`proxy.conf.js`) forwards `/api/*` to the backend so calls are **same-origin — no CORS**, and `API_BASE_URL` is provided as `''` so generated clients issue relative requests. Press F5 with the **"Full stack (backend + frontend)"** compound in `.vscode/launch.json` to start both.
+- **Domain model** (`Ahk.Web.Data/Entities`): `Course` 1:1 `CourseGitHubConfig` (per-course GitHub App creds + `WorkflowRunThreshold` — was per-deployment `AHK_*` — plus `GitHubAccessToken`, a PAT used for REST calls that need no installation token, today only the health check); `Student`/`Submission` replace the neptun/repo-name strings; `SubmissionEvent` (TPH: Repository/Branch/PullRequest/WorkflowRun) and `GradeRecord`+`GradeExercisePoint` are **append-only** — current state is projected, never updated. `CourseWebhookToken.Token` is globally unique because the CI callback carries no `{course}` segment; its `Secret` is a plaintext column (HMAC needs the raw key) and the admin API **returns it** (list + detail) so the console can re-copy it later — a deliberate exception to the never-return rule, justified because any admin can mint an equivalent token anyway.
+- **Assignments** (`Ahk.Web.Data/Entities/Assignment.cs`): student repos are named `{RepoNamePrefix}-{neptun}`, falling back to the template repo's bare name when `RepoNamePrefix` is blank (keeps pre-prefix assignments working) — logic in `AssignmentInviteService.BuildRepositoryName`. The template repo is validated **advisorily** (`AssignmentService.CheckTemplateAsync` → `POST api/{course}/assignments/check-template`): it reports existence + `is_template` as a warning and **never blocks saving** (an assignment may be drafted before its template exists). A bad template otherwise only fails at student-accept time as a 502.
+- **Webhook receiver** (`Ahk.Web.Services/GitHubWebhooks/`): `GitHubWebhookDispatcher` selects handlers by `IGitHubWebhookHandler.GitHubEventName`; per-delivery state (course id, delivery id, body, `IGitHubClient`, run threshold) travels on `GitHubWebhookContext`, so handlers are stateless scoped DI registrations. **DI registration order is dispatch order** — that is what replaced github-monitor's explicit config builder, and handlers post comments whose order is visible to students. The `.github/ahk-monitor.yml` `enabled: true` gate and its **12-hour** per-repo-id cache are kept verbatim: adding the file to an already-seen repo takes up to half a day to take effect, and an app restart is the only faster flush. This is the most common cause of "webhook delivers 200 but nothing happens" — read the `WebhookResult` body, it says `no ahk-monitor.yml or disabled`.
+- ⚠️ **At most one handler per GitHub event may write a `SubmissionEvent`.** `GitHubDeliveryId` is globally unique (the redelivery guard) but one delivery fans out to several handlers, so a second writer's rows are silently swallowed by the guard, or rejected by the unique index on SQL Server. Handlers that write are marked `IStatusEventWriter` and `WebhookHandlerRegistrationTests` fails the build if two share an event.
+- ⚠️ **Course resolution necessarily precedes signature validation** in the webhook receiver: the secret is per course (`CourseGitHubConfig.GitHubWebhookSecret`) and the only thing identifying the course is `repository.full_name` *inside* the body. Everything before the HMAC check is deliberately inert — one property read from a `JsonDocument` that is then disposed, two indexed reads, no writes/GitHub calls/body logging — and every signature failure returns the same 400. Don't add work to that stretch. Benign cases (repo in no course, integration off) answer **202, not 4xx**, because GitHub colours non-2xx red in the delivery log.
+- ⚠️ **Both signature comparisons must stay constant-time** (`SignatureComparison.FixedTimeEquals`). The Azure Functions used `string.Equals`, which returns at the first differing character and lets a forged signature be refined a character at a time by timing — do not "restore parity" here. Same reason the CI callback logs only a masked last-four of the caller's token, where grade-management logged it whole.
+- ⚠️ **`AHK_APPURL` must byte-match the CI callback's public URL** (`https://ahk.aut.bme.hu/api/integrations/evaluation-result`): the Go client signs the URL, so scheme, host, path and trailing slash all matter (casing does not — both sides lower it). `http://` fails confusingly, via a 307 and then a scheme mismatch. `UseForwardedHeaders` must stay first in the pipeline for `GetDisplayUrl()` to yield the public URL behind IIS. Full contract in `ahk-backend/docs/ci-callback.md`.
+- ⚠️ **`ICourseGitHubAppTokenProvider.GetForCourseAsync(Course)` returns null unless the course was loaded with `Include(c => c.GitHubConfig)`** — it reads `course.GitHubConfig?.GitHubAppId`. `ResolveByRepositoryAsync` does *not* include it, so m2m paths must use the `(int courseId, …)` overload or they silently degrade to "course not connected to GitHub".
+- ⚠️ **The course query filter matches nothing when no course is resolved.** `ICurrentCourseProvider` is set by whichever entry point resolved the course. Anything without HTTP course context — dev seeder, importer, services — must use `IgnoreQueryFilters()` and filter on `courseId` itself, or it silently reads **zero rows**. Service methods therefore always take an explicit `int courseId` and never read the provider.
+
+## Build, test, run
+
+The four original apps target **.NET 6** (Azure Functions v4) except publish-results-pr which is **Go 1.17**. The new portal targets **.NET 10** (ahk-backend) and **Angular 21 / Node** (ahk-frontend).
+
+### github-monitor / grade-management / review-ui (.NET)
+```bash
+# from the app directory (github-monitor, grade-management, or review-ui)
+dotnet build
+dotnet test # run all tests
+dotnet test --filter "FullyQualifiedName~HmacSha256ValidatorTest" # single test class
+dotnet test --filter "Name=SomeTestMethod" # single test method
+```
+- github-monitor and grade-management are Azure Functions — run locally with `func start` (Azure Functions Core Tools) from the function project directory. They need `local.settings.json` (gitignored) supplying the `AHK_*` env vars.
+- review-ui: `dotnet run` (or `dotnet watch`) from `review-ui/Ahk.Review.Ui`. It is a standalone WASM app; configure the backend URL in `wwwroot/appsettings.json`.
+- Tests use **xUnit + Moq**; handler tests mock the GitHub client, memory cache, and stores (see `Tests/.../Helpers/*MockFactory.cs`).
+
+### publish-results-pr (Go)
+```bash
+# from publish-results-pr
+go build
+go test ./... -test.v
+```
+Ships as a container (`Dockerfile`) published to `ghcr.io/bmeaut/ahk-publish-results-pr` (moved out of a former employee's personal namespace; **student repos generated before the move still reference `ghcr.io/akosdudas/...`** and need their `uses:` line edited), invoked as a GitHub Action step. `.devcontainer` is provided for development.
+
+### ahk-backend (.NET 10)
+```bash
+# from ahk-backend
+dotnet build
+dotnet test # xUnit
+dotnet run --project Ahk.Web.Server --launch-profile https # https://localhost:7443, Swagger at /swagger
+
+# EF Core migrations use the design-time factory in Ahk.Web.Data (both --project and --startup-project point there)
+dotnet ef database update --project Ahk.Web.Data --startup-project Ahk.Web.Data
+dotnet ef migrations add --project Ahk.Web.Data --startup-project Ahk.Web.Data --output-dir Migrations
+```
+Needs MSSQL (LocalDB by default; `ConnectionStrings:Default` in `appsettings.Development.json`). The dev seeder (Development env only) creates `admin`/`Admin123!` (site admin), `instructor`/`Instructor123!` (member of `viaubc01` only), courses `viaubc01`/`viaubb01` with GitHub config + CI token, and sample students/submissions/events/grades. The two courses are deliberately configured differently so the admin health dashboard shows a mix of states.
+
+⚠️ **The seeder is create-if-missing.** Editing seeded values changes nothing on a dev database that already has those rows — delete the course (or the database) to see the new values. It also calls `db.Database.MigrateAsync()` on startup, so running the backend in Development **auto-applies pending migrations** to the dev DB (prod still applies the `migrate.sql` artifact by hand).
+
+```bash
+# one-time Cosmos-export import (throwaway tool; delete once every course is migrated)
+dotnet run --project Ahk.Web.Import -- --course --connection "" \
+ --grades grades.json --events events.json --tokens tokens.json # --repo-prefix, --force
+```
+
+### ahk-frontend (Angular 21)
+```bash
+# from ahk-frontend
+npm install
+npm start # ng serve --ssl on https://localhost:4200, proxy → https://localhost:7443
+npm run build
+npm test # vitest
+npm run generate-api # regenerate src/app/api from the backend's OpenAPI (backend must be running)
+```
+`nswag.json` pins `"runtime": "Net100"` (the default Net90 binary needs .NET 9, which is not installed here).
+
+### Portal gotchas
+- **A running server locks its binary** — `dotnet build` fails with MSB3027; stop it first. `pkill -f` is unreliable here; free ports with PowerShell `Get-NetTCPConnection -LocalPort 7443 -State Listen | ... Stop-Process -Force`.
+- **Never hand-delete a migration file** — the model snapshot stays advanced and the next `migrations add` scaffolds an empty diff. Use `dotnet ef migrations remove`, or delete the whole `Migrations/` dir and regenerate.
+- **SQL Server rejects multiple cascade paths**: `Course`→`Student`→`Submission` alongside `Course`→`Submission` forces the `Student` FKs to `DeleteBehavior.NoAction`. Consequence: **deleting a course cannot rely on the database alone.** `CoursesAdminController.Delete` removes grade points, grades, events and submissions explicitly with `ExecuteDeleteAsync` before the course row goes — a new course-scoped entity whose FK is `NoAction` must be added to that list, or the delete fails on a foreign-key violation.
+- **`sqlcmd` needs `SET QUOTED_IDENTIFIER ON`** before DML on `AspNetUsers`/`SubmissionEvents` — the filtered unique index on `GitHubDeliveryId` makes the default fail.
+- **Auth cookies are named `ahk.auth` / `ahk.auth.external`, not the framework defaults** (`Program.ApplicationCookieName`). Browsers scope cookies by host and **ignore the port**, so on `localhost` every ASP.NET Identity app shares `.AspNetCore.Identity.Application`. A cookie from another project whose user id is a GUID reaches this int-keyed app and throws `"… is not a valid value for Int32"` inside `SecurityStampValidator` — a 500 on *every* request, including login. `OnValidatePrincipal` also wraps the stamp validator so an unreadable cookie signs the caller out instead of throwing. Do not revert either to the defaults.
+- **Windows PowerShell 5.1 cannot load .NET 10 assemblies** (`Add-Type` throws); to find a type's namespace, `grep -ao` the DLL instead.
+- **To probe a NuGet package's API surface**, read `~/.nuget/packages///lib//*.xml` (`grep -o 'name="M:Type\.[^"]*"'`) — but it is **partial**, many members have no doc entry, so absence there proves nothing; fall back to `grep -a` on the DLL for a member name, or just write the call and let the compiler answer. Octokit 14 facts found this way: `NewRepositoryFromTemplate` has **no** `IncludeAllBranches` (GitHub defaults it false, which is what we want), and `Repository.IsTemplate` / `RepositoryInvitation.Expired` are **non-nullable** `bool`.
+- **No Docker in this environment** — dev dependencies are built in-app (e.g. the mock OIDC provider), not containerized.
+- **Verify UI changes by screenshotting the running app**, not by trusting the build. Chrome is at `/c/Program Files/Google/Chrome/Application/chrome.exe`. A plain `--headless=new --screenshot` fires before Angular hydrates and yields a near-empty page — add `--virtual-time-budget=4000`. For screens behind login, start Chrome with `--remote-debugging-port=9222` and drive the DevTools protocol from a Node script: Node 24 ships a built-in `WebSocket`, so this needs no npm dependency. Always pass `--ignore-certificate-errors` (self-signed dev cert). When reading a value back from `Runtime.evaluate`, the RemoteObject is doubly nested at `msg.result.result.value` — reading `msg.result.value` silently yields `undefined` (the clicks still fire, so screenshots look fine while probes read blank).
+- **Exercise the m2m endpoints by hand** against the running backend — faster than the GitHub UI and it works offline. Sign with `openssl`: ``SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'dev-webhook-secret' -hex | sed 's/.*= //')"``, then `curl -sk -X POST https://localhost:7443/api/integrations/github -H 'X-GitHub-Event: ping' -H "X-Hub-Signature-256: $SIG" -d "$BODY"`. The dev seeder's `viaubc01` is org **`ahk-viaubc01`**, secret **`dev-webhook-secret`**; `viaubb01` deliberately has **no** secret, so it exercises the 500 branch.
+- **No image tooling** — no PIL, no ImageMagick. To read a PNG's pixels (sampling a brand colour, checking dimensions) decode it by hand with `zlib` + `struct`. EPS files are text-ish: `%%CMYKCustomColor` in the header carries the print colour spec.
+- **Prod hosting is same-origin**: the backend serves the Angular SPA — `UseDefaultFiles`/`UseStaticFiles` + `MapFallbackToFile("index.html")` in `Program.cs`, `ng build` output copied into `wwwroot`. Only dev uses the proxy.
+- **`web.config` is checked in with its SDK transform disabled** (`IsTransformWebConfigDisabled=true`): Mezga registers ANCM under the **V1** name `AspNetCoreModule`, so the SDK-generated V2 web.config fails to start the app there. Don't delete the file or re-enable the transform.
+- **`SelfContained=true` needs a `RuntimeIdentifier`** — pinned to `win-x64` in `Mezga.pubxml`; the server has no .NET runtime.
+
+### CI
+`.github/workflows/*-build.yaml` build+test each app (path-filtered so only the changed app runs). `*-azure-publish.yaml` / `*-docker-publish.yaml` deploy. Several azure-publish workflows are per-instance (e.g. `VIAUBC01`, `viaubb01`) — deployments are duplicated per course/organization instance.
+The portal has its own `ahk-web-deploy.yaml` (manual `workflow_dispatch`): publishes self-contained `win-x64` via the `Mezga.pubxml` profile, tunnels to the on-prem IIS server **Mezga** over **SSTP VPN**, mirrors the build over a CIFS share (`rsync`, `app_offline.htm` bracketing). Migrations are **not** auto-applied — CI emits an idempotent `migrate.sql` artifact applied by hand to a fresh DB.
+
+## github-monitor architecture (the most complex app)
+
+- **Entry point**: `GitHubMonitorFunction.cs` — single anonymous HTTP webhook. It validates the `X-Hub-Signature-256` HMAC against `AHK_GitHubWebhookSecret` before doing anything, then hands the raw body to `EventDispatchService`.
+- **Dispatch**: `Services/EventDispatch/EventDispatchService` maps a GitHub event name → list of handler types (registered in `Startup.cs` via `EventDispatchConfigBuilder`). Handlers run independently; one throwing is caught and logged, others still run.
+- **Handlers**: `EventHandlers/**`. Most extend `RepositoryEventBase`, which:
+ - deserializes the Octokit payload,
+ - creates a per-installation `GitHubClient`,
+ - **short-circuits unless the repo has `.github/ahk-monitor.yml` with `enabled: true`** (cached 12h). New handlers should derive from this base to inherit the enablement gate and the `neptun.txt` / org-membership caching helpers.
+- **Two handler families** run side-by-side: rule-enforcement handlers (branch protection, duplicate PR, review→assignee, comment edit/delete, workflow-run limit) and `StatusTracking/**` + `GradeComment/**` handlers that emit events to the queues for grade-management.
+- **Store abstraction**: `IGradeStore` / `IStatusTrackingStore` have `*AzureQueue` and `*Noop` implementations. `Startup.cs` wires the Noop variants when `AHK_EventsQueueConnectionString` is absent, so the app runs fully without grade-management.
+
+## Conventions
+
+- **Config**: all runtime config comes from `AHK_`-prefixed environment variables (bound via `AddEnvironmentVariables("AHK_")` in github-monitor; direct `AHK_*` names elsewhere). See each app's README for the exact variables. Never commit secrets; `local.settings.json` is gitignored.
+- **Enabling a repo for github-monitor**: the repo needs `.github/ahk-monitor.yml` containing `enabled: true` on its default branch, otherwise all its events are ignored.
+- **Teacher grading chatops**: `/ahk ok`, `/ahk ok 5`, `/ahk ok 5 3.5 0` in a PR comment approves/merges and records grades (numbers map positionally to exercises). Parsing lives in `Helpers/GradeCommentParser.cs`.
+- **Style is enforced at build time**: StyleCop.Analyzers + `EnforceCodeStyleInBuild` + `AnalysisMode=AllEnabledByDefault`, and grade-management/review-ui set `TreatWarningsAsErrors=true`. A large root `.editorconfig` defines the rules — match existing style exactly or the build fails. Match idiom per project (e.g. github-monitor uses explicit namespaces; review-ui uses `ImplicitUsings`/`Nullable` enabled).
+- **result.txt evaluation format** (produced by evaluators, parsed by publish-results-pr): lines of `###ahk#taskname#result#comment`, with optional `group@` prefix on taskname for grouped totals. Full spec in `publish-results-pr/README.md`.
+- **Portal conventions differ from the original apps**: ahk-backend uses standard ASP.NET config (`appsettings*.json` — `ConnectionStrings:Default`, `Authentication:Oidc:*`), **not** `AHK_` env vars, and uses the default .NET 10 SDK analyzers (no `TreatWarningsAsErrors`), so it is not bound by the root `.editorconfig`'s StyleCop rules. ahk-frontend follows the Angular style (2-space, standalone components + signals).
+- **Portal UI**: the design system lives in `ahk-frontend/src/styles.scss` — tokens plus the shared classes (`page`, `card`, `field`, `btn`, `table.data`, `badge`, `notice`, `dot`). Component stylesheets are Angular-scoped, so compose from those classes instead of restyling buttons/tables per screen. Enums cross the wire as **names** (`JsonStringEnumConverter` in `Program.cs`), which is why NSwag emits string-literal unions like `CourseRole = 'Instructor' | 'Admin'`.
+- **Portal look is the BME AUT identity**, derived from aut.bme.hu — crimson Georgia headings, Verdana body, parchment (`#dbd9c0`) table headers, the department logo. Type does three jobs: `--font-display` (Georgia) for headings, `--font-ui` (Verdana) for labels/controls, `--font-mono` for machine identifiers (slugs, orgs, repos, Neptun codes, tokens) — do not "unify" them. `--brand` (`#a4001e`, headings + primary actions), `--link` (`#074371` navy, navigation) and `--bad` (`#801b1b`, broken) are **three deliberately different reds**; collapsing them loses meaning. The logo masters and every colour's provenance live in `ahk-frontend/brand/` (BME AUT identity pack + the eduID login logo); the web copies the app loads are in `ahk-frontend/public/`. No square favicon exists yet (the mark is 2.86:1); a follow-up needs a square crop from the EPS. The **login screen leads with eduID** (the federated `/api/auth/external/challenge` flow); local username/password is collapsed behind an "I don't have an eduID account" link. The eduID button follows the [eduID brand](https://eduid.hu/hu/depo/) but renders its own English "Login" rather than the official Hungarian-label PNG; `--eduid` blue is scoped to that button, never in the global palette.
+- **Portal code style**: **no top-level statements** (`Program` is an explicit class with `Main`); **`int` keys everywhere**, including Identity (`IdentityUser`); the domain term is **Course**, never "tenant".
+- **Frontend API errors**: use `readApiError(err, fallback)` from `ahk-frontend/src/app/core/api-error.ts` instead of parsing a `SwaggerException` inline. It pulls out the API's own `{error}`/`{errors}` message and reports status 0 as "the server is not responding" — without that, a generated-client failure surfaces as a misleading domain error (a stopped backend once looked exactly like "wrong password").
+- **Portal tests**: course-scoping is tested against `ApplicationDbContext` directly with EF InMemory + a mutable `ICurrentCourseProvider` double. `WebApplicationFactory` DbContext swaps must remove **both** `DbContextOptions` and EF 9+'s `IDbContextOptionsConfiguration` descriptors, else two providers register. `Ahk.Web.Services` exposes internals via `InternalsVisibleTo`. Frontend single run: `npx ng test --watch=false`. Controller tests can run over a real `UserManager` on EF InMemory (see `UserNeptunTests`) — but InMemory does **not** enforce filtered unique indexes, so uniqueness is proven via the controller's pre-check, not the DB.
+- **Testing the m2m endpoints**: build Octokit models by **deserializing the JSON GitHub would send** — `new SimpleJsonSerializer().Deserialize(json)` — rather than through their long constructors. `WebApplicationFactory` must also remove `ICourseGitHubAppTokenProvider` / `ICourseGitHubClientFactory` (so nothing reaches api.github.com) and `TimeProvider` (swap in `FakeTimeProvider` from `Microsoft.Extensions.TimeProvider.Testing`). Two porting traps: xUnit `[InlineData]` has **no `params`** — MSTest `[DataRow(…, 1, 2)]` becomes `[InlineData(…, new[] { 1d, 2d })]`; and `HttpClient` parses the `Date` header itself and **refuses to send a malformed one**, so reaching the "not valid RFC1123" branch needs `TryAddWithoutValidation`.
+- **Legacy parity is a hard constraint when porting**: grade semantics (append-only, positional `ex0`/`ex1` name carry-forward, per-exercise summing) and the CSV layout are covered by parity tests — changing them changes existing courses' grades. One deliberate deviation: `CsvExporter` sorts columns `Ordinal` rather than culture-sensitively.
diff --git a/README.md b/README.md
index 3d930f0..dfbb3cd 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,17 @@
Please refer to for the concept and details.
-## Applications
+## The portal
+
+**[ahk-backend](./ahk-backend)** + **[ahk-frontend](./ahk-frontend)**: a single ASP.NET Core (.NET 10) site with an Angular front end, at `ahk.aut.bme.hu`, replacing the per-course Azure deployments below with one multi-course installation. It now hosts every entry point the original system had — the GitHub webhook receiver, the `/ahk ok` chatops commands and the HMAC-verified CI callback — as well as taking over from GitHub Classroom for handing out student repositories, so a course can run on it with **no Azure Functions**.
+
+The four applications below remain deployed for courses that have not migrated yet; courses move one at a time. See [Cutover per course](./ahk-backend/docs/github-app.md#cutover-per-course).
+
+## Applications (the original system)
**[GitHub Monitor](./github-monitor)**: An Azure function written in .NET with an http webhook registered as a GitHub Application that manages the workflow of homework submissions. Performs automatic actions on repositories acting as submissions and monitors proper usage of pull requests.
-**[Publish Results to PR](./publish-results-pr)**: A [containerized](https://github.com/users/akosdudas/packages/container/package/ahk-publish-results-pr) Go application that processes the output of evaluator applications and publishes the results into a pull request for the student to see, as well as forwarding it to the _grade management_ application.
+**[Publish Results to PR](./publish-results-pr)**: A [containerized](https://github.com/orgs/bmeaut/packages/container/package/ahk-publish-results-pr) Go application that processes the output of evaluator applications and publishes the results into a pull request for the student to see, as well as forwarding it to the _grade management_ application.
**[Grade Management](./grade-management)**: An Azure function written in .NET that accepts events from the other applications and stores them in Azure CosmosDB database. Helps teachers by reducing the administration of tracking the status of submissions and exporting final grades.
diff --git a/ahk-backend/.gitignore b/ahk-backend/.gitignore
new file mode 100644
index 0000000..c2c3706
--- /dev/null
+++ b/ahk-backend/.gitignore
@@ -0,0 +1,8 @@
+## Build output
+bin/
+obj/
+
+## Local/dev secrets (never commit)
+appsettings.*.local.json
+local.settings.json
+*.user
diff --git a/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj b/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj
new file mode 100644
index 0000000..ddb40d7
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Ahk.Web.Data.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
diff --git a/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs b/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs
new file mode 100644
index 0000000..068ce30
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/ApplicationDbContext.cs
@@ -0,0 +1,232 @@
+using Ahk.Web.Data.Entities;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace Ahk.Web.Data;
+
+///
+/// EF Core context backing ASP.NET Identity plus the course-scoped domain model. Applies a global query filter
+/// on every entity so a request only sees rows for the resolved current course.
+///
+/// Note: the filter follows , which is populated by whichever entry point
+/// resolved the course (route segment, webhook payload, or CI token). When no course is resolved the filter
+/// matches nothing — callers with no course context (e.g. the one-time importer) must set a provider or use
+/// IgnoreQueryFilters().
+///
+public class ApplicationDbContext : IdentityDbContext
+{
+ private readonly ICurrentCourseProvider currentCourse;
+
+ public ApplicationDbContext(DbContextOptions options, ICurrentCourseProvider currentCourse)
+ : base(options)
+ {
+ this.currentCourse = currentCourse;
+ }
+
+ public DbSet Courses => Set();
+
+ public DbSet CourseGitHubConfigs => Set();
+
+ public DbSet CourseMemberships => Set();
+
+ public DbSet CourseWebhookTokens => Set();
+
+ public DbSet Students => Set();
+
+ public DbSet Submissions => Set();
+
+ public DbSet SubmissionEvents => Set();
+
+ public DbSet GradeRecords => Set();
+
+ public DbSet GradeExercisePoints => Set();
+
+ public DbSet Assignments => Set();
+
+ public DbSet AssignmentAcceptances => Set();
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+
+ builder.Entity(e =>
+ {
+ e.Property(u => u.DisplayName).HasMaxLength(256);
+ e.Property(u => u.NeptunCode).HasMaxLength(32);
+ e.Property(u => u.Affiliation).HasMaxLength(256);
+ e.Property(u => u.GitHubUsername).HasMaxLength(128);
+
+ // Filtered unique index: a Neptun code identifies a person, so no two accounts may share one.
+ // NULL means "no code" (directory/local accounts may have none) and is allowed many times —
+ // which is why the admin controllers store null, never "", for a blank code.
+ e.HasIndex(u => u.NeptunCode).IsUnique().HasFilter("[NeptunCode] IS NOT NULL");
+ });
+
+ builder.Entity(e =>
+ {
+ e.HasIndex(c => c.Slug).IsUnique();
+ e.Property(c => c.Slug).HasMaxLength(64).IsRequired();
+ e.Property(c => c.Name).HasMaxLength(256).IsRequired();
+ e.Property(c => c.GitHubOrganization).HasMaxLength(256);
+ e.Property(c => c.RepoNamePrefix).HasMaxLength(256);
+
+ // Machine-to-machine course resolution: organization first, then repo-name prefix.
+ e.HasIndex(c => c.GitHubOrganization);
+ e.HasIndex(c => c.RepoNamePrefix);
+
+ e.HasOne(c => c.GitHubConfig)
+ .WithOne(g => g.Course!)
+ .HasForeignKey(g => g.CourseId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(e =>
+ {
+ e.HasIndex(g => g.CourseId).IsUnique();
+ e.Property(g => g.GitHubAppId).HasMaxLength(64);
+ e.Property(g => g.GitHubAccessToken).HasMaxLength(512);
+ e.Property(g => g.GitHubWebhookSecret).HasMaxLength(512);
+ });
+
+ builder.Entity(e =>
+ {
+ e.HasKey(m => new { m.UserId, m.CourseId });
+ e.HasOne(m => m.User).WithMany(u => u.CourseMemberships).HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(m => m.Course).WithMany(c => c.Memberships).HasForeignKey(m => m.CourseId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(t => t.Token).HasMaxLength(128).IsRequired();
+ e.Property(t => t.Secret).HasMaxLength(512).IsRequired();
+ e.Property(t => t.Description).HasMaxLength(512);
+
+ // Globally unique: the CI callback carries no {course} segment, so the token resolves the course.
+ e.HasIndex(t => t.Token).IsUnique();
+ e.HasOne(t => t.Course).WithMany().HasForeignKey(t => t.CourseId).OnDelete(DeleteBehavior.Cascade);
+ e.HasQueryFilter(t => t.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(s => s.Neptun).HasMaxLength(32).IsRequired();
+ e.Property(s => s.GitHubUsername).HasMaxLength(128);
+ e.Property(s => s.Name).HasMaxLength(256);
+
+ e.HasIndex(s => new { s.CourseId, s.Neptun }).IsUnique();
+ e.HasOne(s => s.Course).WithMany().HasForeignKey(s => s.CourseId).OnDelete(DeleteBehavior.Cascade);
+ e.HasQueryFilter(s => s.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(s => s.GitHubRepoName).HasMaxLength(400).IsRequired();
+
+ e.HasIndex(s => new { s.CourseId, s.GitHubRepoName }).IsUnique();
+ e.HasIndex(s => new { s.CourseId, s.StudentId });
+
+ e.HasOne(s => s.Course).WithMany().HasForeignKey(s => s.CourseId).OnDelete(DeleteBehavior.Cascade);
+
+ // NoAction (not SetNull): Course cascades to both Student and Submission, and SQL Server rejects
+ // the resulting multiple cascade paths. Deleting a course still removes both.
+ e.HasOne(s => s.Student).WithMany(st => st!.Submissions).HasForeignKey(s => s.StudentId).OnDelete(DeleteBehavior.NoAction);
+ e.HasQueryFilter(s => s.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e =>
+ {
+ // Table-per-hierarchy, mirroring the original polymorphic event log.
+ e.HasDiscriminator("EventType")
+ .HasValue(nameof(RepositoryCreatedEvent))
+ .HasValue(nameof(BranchCreatedEvent))
+ .HasValue(nameof(PullRequestEvent))
+ .HasValue(nameof(WorkflowRunEvent));
+
+ e.Property(x => x.GitHubDeliveryId).HasMaxLength(128);
+
+ e.HasIndex(x => new { x.CourseId, x.SubmissionId, x.Timestamp });
+
+ // Filtered unique index: redelivered webhooks must not duplicate events.
+ e.HasIndex(x => x.GitHubDeliveryId).IsUnique().HasFilter("[GitHubDeliveryId] IS NOT NULL");
+
+ e.HasOne(x => x.Course).WithMany().HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.NoAction);
+ e.HasOne(x => x.Submission).WithMany(s => s!.Events).HasForeignKey(x => x.SubmissionId).OnDelete(DeleteBehavior.Cascade);
+ e.HasQueryFilter(x => x.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e => e.Property(x => x.Branch).HasMaxLength(400));
+ builder.Entity(e => e.Property(x => x.Conclusion).HasMaxLength(64));
+ builder.Entity(e =>
+ {
+ e.Property(x => x.Action).HasMaxLength(64);
+ e.Property(x => x.HtmlUrl).HasMaxLength(1024);
+ e.Property(x => x.Neptun).HasMaxLength(32);
+ e.PrimitiveCollection(x => x.Assignees); // JSON column
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(g => g.Neptun).HasMaxLength(32).IsRequired();
+ e.Property(g => g.PrUrl).HasMaxLength(1024);
+ e.Property(g => g.Actor).HasMaxLength(256);
+ e.Property(g => g.Origin).HasMaxLength(1024);
+
+ // "Latest result for this submission/PR" — the GetLastResultOf access path.
+ e.HasIndex(g => new { g.CourseId, g.SubmissionId, g.PrNumber, g.Date });
+
+ // Confirmed-grade listing and CSV export.
+ e.HasIndex(g => new { g.CourseId, g.Confirmed, g.Date });
+
+ e.HasOne(g => g.Course).WithMany().HasForeignKey(g => g.CourseId).OnDelete(DeleteBehavior.NoAction);
+ e.HasOne(g => g.Submission).WithMany(s => s!.Grades).HasForeignKey(g => g.SubmissionId).OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(g => g.Student).WithMany().HasForeignKey(g => g.StudentId).OnDelete(DeleteBehavior.NoAction);
+ e.HasQueryFilter(g => g.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(p => p.Name).HasMaxLength(256).IsRequired();
+ e.HasOne(p => p.GradeRecord).WithMany(g => g!.Points).HasForeignKey(p => p.GradeRecordId).OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(a => a.Name).HasMaxLength(256).IsRequired();
+ e.Property(a => a.Description).HasMaxLength(1024);
+ e.Property(a => a.TemplateRepoName).HasMaxLength(400).IsRequired();
+ e.Property(a => a.InviteToken).HasMaxLength(128).IsRequired();
+
+ // Globally unique: the invite link carries the token as its only identifier of the assignment, and
+ // it is the capability that lets a stranger provision a repository — collisions are not an option.
+ e.HasIndex(a => a.InviteToken).IsUnique();
+
+ // The instructor listing reads "this course's open assignments".
+ e.HasIndex(a => new { a.CourseId, a.ArchivedAt });
+
+ e.HasOne(a => a.Course).WithMany().HasForeignKey(a => a.CourseId).OnDelete(DeleteBehavior.Cascade);
+ e.HasQueryFilter(a => a.CourseId == this.currentCourse.CurrentCourseId);
+ });
+
+ builder.Entity(e =>
+ {
+ e.Property(a => a.GitHubRepoName).HasMaxLength(400).IsRequired();
+ e.Property(a => a.RepoUrl).HasMaxLength(1024).IsRequired();
+ e.Property(a => a.GitHubUsername).HasMaxLength(128).IsRequired();
+
+ // One repository per student per assignment. This index is the concurrency guard: a double click or
+ // a second tab loses the race here rather than creating a second repository on GitHub.
+ e.HasIndex(a => new { a.AssignmentId, a.UserId }).IsUnique();
+ e.HasIndex(a => new { a.CourseId, a.GitHubRepoName });
+
+ // The student home page reads every acceptance of one user across all courses.
+ e.HasIndex(a => a.UserId);
+
+ // NoAction on Course: Course cascades to Assignment which cascades to here, and SQL Server rejects
+ // the second path. CoursesAdminController.Delete removes these rows explicitly because of it.
+ e.HasOne(a => a.Course).WithMany().HasForeignKey(a => a.CourseId).OnDelete(DeleteBehavior.NoAction);
+ e.HasOne(a => a.Assignment).WithMany(x => x!.Acceptances).HasForeignKey(a => a.AssignmentId).OnDelete(DeleteBehavior.Cascade);
+ e.HasOne(a => a.User).WithMany().HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade);
+ e.HasQueryFilter(a => a.CourseId == this.currentCourse.CurrentCourseId);
+ });
+ }
+}
diff --git a/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs b/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..1409c30
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/DesignTimeDbContextFactory.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Ahk.Web.Data;
+
+///
+/// Lets dotnet ef build the context directly from this project (no web host needed). Uses a
+/// LocalDB design-time connection; the runtime connection string comes from configuration in the web app.
+///
+public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory
+{
+ public ApplicationDbContext CreateDbContext(string[] args)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=AhkWeb;Trusted_Connection=True;TrustServerCertificate=True")
+ .Options;
+
+ return new ApplicationDbContext(options, new NullCurrentCourseProvider());
+ }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs b/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs
new file mode 100644
index 0000000..223f9d3
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/ApplicationRole.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Ahk.Web.Data.Entities;
+
+/// Application-wide (site-level) role, e.g. the super-admin role. Course-level roles live on .
+public class ApplicationRole : IdentityRole
+{
+ public ApplicationRole()
+ {
+ }
+
+ public ApplicationRole(string roleName)
+ : base(roleName)
+ {
+ }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs b/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs
new file mode 100644
index 0000000..7d2cca9
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/ApplicationUser.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Identity;
+
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Application user. Extends the ASP.NET Identity user with app-specific profile fields.
+/// A user may be a member of many s via .
+///
+public class ApplicationUser : IdentityUser
+{
+ public string? DisplayName { get; set; }
+
+ ///
+ /// Neptun code — from the IdP's neptun_code claim, or set by an admin when creating the account.
+ /// The key of the domain model: it links a signed-in user to their rows and is how
+ /// an eduID login is matched to a pre-provisioned account. Unique when present (filtered unique index);
+ /// null means "no code" and may repeat.
+ ///
+ public string? NeptunCode { get; set; }
+
+ ///
+ /// The IdP's eduperson_scoped_affiliation claim (e.g. "staff@bme.hu"). Multi-valued at the source;
+ /// all values are stored joined with ';'. Kept so login can be restricted by affiliation later.
+ ///
+ public string? Affiliation { get; set; }
+
+ ///
+ /// GitHub login, verified against the GitHub API when the user first supplies it. Site-wide rather than
+ /// per-course: a person has one GitHub account, so once it is known no course asks for it again.
+ /// Copied onto when an assignment is accepted.
+ ///
+ public string? GitHubUsername { get; set; }
+
+ /// GitHub's numeric account id — stable across a rename, which the login is not.
+ public long? GitHubUserId { get; set; }
+
+ public ICollection CourseMemberships { get; } = new List();
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs b/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs
new file mode 100644
index 0000000..0856a38
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/Assignment.cs
@@ -0,0 +1,53 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// A piece of homework a course hands out: a template repository plus the invite link students use to get their
+/// own copy of it. This is the part of the lifecycle GitHub Classroom used to own.
+///
+/// Assignments are deliberately *additive*: a repository does not need one. Submissions created by external
+/// tooling (or by Classroom before the migration) keep working, so nothing downstream may assume that a
+/// has an assignment behind it.
+///
+public class Assignment : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ /// Shown to the student on the accept screen ("Accept the assignment — {Name}").
+ public string Name { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+
+ ///
+ /// The template repository students are given a copy of, as full "owner/name", normalized with
+ /// . It must be marked is_template on GitHub.
+ ///
+ public string TemplateRepoName { get; set; } = string.Empty;
+
+ ///
+ /// Prefix for the student repositories generated from this assignment: each clone is named
+ /// {RepoNamePrefix}-{neptun}. When blank, the template repository's own name is used instead (the
+ /// original behaviour), so assignments created before this field keep their naming unchanged.
+ ///
+ public string? RepoNamePrefix { get; set; }
+
+ ///
+ /// Random, unguessable segment of the invite URL (/{course}/invite/{token}). A readable slug would
+ /// let any signed-in user guess another course's assignment and provision themselves a repository, so the
+ /// link itself is the capability. Regenerating it invalidates every copy already handed out.
+ ///
+ public string InviteToken { get; set; } = string.Empty;
+
+ ///
+ /// Set when the assignment is archived. Archived assignments drop out of the default listing *and* stop
+ /// accepting new students; those who already accepted keep their repository link.
+ ///
+ public DateTimeOffset? ArchivedAt { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public ICollection Acceptances { get; } = new List();
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs b/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs
new file mode 100644
index 0000000..ec24c64
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/AssignmentAcceptance.cs
@@ -0,0 +1,52 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// One student's acceptance of one : which repository was created for them and when.
+/// The unique index on (AssignmentId, UserId) is what makes accepting twice — a double click, a second tab —
+/// produce one repository rather than two.
+///
+/// The identity link is the , not : the signed-in account is
+/// who accepted, and the course-scoped student row is reachable through their Neptun code when grading needs it.
+/// No submission link either — one repository can carry many submissions over a semester, and none of them are
+/// this record's business.
+///
+public class AssignmentAcceptance : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ public int AssignmentId { get; set; }
+
+ public Assignment? Assignment { get; set; }
+
+ /// The account that clicked Accept.
+ public int UserId { get; set; }
+
+ public ApplicationUser? User { get; set; }
+
+ /// Full "owner/name" of the created repository, normalized with .
+ public string GitHubRepoName { get; set; } = string.Empty;
+
+ public string RepoUrl { get; set; } = string.Empty;
+
+ /// The GitHub login the repository was shared with, as it stood at accept time.
+ public string GitHubUsername { get; set; } = string.Empty;
+
+ public DateTimeOffset AcceptedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ // --- Collaborator invitation state ---
+ // A student who is already an organization member is added to the repository outright (GitHub answers 204).
+ // Anyone else only gets an *invitation* (201) which they must accept, and which expires. Until then the
+ // repository is invisible to them, so the portal has to track and be able to re-send it.
+
+ /// True while GitHub has an outstanding invitation the student has not accepted yet.
+ public bool InvitationPending { get; set; }
+
+ /// GitHub's invitation id — needed to delete the stale one before issuing a replacement.
+ public long? InvitationId { get; set; }
+
+ public DateTimeOffset? InvitationSentAt { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/Course.cs b/ahk-backend/Ahk.Web.Data/Entities/Course.cs
new file mode 100644
index 0000000..1d77a67
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/Course.cs
@@ -0,0 +1,37 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// A university course (e.g. BME subject code "viaubc01"). This is what used to be a separate
+/// per-course Azure Functions deployment; each course now lives as one record in the central site
+/// and holds its own GitHub-environment configuration. Domain data is assigned to a course via
+/// .
+///
+public class Course
+{
+ public int Id { get; set; }
+
+ /// URL-safe unique identifier used in the path segment: ahk.aut.bme.hu/{Slug}/...
+ public string Slug { get; set; } = string.Empty;
+
+ public string Name { get; set; } = string.Empty;
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ // --- Repository routing ---
+ // These two live on Course (not CourseGitHubConfig) because machine-to-machine entry points resolve
+ // the course from them, and CourseResolutionMiddleware loads Course on every course-scoped request.
+ // Credentials deliberately live in CourseGitHubConfig so they are not on that hot path.
+
+ /// GitHub organization owning this course's repositories — the primary resolution key.
+ public string? GitHubOrganization { get; set; }
+
+ ///
+ /// Optional repository-name prefix, used to disambiguate when one organization hosts several courses.
+ /// This is the explicit form of what used to be the implicit "repo prefix = course" convention.
+ ///
+ public string? RepoNamePrefix { get; set; }
+
+ public CourseGitHubConfig? GitHubConfig { get; set; }
+
+ public ICollection Memberships { get; } = new List();
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs
new file mode 100644
index 0000000..89db783
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/CourseGitHubConfig.cs
@@ -0,0 +1,39 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Per-course GitHub integration configuration — what used to be the per-deployment AHK_* environment
+/// variables of github-monitor (GitHubMonitorConfig.cs). Kept in its own table (1:1 with
+/// ) so the per-request course lookup never loads the GitHub App private key.
+///
+public class CourseGitHubConfig
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ /// GitHub App id (was AHK_GitHubAppId).
+ public string? GitHubAppId { get; set; }
+
+ /// GitHub App private key (was AHK_GitHubAppPrivateKey). Stored as a plain column by decision.
+ public string? GitHubAppPrivateKey { get; set; }
+
+ ///
+ /// Personal / fine-grained access token used for REST calls that do not need a per-installation token —
+ /// today only the connectivity health check. Stored as a plain column, like the other credentials.
+ ///
+ public string? GitHubAccessToken { get; set; }
+
+ /// Secret used to validate the X-Hub-Signature-256 webhook signature (was AHK_GitHubWebhookSecret).
+ public string? GitHubWebhookSecret { get; set; }
+
+ /// Maximum allowed Actions workflow runs per repository; was the const WorkflowRunThreshold = 5.
+ public int WorkflowRunThreshold { get; set; } = 5;
+
+ /// When false, incoming webhooks for this course are ignored.
+ public bool Enabled { get; set; } = true;
+
+ /// Last time an administrator changed these settings; shown in the admin UI.
+ public DateTimeOffset? UpdatedAt { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs
new file mode 100644
index 0000000..5abc1af
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/CourseMembership.cs
@@ -0,0 +1,15 @@
+namespace Ahk.Web.Data.Entities;
+
+/// Assigns a user to a course with a course-level role. A user may belong to many courses.
+public class CourseMembership
+{
+ public int UserId { get; set; }
+
+ public int CourseId { get; set; }
+
+ public CourseRole Role { get; set; } = CourseRole.Instructor;
+
+ public ApplicationUser? User { get; set; }
+
+ public Course? Course { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs
new file mode 100644
index 0000000..9459cd9
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/CourseRole.cs
@@ -0,0 +1,11 @@
+namespace Ahk.Web.Data.Entities;
+
+/// Role a user holds within a specific course (distinct from site-level roles).
+public enum CourseRole
+{
+ /// Can view the course's submissions, statuses and grades.
+ Instructor = 0,
+
+ /// Can additionally manage the course's configuration and members.
+ Admin = 1,
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs b/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs
new file mode 100644
index 0000000..c916452
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/CourseWebhookToken.cs
@@ -0,0 +1,31 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Token/secret pair authenticating a course's CI callbacks (publish-results-pr → evaluation-result webhook).
+/// Relational form of the webhooktokens container's WebhookToken.
+///
+/// is globally unique because the CI callback carries no {course} path segment — the token
+/// itself is how that request resolves to a course. is the HMAC-SHA256 key, verified with
+/// the scheme ported from grade-management/.../Helpers/HmacSha256Validator.cs.
+///
+public class CourseWebhookToken : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ /// Public identifier sent in the X-Ahk-Token header.
+ public string Token { get; set; } = string.Empty;
+
+ /// HMAC signing key. Stored as a plain column by decision.
+ public string Secret { get; set; } = string.Empty;
+
+ public string? Description { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ /// When set, the token is no longer accepted.
+ public DateTimeOffset? RevokedAt { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs b/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs
new file mode 100644
index 0000000..3f4292c
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/GradeRecord.cs
@@ -0,0 +1,67 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Append-only grade result — the relational form of StudentResult. Never updated: every evaluation
+/// or teacher action inserts a new row, and the current grade is the latest one.
+/// distinguishes an automated evaluation result (false) from a teacher-approved grade (true).
+///
+public class GradeRecord : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ public int SubmissionId { get; set; }
+
+ public Submission? Submission { get; set; }
+
+ public int? StudentId { get; set; }
+
+ public Student? Student { get; set; }
+
+ ///
+ /// Neptun as recorded at grading time. Denormalized deliberately: the CSV export reports the code that was
+ /// on the result, and a grade is a point-in-time record.
+ ///
+ public string Neptun { get; set; } = string.Empty;
+
+ public int? PrNumber { get; set; }
+
+ public string? PrUrl { get; set; }
+
+ public DateTimeOffset Date { get; set; }
+
+ /// Who produced it: a teacher's GitHub login, or "grade-management-api" for automated results.
+ public string? Actor { get; set; }
+
+ /// Where it came from: the commit URL for automated results, or the PR comment for chatops.
+ public string? Origin { get; set; }
+
+ /// False for automated evaluation results; true once a teacher approves/overrides via /ahk ok.
+ public bool Confirmed { get; set; }
+
+ public ICollection Points { get; } = new List();
+}
+
+///
+/// Points for one exercise of a — the relational form of the embedded
+/// ExerciseWithPoint collection. Exercise names stay free-form (positional "ex0"/"ex1" carried forward
+/// from the previous result, or the evaluator's exerciseName), matching the original semantics.
+///
+public class GradeExercisePoint
+{
+ public int Id { get; set; }
+
+ public int GradeRecordId { get; set; }
+
+ public GradeRecord? GradeRecord { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public double Point { get; set; }
+
+ /// Preserves positional order, which is significant for the /ahk ok "5 3.5 0" chatops form.
+ public int Order { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs b/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs
new file mode 100644
index 0000000..428d856
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/ICourseScoped.cs
@@ -0,0 +1,11 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Marker for domain entities whose rows belong to a single course. The
+/// applies an EF Core global query filter on so queries only see the active course's
+/// rows. Authorization (course membership) remains the real access gate; the filter is a scoping convenience.
+///
+public interface ICourseScoped
+{
+ int CourseId { get; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/Student.cs b/ahk-backend/Ahk.Web.Data/Entities/Student.cs
new file mode 100644
index 0000000..1a9c805
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/Student.cs
@@ -0,0 +1,27 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// A student within a course, keyed by Neptun code (BME's student identifier). Replaces the neptun string
+/// that was denormalized onto every grade and pull-request event in the original system.
+/// Rows are created on first sighting (from neptun.txt or a pull-request payload); no roster import
+/// is required.
+///
+public class Student : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ /// Normalized with (uppercase, trimmed).
+ public string Neptun { get; set; } = string.Empty;
+
+ public string? GitHubUsername { get; set; }
+
+ public string? Name { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public ICollection Submissions { get; } = new List();
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/Submission.cs b/ahk-backend/Ahk.Web.Data/Entities/Submission.cs
new file mode 100644
index 0000000..af24c16
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/Submission.cs
@@ -0,0 +1,34 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// One student's GitHub repository within a course — the anchor that status events and grades hang off.
+/// Replaces the raw repository-name string that the original system used as its grouping key
+/// (StudentResult.GitHubRepoName / StatusEventBase.Repository).
+///
+public class Submission : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ /// Null while the student is unknown — the repository often exists before neptun.txt is pushed.
+ public int? StudentId { get; set; }
+
+ public Student? Student { get; set; }
+
+ /// Full "owner/name", normalized with (lowercase, trimmed).
+ public string GitHubRepoName { get; set; } = string.Empty;
+
+ /// GitHub's numeric repository id, when known.
+ public long? GitHubRepoId { get; set; }
+
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+
+ public DateTimeOffset? LastEventAt { get; set; }
+
+ public ICollection Events { get; } = new List();
+
+ public ICollection Grades { get; } = new List();
+}
diff --git a/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs b/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs
new file mode 100644
index 0000000..d5e7c72
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Entities/SubmissionEvent.cs
@@ -0,0 +1,64 @@
+namespace Ahk.Web.Data.Entities;
+
+///
+/// Append-only status event for a submission. Mirrors the polymorphic StatusEventBase log of the
+/// original CosmosDB events container, mapped table-per-hierarchy. Rows are never mutated; the
+/// current status of a submission is a projection over this log (see the status projection in
+/// Ahk.Web.Services), exactly as StatusTrackingService.createStatus did.
+///
+public abstract class SubmissionEvent : ICourseScoped
+{
+ public int Id { get; set; }
+
+ public int CourseId { get; set; }
+
+ public Course? Course { get; set; }
+
+ public int SubmissionId { get; set; }
+
+ public Submission? Submission { get; set; }
+
+ public DateTimeOffset Timestamp { get; set; }
+
+ ///
+ /// GitHub's X-GitHub-Delivery id. Unique (where present) so webhook redeliveries do not duplicate rows —
+ /// a guard the original Cosmos model lacked.
+ ///
+ public string? GitHubDeliveryId { get; set; }
+}
+
+/// Repository was created for the student (was RepositoryCreateEvent).
+public class RepositoryCreatedEvent : SubmissionEvent
+{
+}
+
+/// A branch was pushed/created (was BranchCreateEvent).
+public class BranchCreatedEvent : SubmissionEvent
+{
+ public string Branch { get; set; } = string.Empty;
+}
+
+/// Pull request activity (was PullRequestEvent).
+public class PullRequestEvent : SubmissionEvent
+{
+ public int Number { get; set; }
+
+ /// GitHub action name (opened, closed, assigned, ...); the latest one is the PR's status.
+ public string Action { get; set; } = string.Empty;
+
+ public string? HtmlUrl { get; set; }
+
+ /// Neptun as seen at event time; kept as a snapshot because the original log recorded it per event.
+ public string? Neptun { get; set; }
+
+ /// Assignees at event time. Mapped to a JSON column — only ever concatenated for display.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "EF Core primitive collections require a settable List.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "EF Core primitive collections require List.")]
+ public List Assignees { get; set; } = new();
+}
+
+/// An Actions workflow run finished (was WorkflowRunEvent).
+public class WorkflowRunEvent : SubmissionEvent
+{
+ public string? Conclusion { get; set; }
+}
diff --git a/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs b/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs
new file mode 100644
index 0000000..71867e8
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/ICurrentCourseProvider.cs
@@ -0,0 +1,16 @@
+namespace Ahk.Web.Data;
+
+///
+/// Supplies the course the current request is scoped to. Implemented in the web layer (resolved from the
+/// {course} route segment) and consumed by to drive the course query filter.
+///
+public interface ICurrentCourseProvider
+{
+ int? CurrentCourseId { get; }
+}
+
+/// No-op provider (no active course). Used at design time and in host/admin contexts.
+public sealed class NullCurrentCourseProvider : ICurrentCourseProvider
+{
+ public int? CurrentCourseId => null;
+}
diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs
new file mode 100644
index 0000000..420c03f
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.Designer.cs
@@ -0,0 +1,1008 @@
+//
+using System;
+using Ahk.Web.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Ahk.Web.Data.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260730062811_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex")
+ .HasFilter("[NormalizedName] IS NOT NULL");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("Affiliation")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DisplayName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("GitHubUserId")
+ .HasColumnType("bigint");
+
+ b.Property("GitHubUsername")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("bit");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("NeptunCode")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("PasswordHash")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("bit");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("bit");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NeptunCode")
+ .IsUnique()
+ .HasFilter("[NeptunCode] IS NOT NULL");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex")
+ .HasFilter("[NormalizedUserName] IS NOT NULL");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ArchivedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Description")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("InviteToken")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("TemplateRepoName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InviteToken")
+ .IsUnique();
+
+ b.HasIndex("CourseId", "ArchivedAt");
+
+ b.ToTable("Assignments");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("AcceptedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("AssignmentId")
+ .HasColumnType("int");
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("GitHubRepoName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("GitHubUsername")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("InvitationId")
+ .HasColumnType("bigint");
+
+ b.Property("InvitationPending")
+ .HasColumnType("bit");
+
+ b.Property("InvitationSentAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("RepoUrl")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("AssignmentId", "UserId")
+ .IsUnique();
+
+ b.HasIndex("CourseId", "GitHubRepoName");
+
+ b.ToTable("AssignmentAcceptances");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("GitHubOrganization")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("RepoNamePrefix")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GitHubOrganization");
+
+ b.HasIndex("RepoNamePrefix");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Courses");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GitHubAccessToken")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("GitHubAppId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GitHubAppPrivateKey")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("GitHubWebhookSecret")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("WorkflowRunThreshold")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseId")
+ .IsUnique();
+
+ b.ToTable("CourseGitHubConfigs");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("Role")
+ .HasColumnType("int");
+
+ b.HasKey("UserId", "CourseId");
+
+ b.HasIndex("CourseId");
+
+ b.ToTable("CourseMemberships");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Description")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("RevokedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Secret")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseId");
+
+ b.HasIndex("Token")
+ .IsUnique();
+
+ b.ToTable("CourseWebhookTokens");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("GradeRecordId")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Order")
+ .HasColumnType("int");
+
+ b.Property("Point")
+ .HasColumnType("float");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GradeRecordId");
+
+ b.ToTable("GradeExercisePoints");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Actor")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Confirmed")
+ .HasColumnType("bit");
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("Date")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Neptun")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Origin")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("PrNumber")
+ .HasColumnType("int");
+
+ b.Property("PrUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("StudentId")
+ .HasColumnType("int");
+
+ b.Property("SubmissionId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StudentId");
+
+ b.HasIndex("SubmissionId");
+
+ b.HasIndex("CourseId", "Confirmed", "Date");
+
+ b.HasIndex("CourseId", "SubmissionId", "PrNumber", "Date");
+
+ b.ToTable("GradeRecords");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("GitHubUsername")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Neptun")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseId", "Neptun")
+ .IsUnique();
+
+ b.ToTable("Students");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("GitHubRepoId")
+ .HasColumnType("bigint");
+
+ b.Property("GitHubRepoName")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.Property("LastEventAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("StudentId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StudentId");
+
+ b.HasIndex("CourseId", "GitHubRepoName")
+ .IsUnique();
+
+ b.HasIndex("CourseId", "StudentId");
+
+ b.ToTable("Submissions");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("CourseId")
+ .HasColumnType("int");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(34)
+ .HasColumnType("nvarchar(34)");
+
+ b.Property("GitHubDeliveryId")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("SubmissionId")
+ .HasColumnType("int");
+
+ b.Property("Timestamp")
+ .HasColumnType("datetimeoffset");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GitHubDeliveryId")
+ .IsUnique()
+ .HasFilter("[GitHubDeliveryId] IS NOT NULL");
+
+ b.HasIndex("SubmissionId");
+
+ b.HasIndex("CourseId", "SubmissionId", "Timestamp");
+
+ b.ToTable("SubmissionEvents");
+
+ b.HasDiscriminator("EventType").HasValue("SubmissionEvent");
+
+ b.UseTphMappingStrategy();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RoleId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ClaimValue")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("RoleId")
+ .HasColumnType("int");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("int");
+
+ b.Property("LoginProvider")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Value")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.BranchCreatedEvent", b =>
+ {
+ b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent");
+
+ b.Property("Branch")
+ .IsRequired()
+ .HasMaxLength(400)
+ .HasColumnType("nvarchar(400)");
+
+ b.HasDiscriminator().HasValue("BranchCreatedEvent");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.PullRequestEvent", b =>
+ {
+ b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.PrimitiveCollection("Assignees")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("HtmlUrl")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Neptun")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Number")
+ .HasColumnType("int");
+
+ b.HasDiscriminator().HasValue("PullRequestEvent");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.RepositoryCreatedEvent", b =>
+ {
+ b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent");
+
+ b.HasDiscriminator().HasValue("RepositoryCreatedEvent");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.WorkflowRunEvent", b =>
+ {
+ b.HasBaseType("Ahk.Web.Data.Entities.SubmissionEvent");
+
+ b.Property("Conclusion")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasDiscriminator().HasValue("WorkflowRunEvent");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.AssignmentAcceptance", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Assignment", "Assignment")
+ .WithMany("Acceptances")
+ .HasForeignKey("AssignmentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Assignment");
+
+ b.Navigation("Course");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseGitHubConfig", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithOne("GitHubConfig")
+ .HasForeignKey("Ahk.Web.Data.Entities.CourseGitHubConfig", "CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseMembership", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany("Memberships")
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", "User")
+ .WithMany("CourseMemberships")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.CourseWebhookToken", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.GradeExercisePoint", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.GradeRecord", "GradeRecord")
+ .WithMany("Points")
+ .HasForeignKey("GradeRecordId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GradeRecord");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.Student", "Student")
+ .WithMany()
+ .HasForeignKey("StudentId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission")
+ .WithMany("Grades")
+ .HasForeignKey("SubmissionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+
+ b.Navigation("Student");
+
+ b.Navigation("Submission");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.Student", "Student")
+ .WithMany("Submissions")
+ .HasForeignKey("StudentId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.Navigation("Course");
+
+ b.Navigation("Student");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.SubmissionEvent", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.Course", "Course")
+ .WithMany()
+ .HasForeignKey("CourseId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.Submission", "Submission")
+ .WithMany("Events")
+ .HasForeignKey("SubmissionId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Course");
+
+ b.Navigation("Submission");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("Ahk.Web.Data.Entities.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.ApplicationUser", b =>
+ {
+ b.Navigation("CourseMemberships");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Assignment", b =>
+ {
+ b.Navigation("Acceptances");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Course", b =>
+ {
+ b.Navigation("GitHubConfig");
+
+ b.Navigation("Memberships");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.GradeRecord", b =>
+ {
+ b.Navigation("Points");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Student", b =>
+ {
+ b.Navigation("Submissions");
+ });
+
+ modelBuilder.Entity("Ahk.Web.Data.Entities.Submission", b =>
+ {
+ b.Navigation("Events");
+
+ b.Navigation("Grades");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs
new file mode 100644
index 0000000..4841872
--- /dev/null
+++ b/ahk-backend/Ahk.Web.Data/Migrations/20260730062811_InitialCreate.cs
@@ -0,0 +1,703 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Ahk.Web.Data.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "AspNetRoles",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_AspNetRoles", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "AspNetUsers",
+ columns: table => new
+ {
+ Id = table.Column(type: "int", nullable: false)
+ .Annotation("SqlServer:Identity", "1, 1"),
+ DisplayName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NeptunCode = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true),
+ Affiliation = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ GitHubUsername = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true),
+ GitHubUserId = table.Column(type: "bigint", nullable: true),
+ UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true),
+ EmailConfirmed = table.Column(type: "bit", nullable: false),
+ PasswordHash = table.Column(type: "nvarchar(max)", nullable: true),
+ SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true),
+ ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true),
+ PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true),
+ PhoneNumberConfirmed = table.Column(type: "bit", nullable: false),
+ TwoFactorEnabled = table.Column(type: "bit", nullable: false),
+ LockoutEnd = table.Column(type: "datetimeoffset", nullable: true),
+ LockoutEnabled = table.Column(type: "bit", nullable: false),
+ AccessFailedCount = table.Column