diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..abcb6e817
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,65 @@
+# Node
+node_modules/
+npm-debug.log
+yarn-error.log
+
+# Build outputs
+dist/
+build/
+target/
+
+# IDEs
+.idea/
+.vscode/
+*.swp
+*.swo
+*.iml
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Environment files
+.env
+.env.local
+
+# Git
+.git/
+
+# Frontend specific
+frontend/node_modules/
+frontend/dist/
+frontend/.angular/
+frontend/playwright-report/
+frontend/test-results/
+frontend/e2e-screenshots/
+
+# Backend specific
+backend/target/
+backend/bin/
+backend/.settings/
+backend/.classpath/
+backend/.project/
+backend/*.iml
+
+# Test Client specific
+test-client/target/
+test-client/bin/
+test-client/*.iml
+
+# Android specific (to keep the root context clean)
+android/app/build/
+android/.gradle/
+android/local.properties
+android/*.iml
+
+# Miscellaneous
+tmp/
+*.log
+build_all.txt
+build_output.txt
+build_tests.txt
+build_tests_2.txt
+final_build_output.txt
+sonar-issues-v2.json
+test-results/
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..9128c4e01
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,96 @@
+# .env.example - Template for local environment variables
+# Copy this file to .env and replace with real values if available
+
+# Note: The values set in .env are also used by scripts/check-secrets.ps1
+# to prevent accidental commits of these specific secrets.
+
+# IPStack API Configuration
+IPSTACK_API_KEY=your_ipstack_api_key
+IPSTACK_API_URL=http://api.ipstack.com/
+
+# SonarCloud Token for static code analysis
+SONAR_TOKEN=your_sonar_token
+
+# Google reCAPTCHA Enterprise
+# Set RECAPTCHA_X_SECRET_KEY to 'disabled' to skip verification during development
+
+# Config 1: localhost-score (Enterprise)
+RECAPTCHA_1_SITE_KEY=your_recaptcha_1_site_key
+RECAPTCHA_1_SECRET_KEY=disabled
+RECAPTCHA_1_PROJECT_ID=your_recaptcha_1_project_id
+RECAPTCHA_1_API_KEY=your_recaptcha_1_api_key
+
+# Config 2: localhost-visible (Legacy v2)
+RECAPTCHA_2_SITE_KEY=your_recaptcha_2_site_key
+RECAPTCHA_2_SECRET_KEY=disabled
+RECAPTCHA_2_PROJECT_ID=your_recaptcha_2_project_id
+RECAPTCHA_2_API_KEY=your_recaptcha_2_api_key
+
+# Config 3: productive setup fargate (Enterprise)
+RECAPTCHA_3_SITE_KEY=your_recaptcha_3_site_key
+# RECAPTCHA_3_SECRET_KEY=disabled
+RECAPTCHA_3_SECRET_KEY=enabled
+RECAPTCHA_3_PROJECT_ID=your_recaptcha_3_project_id
+RECAPTCHA_3_API_KEY=your_recaptcha_3_api_key
+
+# Config 4: productive setup fargate score (Enterprise)
+RECAPTCHA_4_SITE_KEY=your_recaptcha_4_site_key
+RECAPTCHA_4_SECRET_KEY=your_recaptcha_4_secret_key
+RECAPTCHA_4_PROJECT_ID=your_recaptcha_4_project_id
+RECAPTCHA_4_API_KEY=your_recaptcha_4_api_key
+
+# Default Config (1 for localhost, 3 for fargate, 4 for fargate score)
+RECAPTCHA_DEFAULT_CONFIG=2
+
+# Email Configuration
+# Default: Local Mailpit/MailHog (http://localhost:8025)
+SPRING_MAIL_HOST=localhost
+SPRING_MAIL_PORT=1025
+SPRING_MAIL_AUTH=false
+SPRING_MAIL_STARTTLS=false
+
+# AWS SES Configuration (Set these to use real SES in prod or local testing)
+SES_USERNAME=your_ses_smtp_username
+SES_PASSWORD=your_ses_smtp_password
+SES_FROM=noreply@example.com
+
+# App Configuration
+# Optional: Set APP_BASE_URL to override the defaults:
+# - Local IDE: Defaults to http://localhost:4200
+# - Local Docker: Defaults to http://localhost
+# - AWS Fargate: Set in task definition (https://goodone.ch)
+# APP_BASE_URL=http://localhost:4200
+
+# JWT Secret for local development
+JWT_SECRET=defaultSecretKeyWithAtLeast32CharactersLongForSecurity
+
+# Landing Page Message
+LANDING_MESSAGE_MODE=OFF
+LANDING_MESSAGE_EN=
+LANDING_MESSAGE_DE_CH=
+
+# Vulnerability Database API Key
+NVD_API_KEY=your_nvd_api_key
+
+# AWS Infrastructure Identifiers (Used for deployment scripts)
+AWS_ACCOUNT_ID=your_aws_account_id
+VPC_ID=your_vpc_id
+EFS_ID=your_efs_id
+EFS_AP_ID=your_efs_access_point_id
+EFS_SG_ID=your_efs_sg_id
+TARGET_GROUP_ARN=your_target_group_arn
+
+# Default User Passwords and Emails
+ADMIN_PASSWORD=admin123
+USER_PASSWORD=user123
+ADMIN_READ_PASSWORD=admin123
+ADMIN_EMAIL=admin@example.com
+USER_EMAIL=user@example.com
+ADMIN_READ_EMAIL=admin-read@example.com
+
+# E2E Bypass Secret
+E2E_BYPASS_SECRET=your_e2e_bypass_secret
+
+# Database Configuration
+H2_USERNAME=sa
+H2_PASSWORD=
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..4ae8443f3
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,47 @@
+## UX / UI Change PR
+
+### Scope
+- [ ] Dark mode
+- [ ] Mobile layout
+- [ ] Tables/logs/admin views
+- [ ] Tasks/list layouts
+- [ ] Other:
+
+### Acceptance criteria (must be explicit)
+1.
+2.
+3.
+
+### What changed (systemic first)
+- Tokens/theme:
+- Shared components/styles:
+- Component-specific changes (only if necessary):
+
+### Screens / viewports verified
+- [ ] Mobile 375×667 light
+- [ ] Mobile 375×667 dark
+- [ ] Desktop 1440×900 light
+- [ ] Desktop 1440×900 dark
+
+### UX Regression Checklist (must all be ✅)
+**Theme & contrast**
+- [ ] Action icons visible in dark mode (incl. `mat-icon-button` + destructive actions)
+- [ ] Menus/dialogs/selects have readable contrast in dark mode
+- [ ] Focus ring visible (keyboard navigation)
+
+**Mobile space & layout**
+- [ ] Header density acceptable (content above the fold on 375×667)
+- [ ] No unintended horizontal scrolling on mobile
+
+**Responsive patterns**
+- [ ] Tables on mobile: cards or usable scroll (no clipped headers)
+- [ ] Toolbars/filters don’t create orphan controls / accidental wraps
+- [ ] Titles don’t break mid-word; truncation/wrapping intentional
+
+**Maintainability**
+- [ ] No new hardcoded hex colors (unless added to tokens)
+- [ ] No new `!important` (or justified below)
+
+### Justifications (required if any)
+- New `!important` added because:
+- Component-specific workaround added because:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..078a8c30c
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,114 @@
+name: Build & Test
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+ branches: [ main, master ]
+
+jobs:
+ backend-test:
+ name: Backend Test
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: pgvector/pgvector:pg17
+ env:
+ POSTGRES_DB: angularai
+ POSTGRES_USER: admin
+ POSTGRES_PASSWORD: admin
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Run Backend Tests
+ run: mvn -pl backend verify -DskipTests=false
+ env:
+ GIT_SHA: ${{ github.sha }}
+ BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.updated_at }}
+ NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+ SPRING_PROFILES_ACTIVE: postgres,dev,mock,test
+ SPRING_DATASOURCE_URL: jdbc:postgresql://127.0.0.1:5432/angularai?stringtype=unspecified¤tSchema=app,public
+ SPRING_DATASOURCE_USERNAME: admin
+ SPRING_DATASOURCE_PASSWORD: admin
+ AI_QUICK_ADD_PROVIDER: mock
+ AI_ARCHITECTURE_PROVIDER: mock
+ AI_EMBEDDING_PROVIDER: mock
+
+ frontend-test:
+ name: Frontend Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 21 (for backend in Playwright)
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install dependencies
+ working-directory: frontend
+ run: npm ci --legacy-peer-deps
+
+ - name: Run Lint
+ working-directory: frontend
+ run: npm run lint
+
+ - name: Run Frontend Unit Tests
+ working-directory: frontend
+ run: npm run test
+
+ commit-lint:
+ name: Commit Lint
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Install commitlint
+ run: npm install --save-dev @commitlint/config-conventional @commitlint/cli
+ - name: Validate PR commits
+ run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
+
+ sonar:
+ name: SonarCloud
+ runs-on: ubuntu-latest
+ needs: [ backend-test, frontend-test ]
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: SonarCloud Scan
+ uses: SonarSource/sonarcloud-github-action@v2
+ env:
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml
new file mode 100644
index 000000000..0caca3ebd
--- /dev/null
+++ b/.github/workflows/code-review.yml
@@ -0,0 +1,449 @@
+name: Code Review
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+on:
+ workflow_dispatch: # Allows manual trigger from Actions tab
+ push:
+ branches: [ "main", "master" ]
+ pull_request:
+ branches: [ "main", "master" ]
+ schedule:
+ - cron: '30 0 * * 1' # Every Monday at 00:30 UTC
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ backend: ${{ steps.filter.outputs.backend }}
+ frontend: ${{ steps.filter.outputs.frontend }}
+ docker: ${{ steps.filter.outputs.docker }}
+ scripts: ${{ steps.filter.outputs.scripts }}
+ deploy: ${{ steps.filter.outputs.deploy }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dorny/paths-filter@v3
+ id: filter
+ with:
+ filters: |
+ backend:
+ - 'backend/**'
+ - 'pom.xml'
+ frontend:
+ - 'frontend/**'
+ docker:
+ - 'Dockerfile'
+ - 'docker-compose.yml'
+ - '.github/workflows/code-review.yml'
+ scripts:
+ - 'scripts/**'
+ deploy:
+ - 'deploy/**'
+
+ qodana:
+ name: Qodana Code Review
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.backend == 'true' ||
+ needs.changes.outputs.frontend == 'true' ||
+ needs.changes.outputs.scripts == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ checks: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Qodana Scan
+ uses: JetBrains/qodana-action@v2024.3
+ env:
+ QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} # Required for Qodana Cloud reports
+ with:
+ args: --fail-threshold,0
+
+ codeql:
+ name: GitHub CodeQL Analysis
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.backend == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Initialize CodeQL (Java)
+ uses: github/codeql-action/init@v3
+ with:
+ languages: java-kotlin
+ queries: security-extended,security-and-quality
+ build-mode: manual
+
+ - name: Build Java
+ run: mvn clean compile -pl backend -DskipTests
+
+ - name: Perform CodeQL Analysis (Java)
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:java-kotlin"
+
+ codeql-js:
+ name: GitHub CodeQL Analysis (JS/TS)
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.frontend == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Initialize CodeQL (JS/TS)
+ uses: github/codeql-action/init@v3
+ with:
+ languages: javascript-typescript
+ queries: security-extended,security-and-quality
+
+ - name: Perform CodeQL Analysis (JS/TS)
+ uses: github/codeql-action/analyze@v3
+ with:
+ category: "/language:javascript-typescript"
+
+ sonarcloud:
+ name: SonarCloud Scan
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.backend == 'true' ||
+ needs.changes.outputs.frontend == 'true'
+ runs-on: ubuntu-latest
+ env:
+ NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ java-package: jdk
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Cache SonarCloud packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.sonar/cache
+ key: ${{ runner.os }}-sonar
+ restore-keys: ${{ runner.os }}-sonar
+
+ - name: Cache Maven packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2
+ key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
+ restore-keys: ${{ runner.os }}-m2
+
+ - name: Cache OWASP Dependency-Check DB
+ uses: actions/cache@v4
+ with:
+ path: data/dependency-check
+ key: ${{ runner.os }}-odc-${{ hashFiles('**/pom.xml') }}
+ restore-keys: ${{ runner.os }}-odc-
+
+ - name: Install Frontend Dependencies
+ working-directory: frontend
+ run: npm ci --legacy-peer-deps
+
+ - name: Run Frontend Tests
+ working-directory: frontend
+ run: npm test
+
+ - name: Build and analyze
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+ GIT_SHA: ${{ github.sha }}
+ BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.updated_at }}
+ NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+ run: |
+ mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \
+ -Dsonar.scm.disabled=true \
+ -Ddependency-check.skip=false
+
+ container-security:
+ name: Container Security Scan
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.docker == 'true' ||
+ needs.changes.outputs.backend == 'true' ||
+ needs.changes.outputs.frontend == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Build Docker image
+ run: |
+ docker build -t angularai-app:${{ github.sha }} -f deploy/dev/Dockerfile .
+
+ - name: Run Trivy vulnerability scanner (Image)
+ uses: aquasecurity/trivy-action@0.28.0
+ with:
+ image-ref: 'angularai-app:${{ github.sha }}'
+ format: 'table'
+ exit-code: '1' # Fail the build if vulnerabilities are found
+ ignore-unfixed: true
+ vuln-type: 'os,library'
+ severity: 'CRITICAL,HIGH'
+ version: 'latest'
+
+ - name: Generate Trivy SARIF (Image)
+ uses: aquasecurity/trivy-action@0.28.0
+ with:
+ image-ref: 'angularai-app:${{ github.sha }}'
+ format: 'sarif'
+ output: 'trivy-image.sarif.json'
+ ignore-unfixed: true
+ vuln-type: 'os,library'
+ severity: 'CRITICAL,HIGH'
+ version: 'latest'
+ continue-on-error: true
+
+ - name: Upload Trivy Image SARIF to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-image.sarif.json
+ category: trivy-image
+ continue-on-error: true
+
+ - name: Run Trivy misconfiguration scanner (Dockerfile)
+ uses: aquasecurity/trivy-action@0.28.0
+ with:
+ scan-type: 'config'
+ scan-ref: 'deploy/dev/Dockerfile'
+ hide-progress: false
+ format: 'table'
+ exit-code: '1'
+ severity: 'CRITICAL,HIGH'
+ skip-policy-update: false
+ version: 'latest'
+
+ - name: Generate Trivy SARIF (Config)
+ uses: aquasecurity/trivy-action@0.28.0
+ with:
+ scan-type: 'config'
+ scan-ref: 'deploy/dev/Dockerfile'
+ hide-progress: false
+ format: 'sarif'
+ output: 'trivy-config.sarif.json'
+ severity: 'CRITICAL,HIGH'
+ skip-policy-update: false
+ version: 'latest'
+ continue-on-error: true
+
+ - name: Upload Trivy Config SARIF to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-config.sarif.json
+ category: trivy-config
+ continue-on-error: true
+
+ snyk:
+ name: Snyk Security Scan
+ needs: changes
+ if: |
+ github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'schedule' ||
+ needs.changes.outputs.backend == 'true' ||
+ needs.changes.outputs.frontend == 'true' ||
+ needs.changes.outputs.scripts == 'true' ||
+ needs.changes.outputs.deploy == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install Snyk CLI
+ uses: snyk/actions/setup@0.4.0
+
+ - name: Snyk Open Source Scan (Backend)
+ run: snyk test --severity-threshold=high --sarif > snyk-backend.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Snyk Open Source Scan (Frontend)
+ working-directory: frontend
+ run: |
+ npm ci --legacy-peer-deps
+ snyk test --severity-threshold=high --sarif > ../snyk-frontend.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Snyk Open Source Scan (Scripts)
+ working-directory: scripts
+ run: snyk test --severity-threshold=high --sarif > ../snyk-scripts.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Snyk Infrastructure as Code Scan (K8s)
+ run: snyk iac test deploy/k8s/ --severity-threshold=high --sarif > snyk-deploy.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Snyk Code Scan
+ run: snyk code test --severity-threshold=high --sarif > snyk-code.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Build Docker image for Snyk
+ run: docker build -t angularai-app:snyk .
+
+ - name: Snyk Container Scan
+ run: snyk container test angularai-app:snyk --severity-threshold=high --sarif > snyk-container.sarif.json
+ continue-on-error: true
+ env:
+ SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
+
+ - name: Upload Snyk Results
+ uses: actions/upload-artifact@v4
+ with:
+ name: snyk-results
+ path: |
+ snyk-backend.sarif.json
+ snyk-frontend.sarif.json
+ snyk-scripts.sarif.json
+ snyk-deploy.sarif.json
+ snyk-code.sarif.json
+ snyk-container.sarif.json
+
+ - name: Generate Security Summary
+ if: always()
+ run: |
+ echo "### Security Scan Summary" >> $GITHUB_STEP_SUMMARY
+ echo "#### Snyk Open Source (Backend)" >> $GITHUB_STEP_SUMMARY
+ if [ -f snyk-backend.sarif.json ]; then
+ COUNT=$(grep -c "ruleId" snyk-backend.sarif.json || echo "0")
+ echo "Issues found: $COUNT" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Scan failed or not run" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "#### Snyk Open Source (Frontend)" >> $GITHUB_STEP_SUMMARY
+ if [ -f snyk-frontend.sarif.json ]; then
+ COUNT=$(grep -c "ruleId" snyk-frontend.sarif.json || echo "0")
+ echo "Issues found: $COUNT" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Scan failed or not run" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "#### Snyk Code" >> $GITHUB_STEP_SUMMARY
+ if [ -f snyk-code.sarif.json ]; then
+ COUNT=$(grep -c "ruleId" snyk-code.sarif.json || echo "0")
+ echo "Issues found: $COUNT" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Scan failed or not run" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Upload Snyk Open Source (Backend) to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-backend.sarif.json
+ category: snyk-opensource-backend
+ continue-on-error: true
+
+ - name: Upload Snyk Open Source (Frontend) to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-frontend.sarif.json
+ category: snyk-opensource-frontend
+ continue-on-error: true
+
+ - name: Upload Snyk Open Source (Scripts) to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-scripts.sarif.json
+ category: snyk-opensource-scripts
+ continue-on-error: true
+
+ - name: Upload Snyk Infrastructure as Code (K8s) to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-deploy.sarif.json
+ category: snyk-iac
+ continue-on-error: true
+
+ - name: Upload Snyk Code to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-code.sarif.json
+ category: snyk-code
+ continue-on-error: true
+
+ - name: Upload Snyk Container to GitHub Code Scanning
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: snyk-container.sarif.json
+ category: snyk-container
+ continue-on-error: true
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..72cc67ca9
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,119 @@
+name: CD to Demo Environment
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ environment:
+ description: 'Environment to deploy to'
+ required: true
+ default: 'demo'
+ type: choice
+ options:
+ - demo
+
+jobs:
+ deploy-demo:
+ name: Deploy to Demo
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
+ environment: demo
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'maven'
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
+ aws-region: eu-central-1
+
+ - name: Validate Secrets Manager JSON
+ env:
+ AWS_PAGER: ""
+ SECRET_ID: goodone-config
+ run: |
+ echo "Fetching secret $SECRET_ID from AWS..."
+ SECRET_VALUE=$(aws secretsmanager get-secret-value --secret-id $SECRET_ID --query 'SecretString' --output text)
+
+ # Check if it's valid JSON
+ if ! echo "$SECRET_VALUE" | jq empty; then
+ echo "::error::Secret $SECRET_ID is NOT a valid JSON object. Deployment aborted."
+ echo "This usually happens when updating secrets via PowerShell without proper escaping."
+ exit 1
+ fi
+
+ # Check for critical keys and their properties (min length for JWT_SECRET)
+ JWT_SECRET_LEN=$(echo "$SECRET_VALUE" | jq -r '.JWT_SECRET // "" | length')
+ if [ "$JWT_SECRET_LEN" -lt 32 ]; then
+ echo "::error::JWT_SECRET in $SECRET_ID is missing or too short (found $JWT_SECRET_LEN chars, minimum 32 required for demo/prod)."
+ exit 1
+ fi
+
+ echo "Secret $SECRET_ID validation successful: Valid JSON and secure JWT_SECRET found."
+
+ - name: Login to Amazon ECR
+ id: login-ecr
+ uses: aws-actions/amazon-ecr-login@v2
+
+ - name: Extract version
+ id: vars
+ run: |
+ if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
+ VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
+ else
+ VERSION=${GITHUB_REF#refs/tags/v}
+ fi
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Build, tag, and push image to Amazon ECR
+ env:
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+ ECR_REPOSITORY: angularai-app
+ IMAGE_TAG: ${{ steps.vars.outputs.VERSION }}
+ NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+ run: |
+ # Use NVD_API_KEY if available as a secret
+ if [ -n "$NVD_API_KEY" ]; then
+ echo "$NVD_API_KEY" > nvd_api_key.txt
+ docker build --secret id=NVD_API_KEY,src=nvd_api_key.txt -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f deploy/dev/Dockerfile .
+ rm nvd_api_key.txt
+ else
+ docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f deploy/dev/Dockerfile .
+ fi
+ docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
+ docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
+
+ - name: Update ECS service
+ env:
+ AWS_PAGER: ""
+ CLUSTER_NAME: angular-boot
+ SERVICE_NAME: angularai-backend-test-service
+ TASK_DEF_FILE: deploy/aws/backend-test-task-definition.json
+ VERSION: ${{ steps.vars.outputs.VERSION }}
+ ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
+ run: |
+ # Read the task definition and update the image
+ NEW_TASK_DEF=$(cat $TASK_DEF_FILE | jq --arg IMAGE "$ECR_REGISTRY/angularai-app:$VERSION" '.containerDefinitions[0].image = $IMAGE')
+
+ # Remove fields not allowed in register-task-definition
+ FINAL_TASK_DEF=$(echo $NEW_TASK_DEF | jq 'del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
+
+ # Register new task definition
+ NEW_TASK_DEF_ARN=$(aws ecs register-task-definition --cli-input-json "$FINAL_TASK_DEF" --query 'taskDefinition.taskDefinitionArn' --output text)
+
+ # Update service
+ aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --task-definition $NEW_TASK_DEF_ARN --desired-count 1 --force-new-deployment --deployment-configuration "maximumPercent=100,minimumHealthyPercent=0"
+
+ # Wait for stability
+ aws ecs wait services-stable --cluster $CLUSTER_NAME --services $SERVICE_NAME
diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml
new file mode 100644
index 000000000..4d9315f11
--- /dev/null
+++ b/.github/workflows/docs-validation.yml
@@ -0,0 +1,17 @@
+name: Documentation Validation
+
+on:
+ pull_request:
+ push:
+ branches: [ main ]
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Validate Junie Tasks
+ shell: pwsh
+ run: ./scripts/validate-junie-tasks.ps1
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 000000000..3b9fdbfc4
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,165 @@
+name: Playwright UX
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+on:
+ pull_request:
+ branches: [ main, master ]
+ push:
+ branches: [ main, master ]
+ workflow_dispatch:
+ inputs:
+ run_docs:
+ description: "Also run screenshot generator (ux-review-docs.spec.ts)"
+ required: false
+ default: "false"
+
+jobs:
+ ux-e2e:
+ name: UX E2E Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build and Start Application (Docker)
+ run: |
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml build
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml up -d
+ env:
+ E2E_BYPASS_SECRET: "ci-secret"
+ JWT_SECRET: "ci-jwt-secret-at-least-32-chars-long"
+ ADMIN_PASSWORD: "admin123"
+ USER_PASSWORD: "user123"
+ ADMIN_READ_PASSWORD: "admin123"
+ USER2_PASSWORD: "user123"
+ ADMIN_EMAIL: "admin@example.com"
+ USER_EMAIL: "user@example.com"
+ GIT_SHA: ${{ github.sha }}
+ BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.updated_at }}
+
+ - name: Install dependencies
+ working-directory: frontend
+ run: npm ci --legacy-peer-deps
+
+ - name: Install Playwright browsers
+ working-directory: frontend
+ run: npx playwright install --with-deps
+
+ - name: Wait for backend
+ run: |
+ npm install -g wait-on
+ wait-on http-get://localhost:8080/api/system/info --timeout 900000
+
+ - name: Run Playwright E2E tests
+ working-directory: frontend
+ run: npx playwright test e2e/ux-guardrails.spec.ts e2e/auth-flow.spec.ts e2e/tasks-ux.spec.ts e2e/quick-add-ai.spec.ts e2e/architecture-page.spec.ts --reporter=line
+ env:
+ PLAYWRIGHT_TEST_BASE_URL: http://localhost:8080
+ USE_PLAYWRIGHT_WEB_SERVER: 'false'
+
+ - name: Upload Playwright report and trace
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-e2e
+ path: |
+ frontend/playwright-report/
+ frontend/test-results/
+
+ - name: Upload Container Logs
+ if: always()
+ run: |
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml logs app > app-container.log
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml logs postgres > postgres-container.log
+
+ - name: Upload Logs as Artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: container-logs
+ path: "*.log"
+
+ - name: Stop Application
+ if: always()
+ run: docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml down
+
+ ux-docs:
+ name: UX Screenshot Docs (manual)
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ needs: ux-e2e
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_docs == 'true' }}
+
+ defaults:
+ run:
+ working-directory: frontend
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Build and Start Application (Docker)
+ run: |
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml build
+ docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml up -d
+ env:
+ E2E_BYPASS_SECRET: "ci-secret"
+ JWT_SECRET: "ci-jwt-secret-at-least-32-chars-long"
+ ADMIN_PASSWORD: "admin123"
+ USER_PASSWORD: "user123"
+ ADMIN_READ_PASSWORD: "admin123"
+ USER2_PASSWORD: "user123"
+ ADMIN_EMAIL: "admin@example.com"
+ USER_EMAIL: "user@example.com"
+ GIT_SHA: ${{ github.sha }}
+ BUILD_TIME: ${{ github.event.head_commit.timestamp || github.event.pull_request.updated_at || github.event.repository.updated_at }}
+
+ - name: Install dependencies
+ run: npm ci --legacy-peer-deps
+
+ - name: Install Playwright browsers
+ run: npx playwright install --with-deps
+
+ - name: Wait for backend
+ run: |
+ npm install -g wait-on
+ wait-on http-get://localhost:8080/api/system/info --timeout 900000
+
+ - name: Run UX review screenshot generator
+ run: npx playwright test e2e/ux-review-docs.spec.ts --reporter=line
+ env:
+ PLAYWRIGHT_TEST_BASE_URL: http://localhost:8080
+ PLAYWRIGHT_BASE_URL: http://localhost:8080
+ USE_PLAYWRIGHT_WEB_SERVER: 'false'
+
+ - name: Upload UX assets + report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: ux-review-assets-and-report
+ path: |
+ doc/ux-review/assets/
+ frontend/playwright-report/
+
+ - name: Stop Application
+ if: always()
+ run: docker compose -f deploy/dev/docker-compose.yml -f deploy/dev/docker-compose.ci.yml down
diff --git a/.github/workflows/validate-tasks.yml b/.github/workflows/validate-tasks.yml
new file mode 100644
index 000000000..331f50f53
--- /dev/null
+++ b/.github/workflows/validate-tasks.yml
@@ -0,0 +1,24 @@
+name: Validate Junie Task Files
+
+on:
+ pull_request:
+ push:
+ branches: [ main ]
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install dependencies
+ run: pip install pyyaml
+
+ - name: Validate task files
+ run: python scripts/validate_tasks.py doc/knowledge/junie-tasks
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..89104314d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,35 @@
+/.idea/
+/android/build/
+/android/.idea/
+/android/.gradle/
+/android/app/build/
+/test-client/target/
+/doc/ai/presentation/tmp/
+/tmp/
+/frontent-proposal/
+/target/sonar/
+/frontend/.nyc_output/
+/doc/ai/presentation/old/
+/doc/ai/presentation/tmp/
+.env
+/data/
+/doc/ai/internal/
+/presentation/target/
+/target/
+/scripts/update-aws-secret.ps1
+/secret_utf8.json
+/monitoring-server/target/
+/backend/data/
+/frontend/data/
+*.db
+*.trace.db
+/presentation/presentations/Iteration-2/old/
+/doc/normalized-tasks/
+/doc/tasks-normalized/
+/releases_test/
+/doc/doc/
+/node_modules/
+/frontend/node_modules/
+/sonar/.sonar-export/issues/
+/sonar/.github-code-scanning-export/alerts/
+/qodana-results/
diff --git a/.husky/_/husky.sh b/.husky/_/husky.sh
new file mode 100644
index 000000000..9ef1d714e
--- /dev/null
+++ b/.husky/_/husky.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+if [ -z "$husky_skip_init" ]; then
+ readonly hook_name="$(basename -- "$0")"
+ if [ "$husky_skip_hooks" = "1" ]; then
+ echo "husky - skip $hook_name hook"
+ exit 0
+ fi
+ readonly husky_script="$(dirname -- "$0")/_/husky.sh"
+ if [ -f "$husky_script" ]; then
+ . "$husky_script"
+ fi
+fi
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 000000000..3ce5aeb82
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,5 @@
+#!/bin/sh
+. "$(dirname "$0")/_/husky.sh"
+
+# Run the secret leak check script
+pwsh -File ./scripts/check-secrets.ps1
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..ab1f4164e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 000000000..2fd8c414e
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 000000000..b901aa45c
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,83 @@
+
+
+
+
+ h2.unified
+ true
+ true
+ $PROJECT_DIR$/backend/src/main/resources/application.properties
+ org.h2.Driver
+ jdbc:h2:mem:testdb
+
+
+
+
+
+ $ProjectFileDir$
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/angularai
+
+
+
+
+
+ $ProjectFileDir$
+
+
+ h2.unified
+ true
+ org.h2.Driver
+ jdbc:h2:file:./data/testdb;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE
+
+
+
+
+
+ $ProjectFileDir$
+
+
+ h2.unified
+ true
+ true
+ $PROJECT_DIR$/backend/src/main/resources/application.properties
+ org.h2.Driver
+ jdbc:h2:./data/angularai;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE
+
+
+
+
+
+ $ProjectFileDir$
+
+
+ h2.unified
+ true
+ true
+ $PROJECT_DIR$/backend/src/main/resources/application-h2-file.properties
+ org.h2.Driver
+ jdbc:h2:./data/angularai-v2;DB_CLOSE_DELAY=-1;IFEXISTS=FALSE;FILE_LOCK=FS;LOCK_TIMEOUT=30000
+
+
+
+
+
+ $ProjectFileDir$
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/angularai
+
+
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/db-forest-config.xml b/.idea/db-forest-config.xml
new file mode 100644
index 000000000..cd7cfc288
--- /dev/null
+++ b/.idea/db-forest-config.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 000000000..565f0ad66
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 000000000..58e18f769
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 000000000..8cd55247a
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..94a25f7f4
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.junie/AI_Usage_Guidelines.md b/.junie/AI_Usage_Guidelines.md
new file mode 100644
index 000000000..ea385b4fa
--- /dev/null
+++ b/.junie/AI_Usage_Guidelines.md
@@ -0,0 +1,93 @@
+# AI Usage Guidelines for Software Development
+
+## Purpose
+Provide clear guidance on how AI tools may be used responsibly in software development.
+
+AI is treated as a **supporting tool**, not an autonomous decision-maker.
+
+---
+
+## Allowed Uses
+
+AI tools may be used for:
+- Code scaffolding and boilerplate
+- Documentation drafts
+- Test case generation
+- UX exploration and alternatives
+- Code review suggestions
+
+All outputs require human review.
+
+---
+
+## Disallowed Uses
+
+AI tools must NOT:
+- Deploy code automatically
+- Introduce secrets or credentials
+- Change architecture without review
+- Bypass security controls
+- Replace code ownership or accountability
+
+---
+
+## Required Guardrails
+
+When using AI:
+- Tasks must be scoped and explicit
+- Acceptance criteria must be defined
+- Changes must pass automated tests
+- Run all relevant tests before reporting a task as done
+- Sensitive data must not be shared
+- Outputs are reviewed like human contributions
+- **Task Logging in Markdown**: When a task is assigned via a dedicated Markdown file, follow the appropriate standard:
+ - **Normalized Tasks** (e.g., `AI-ARCH-*`, `SEC-*`): Strictly follow the **Normalized Markdown Format v1.0** as defined in `.junie/guidelines.md` and `doc/knowledge/junie-tasks/taskset-9/junie-task-format-guideline.md`.
+ - **Prototype/Other Tasks** (e.g., `doc/knowledge/junie-tasks/`): Use the template in `doc/knowledge/junie-tasks/md-prompt-log.md`.
+ - **CRITICAL**: Task logging is mandatory for every task assigned via .md file.
+ - **MANDATORY**: New entries must be added to the top of the log section (directly under the heading). Never overwrite or replace previous entries.
+ - **Normalized Task Entry Format**:
+ ```markdown
+ ### YYYY-MM-DD HH:MM
+ - Summary:
+ - Outcome:
+ - Open items:
+ - Evidence:
+ ```
+ - **Prototype Task Entry Format**:
+ ```markdown
+ Date: YYYY.MM.DD HH:MM
+ Summary: Initial completion of the task.
+ **DONE:**
+ Implementation details:
+ ```
+ - **Failed Acceptance Iterations** must be incremented whenever a failure is reported, and **never** decremented.
+ - Status, `iterations` count, and `updated` timestamp must be kept up to date.
+ - Updates to the file are sorted: the latest 'Date:' entry must be at the top of the logs section.
+ - If a prompt includes the keyword `-detail-`, `-full-`, `-verbose-`, `-detailed-`, or `-expand-`, provide full implementation details in the 'DONE' comment even for follow-up tasks.
+- Business logic takes precedence over tests: Never change the application's implementation or business logic solely to make tests pass. Instead, adjust the tests to reflect the actual business logic. If you believe a business logic change is truly necessary to improve testability or fix a bug, you MUST specifically ask for approval before proceeding.
+- **MANDATORY CHECKLIST BEFORE SUBMITTING**:
+ - [ ] Run all relevant tests.
+ - [ ] If a task was assigned via `.md` file, update it with `Date:`, `Summary:`, and `**DONE:**` (if first time).
+ - [ ] Ensure `Iterations` and `Status` are correct in the task `.md` file.
+ - [ ] Sort logs with the latest entry at the top.
+- Test Timeouts: All automated tests must complete within a reasonable timeframe. The maximum allowed timeout for any single test action, navigation, or assertion is 10 seconds. Avoid using broad waits like `networkidle` if they cause delays; prefer specific selector or state-based waits.
+- Avoid solutions that produce SonarCloud findings (e.g., when fixing log pollution, ensure the fix itself doesn't trigger new quality gate issues). Use positive inclusion patterns for instrumentation tools like JaCoCo when running on modern JDKs to avoid errors in system classes, and explicitly exclude dynamic proxy classes (e.g., Mockito, Hibernate, ByteBuddy) that might also cause instrumentation failures.
+
+### Email and Domain Usage
+- Use placeholder domains like `@example.com` in all public code, tests, documentation, and UI.
+- Real organization domains (e.g., production addresses) must never be committed to Git.
+- Exception: real addresses may be present only in untracked environment files (e.g., `.env`) that are not committed to the repository.
+
+---
+
+## Accountability
+
+- Engineers remain fully accountable for results
+- AI output is treated as untrusted input
+- Final decisions always rest with humans
+
+---
+
+## Key Principle
+
+> AI accelerates engineering — it does not replace engineering judgment.
diff --git a/.junie/frontend-style-guideline.md b/.junie/frontend-style-guideline.md
new file mode 100644
index 000000000..903618f29
--- /dev/null
+++ b/.junie/frontend-style-guideline.md
@@ -0,0 +1,137 @@
+# Frontend Style Guideline
+
+This document defines the standard UI patterns and styles for the AngularAI project to ensure consistency across all pages. Junie must follow these guidelines for all UI-related changes.
+
+## 1. Core Principles
+- **Consistency**: Use the same components, spacing, and layouts across all pages.
+- **Design Tokens**: Always use the CSS variables defined in `src/styles.css` (e.g., `--bg`, `--surface`, `--text`, `--brand`).
+- **Responsive**: Use Flexbox and Grid to ensure layouts work on both desktop and mobile.
+- **Dark Mode**: All components must look good in both light and dark themes using the provided design tokens.
+
+## 2. Layout & Spacing
+- **Page Container**: Use a div with class `page-container` (or specific component container like `dashboard-container`) with `padding: 24px`.
+- **Spacing Scale**: Always use the following spacing scale (defined in `theme-tokens.css`):
+ - `4px`: `--spacing-4` (Only for very tight internal adjustments)
+ - `8px`: `--spacing-8` (Small gaps, chip/button internal gaps)
+ - `16px`: `--spacing-16` (Standard gaps, card internal rhythm)
+ - `24px`: `--spacing-24` (Standard card padding, section rhythm)
+ - `32px`: `--spacing-32` (Page section spacing)
+ - `48px`: `--spacing-48` (Large page section spacing)
+- **Guidelines**:
+ - Page section spacing: Use `24px`/`32px`/`48px`.
+ - Card internal spacing: Use `16px`/`24px`.
+ - Chip/button gaps: Use `8px`/`16px`.
+ - Avoid arbitrary values like `13px`, `19px`, `27px` unless technically necessary.
+- **Grids**: Use `display: grid` with `gap: 24px` for dashboard-like layouts.
+
+## 3. Material Cards
+- **Standard Cards**: Use `` with `class="main-card"` for primary content areas.
+- **Surface Rules**:
+ - **Radius**: Use `--radius-card` (12px) for all app cards.
+ - **Shadow**: Use `--shadow-card` (subtle shadow-1) by default.
+ - **Padding**: Use `padding: var(--spacing-24)` for standard card content.
+- **Card Header Pattern**: Use the `.app-card-header` class for consistent title/action layout:
+ ```html
+
+ ```
+- **Summary Cards**: For KPIs/stats, use `class="summary-card interactive-card"`:
+ - Should have a `border-top: 4px solid var(--brand)`.
+ - Use `.stat-value` for large numbers (font-size: 2.8rem, font-weight: 800).
+- **Interactive Cards**: Cards that link to other pages should have `class="interactive-card"` for hover effects (translateY and increased shadow).
+
+## 4. Tables (Angular Material)
+- **Zebra Striping**: Apply zebra striping to all tables using:
+ ```css
+ tr:nth-child(even) { background-color: color-mix(in srgb, var(--surface-2) 70%, transparent); }
+ ```
+- **Hover Effect**: Rows should highlight on hover: `tr:hover { background-color: var(--brand-weak) !important; }`.
+- **Headers**: Use uppercase, font size (13px), bold, `var(--surface-2)` background, and subtle opacity (e.g., 0.7).
+- **Body Text**: Standard 14px font size. Use `var(--text)` with subtle opacity (e.g., 0.8) for secondary info instead of purely muted colors if they look too grey.
+- **Compact Tables**: Use `class="compact-table"` for dashboard widgets (smaller padding).
+
+## 5. Chips & Semantic Colors
+Use semantic chips for status and roles. Do not rely on hardcoded colors; use background-opacity patterns:
+- **Primary/Brand**: `background: rgba(63, 81, 181, 0.14); color: var(--brand);`
+- **Success/Green**: `background: rgba(76, 175, 80, 0.16); color: #2e7d32;`
+- **Warning/Orange**: `background: rgba(255, 152, 0, 0.16); color: #e65100;`
+- **Error/Red**: `background: rgba(211, 47, 47, 0.12); color: #c62828;`
+- **Neutral**: `background: color-mix(in srgb, var(--surface-2) 70%, transparent); color: var(--text-muted);`
+- **Chip Font**: Use 12px uppercase for chips to ensure legibility.
+
+## 6. Forms
+- **Appearance**: Always use `appearance="outline"` for `mat-form-field`.
+- **Layout**: Use a grid (e.g., `class="form-grid"`) for multiple fields.
+- **Actions**: Place action buttons at the bottom in a `class="form-actions"` div.
+- **Colors**: Use the default neutral background (`--surface`) for input fields. Avoid custom background colors that clash with the theme. Ensure primary text field containers (like `.mat-mdc-text-field-wrapper`) use the `--surface` token, while internal elements like the ripple (`.mdc-text-field__ripple`), notch outline (`.mdc-notched-outline`, including leading/notch/trailing segments and their `::before`/`::after` pseudo-elements), and focus overlay remain strictly transparent (using both `background` and `background-color: transparent !important`) to avoid visual artifacts like overlapping lines or double backgrounds. Specifically, to prevent a vertical line artifact at the edge of the notch, ensure `.mdc-notched-outline__notch` has `border-left: none !important` and `border-right: none !important`. Also, handle browser autofill styles by overriding `-webkit-autofill` with `-webkit-box-shadow: 0 0 0px 1000px var(--surface) inset`.
+
+## 7. Icons
+- Use Material Icons (``).
+- Use consistent icons for actions: `edit` (Edit), `delete` (Delete), `visibility` (View), `person_add` (Add User), `search` (Search/Filter).
+
+## 8. Typography
+- **Page Titles**: Use `` with `class="page-title"` in a `page-toolbar` (font-size: 24px, font-weight: 700).
+- **Muted Text**: Use `class="muted"` for secondary information (uses `--text-muted`).
+- **Weights**: Use `font-weight: 500` for emphasis in tables, `600` for card titles.
+
+## 9. Dark Mode & Material Components
+To ensure consistent UI behavior in dark mode, follow these specific technical rules:
+
+### 1. Button Hierarchy
+- **Primary**: Use `mat-flat-button` or `mat-raised-button` with `color="primary"`.
+- **Secondary**: Use `mat-stroked-button`.
+- **Tertiary**: Use `mat-button` (text-style / low-emphasis).
+- **AI Actions**: Always use the `psychology` icon (yellow) as a prefix.
+- **Destructive Actions**: Use `color="warn"` but keep it restrained.
+
+### 2. Button Styling
+- **Filled Variants Only**: Centralized dark-mode styling (e.g., brand background) should only apply to **filled** variants (`mat-flat-button`, `mat-raised-button`, `mat-unelevated-button`). Do NOT force backgrounds on text buttons (`mat-button`).
+- **Target MDC Variables**: Always override internal Material Design (MDC) variables to ensure style wins over defaults:
+ - `--mdc-filled-button-container-color`
+ - `--mdc-flat-button-container-color`
+ - `--mdc-protected-button-container-color`
+- **Solid Backgrounds for Disabled State**: Avoid using transparency for disabled primary buttons in dark mode, as they can blend into the dark surface and appear black. Use a **solid, opaque color** (e.g., a muted brand blue) to ensure visibility.
+
+### 3. High-Specificity Selectors
+- Material components use highly specific selectors. To guarantee overrides in dark mode, use aggressive selectors starting from `html body.theme-dark` and include both class-based and attribute-based selectors (e.g., `button[mat-flat-button][color="primary"]`).
+
+### 4. E2E Verification & UX Guardrails
+- **Guardrail Execution**: The full E2E test suite `frontend/e2e/ux-guardrails.spec.ts` MUST run successfully after any significant UI or layout change.
+- **Brightness Guardrails**: When verifying dark-mode colors in E2E tests, use a programmatic brightness check (Sum of R+G+B) with a high threshold (e.g., > 150) to ensure primary buttons are clearly colored and not near-black.
+- **Render Stability**: When capturing screenshots in Playwright after a theme toggle, always include an explicit wait (e.g., `1000ms`) to allow CSS transitions and Material re-rendering to settle.
+
+## 10. AI Marker & Visual Language
+- **AI Icon**: Always use the yellow head icon (`psychology`) as the primary AI marker.
+- **AI Actions**: AI-powered features should be clearly marked with the `psychology` icon.
+- **No Decoration**: Do not add decorative glowing teaser pills or ad-hoc animation effects to AI markers unless specified.
+
+## 11. CSS Debt Prevention
+- **No Inline Styles**: Avoid new inline `style="..."` attributes. Use CSS classes instead.
+- **No !important**: Avoid `!important` declarations. If strictly necessary (e.g., framework override), add a comment explaining why.
+- **No Local Overrides**: Reuse shared variables and utility classes instead of creating local CSS overrides in components.
+
+## 12. Page Composition Patterns
+- **Standard Page Structure**:
+ 1. `page-toolbar` (Title + Actions)
+ 2. `helper-text` (Optional description)
+ 3. `filter-section` (Optional)
+ 4. `content-area` (Usually a grid or a list of cards)
+- **Auth Page Pattern**: Branding Header (`app-brand-logo`) + Minimal Login/Register card.
+- **Dashboard Pattern**: Summary KPI cards at the top + Main content cards (e.g., Tasks, Retrospective) below.
+- **AI Feature Page Pattern**: Action toolbar with primary AI action + AI-annotated results (using `psychology` icon).
+
+## 13. UI Review Checklist
+- [ ] Spacing uses shared scale (8, 16, 24, 32, 48px).
+- [ ] No new inline styles or `!important` (unless justified).
+- [ ] Button hierarchy is respected (Primary/Secondary/Tertiary).
+- [ ] AI marker uses the yellow `psychology` icon.
+- [ ] Card styling matches shared surface rules (radius, shadow).
+- [ ] Page structure follows standard composition patterns.
+- [ ] Layout is stable on mobile (360px) and dark mode.
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
new file mode 100644
index 000000000..7f2ca10d7
--- /dev/null
+++ b/.junie/guidelines.md
@@ -0,0 +1,165 @@
+# Development Guidelines
+
+This document outlines the best practices and standards for the AngularAI project.
+
+## General Principles
+- **Modern Standards**: Use the latest stable versions of frameworks (Angular 21+, Spring Boot 4+). NEVER use deprecated methods, features, or syntax in any language (e.g., avoid `*ngIf` and `*ngFor` in Angular, use modern control flow instead).
+- **Consistency**: Follow existing naming conventions and project structure.
+- **Clean Code**: Remove all unused imports, variables, and commented-out code blocks. Every test case MUST have at least one explicit assertion (e.g., `expect`, `assert`, `assertEquals`). Avoid empty catch blocks; at least log the exception or add a comment explaining why it's ignored.
+- **Method Complexity**: Keep methods small and focused (Single Responsibility Principle). Avoid "Brain Methods" with high cyclomatic complexity (e.g., > 10).
+- **Communication**: If there are doubts about the implementation or if better ways are identified, stop the implementation and ask the user before continuing.
+- **RBAC Synchronization**: Frontend and backend Role-Based Access Control (RBAC) MUST always be in sync. Every protected UI route MUST have a corresponding protected REST endpoint with the same security policy (e.g., `ROLE_ADMIN`). This ensures "Defense in Depth" and prevents security bypasses via direct API access. This is a CRITICAL security requirement.
+- **Centralized Versioning**:
+ - The **Root `pom.xml`** is the single source of truth for the project version.
+ - All modules (Backend, Frontend, Android, Test Client) must share the same version.
+ - Use `.\scripts\sync-version.ps1` to propagate version changes from the root `pom.xml` to other files (package.json, build.gradle, deployment scripts, and documentation).
+- **Build Integrity**: Ensure the project builds successfully (`mvn clean install`) before submitting changes.
+- **Post‑Refactoring Build**: After every refactoring (no matter how small), the application MUST build successfully. Verify locally with:
+ - Backend+Frontend: `mvn clean install -DskipTests`
+ - Frontend only: `mvn clean install -pl frontend -DskipTests`
+ Any refactoring is incomplete until these builds pass.
+- **Automation & Scripts**:
+ - Always execute scripts and commands in a non-interactive mode.
+ - **WSL (Linux on Windows)**: Prefer WSL (Ubuntu 2) for complex file manipulation, JSON processing (`jq`), and text editing (`sed`, `awk`). It avoids PowerShell's quoting and escaping complexities. Project files are accessible via `/mnt/c/`. If a required package is missing in WSL, Junie MUST ask the user to install it and provide the necessary command(s).
+ - When deleting directories in PowerShell, always use the `-Recurse` and `-Force` parameters (e.g., `Remove-Item -Path "path/to/dir" -Recurse -Force`) to avoid user interaction prompts.
+ - If a command prompts for confirmation (e.g., "Terminate batch job (Y/N)?"), always provide the appropriate response (e.g., `Y`) instead of boolean values like `true` to avoid stalling.
+ - **AWS CLI**: Always disable the AWS CLI pager to avoid interactive `--more--` prompts.
+ - In scripts: set the environment variable `AWS_PAGER=""`.
+ - In manual commands or ad-hoc tool calls: append the `--no-cli-pager` flag to the `aws` command.
+- **Testing**: Maintain high test coverage (>80%) for both frontend and backend at all times.
+- **UX Guardrails**: Always execute and ensure all tests pass in `frontend/e2e/ux-guardrails.spec.ts` after any significant UI or layout changes. After UX changes, always run Playwright UX guardrails and attach screenshots to the submission.
+- **Minimal Refactoring**: Do **not** refactor unrelated code. Follow existing architecture and naming conventions.
+- **Problem Solving**: If acceptance criteria cannot be met, explain why and propose a minimal alternative.
+- **Definition of Done**: Before submitting, ensure:
+ - CI pipeline is green.
+ - Demo reset works reliably.
+ - Mobile layout is stable at 360px.
+ - No dev-only features are exposed.
+ - Application can be demoed repeatedly without manual fixes.
+ - Playwright UX guardrails are successful (`npx playwright test e2e/ux-guardrails.spec.ts`).
+- **Docker First**: Ensure all changes are compatible with the Docker-based deployment.
+- **Language**: Always communicate in English for all interactions, thoughts, and documentation, unless explicitly requested otherwise by the user.
+- **Translations**: Always provide translations for both supported languages (English `en.json` and German `de-ch.json`) when adding or modifying UI text. The `ch` part of the `de-ch` locale MUST be respected: never use the letter 'ß' (Eszett) in any German translations (e.g. use 'ss' instead).
+- **Documentation**:
+ - Use Bash scripts instead of PowerShell in all documentation (`.md` files) to ensure cross-platform compatibility and consistency.
+ - **Testing Instructions**: Always add clear testing instructions (manual and automated) to the first `DONE` comment in the task `.md` file. This is a CRITICAL requirement.
+ - **Task Logging**: Always preserve the full history of log entries in task `.md` files. New entries must be added to the top of the log section. NEVER overwrite or replace previous entries. This is a MANDATORY requirement.
+ - **Markdown Code Blocks**: Use the `bash` or `powershell` language tags for terminal commands to ensure the 'Run' icon is visible in the editor. Refer to the IntelliJ configuration to enable this for `powershell`.
+ - **Architecture Decision Records (ADR)**:
+ - Junie MUST proactively identify when changes or refactorings constitute a significant architectural decision.
+ - If such a decision is identified, Junie MUST add a new ADR to `doc/knowledge/adrs/adr-full-set.md` following the existing format and numbering.
+ - Junie MUST also update the ADR index at the beginning of the file.
+ - **One Command per Block**: Avoid using 'or' statements or multiple alternative options within a single code block. If multiple options exist, create a separate code block for each one.
+ - **Presentation Content**: When editing `presentation/presentations/presentation-slides.md`, stay as close as possible to a Pandoc-compatible format. While additional layout directives for the custom Python pipeline are accepted, maintaining basic Pandoc compatibility ensures a functional fallback if the advanced pipeline fails.
+ - **Task Management (Normalized Markdown Format v1.0)**:
+ - All normalized markdown task files (e.g., `AI-ARCH-*`, `SEC-*`) must strictly follow the mandatory structure defined in `doc/knowledge/junie-tasks/taskset-9/junie-task-format-guideline.md`.
+ - **Mandatory Structure**:
+ 1. YAML Frontmatter (bounded by `---`)
+ 2. `## Goal`
+ 3. `## Scope`
+ 4. `## Acceptance Criteria`
+ 5. `## Junie Log`
+ 6. `## Verification`
+ 7. `## Links`
+ 8. `## Notes (optional)`
+ - **YAML Frontmatter**: Must include `key`, `title`, `taskset`, `priority`, `status`, `created`, `updated`, and `iterations`.
+ - `status` must be one of: `TODO`, `IN_PROGRESS`, `DONE`, `BLOCKED`.
+ - `priority` must be one of: `P0`, `P1`, `P2`.
+ - **Junie Log Entry Format**:
+ - Must start with `### YYYY-MM-DD HH:MM`.
+ - Must include: `- Summary:`, `- Outcome:`, `- Open items:`, `- Evidence:`.
+ - **Junie Log Rules**:
+ - Always append new log entries under `## Junie Log` (latest entries at the top).
+ - Never remove or rewrite previous Junie Log history.
+ - If status changes, update YAML (`status`, `updated`, `iterations`) and add a log entry explaining the change.
+ - **Restrictions**:
+ - Never change file structure, reorder, or rename required sections.
+ - Never modify unrelated sections when adding a log entry.
+ - Never invent new metadata fields or move metadata into the markdown body.
+ - Never add emojis or decorative formatting.
+ - If an instruction conflicts with this guideline, refuse and explain why.
+ - **Chat Summarization Workaround**:
+ - If the Junie AI JetBrains plugin does not provide a manual rename option, ensure the full task filename (without extension) is mentioned prominently in the initial message of a task. This triggers the plugin's automatic summarizer to include the complete Task ID and filename in the chat history summary.
+
+## Backend Development (Spring Boot)
+
+### 1. Architecture
+- **Controllers**: Use RESTful controllers in `ch.goodone.angularai.backend.controller`.
+- **Models**: Use JPA entities in `ch.goodone.angularai.backend.model`. Always create a Flyway migration script (in `backend/src/main/resources/db/migration/`) whenever a JPA entity is created or modified to ensure the database schema stays in sync.
+- **Idempotent Migrations**: All Flyway migration scripts MUST be idempotent. Use `CREATE TABLE IF NOT EXISTS`, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, and similar constructs. This is critical for Fargate deployments where tasks may restart or run concurrently during rollout.
+- **Repositories**: Use Spring Data JPA repositories in `ch.goodone.angularai.backend.repository`. Avoid using direct SQL statements (e.g., via `JdbcTemplate`) in Java code. Use JPA or Spring Data JPA abstractions for all database operations.
+- **DTOs**: Use DTOs for API requests and responses to avoid leaking internal entity structures. Implement `fromEntity()` static methods in DTOs for centralized mapping.
+
+### 2. Best Practices
+- **Security**:
+ - Use `@MockitoBean` instead of `@MockBean` in tests (Spring Boot 4 requirement).
+ - **No Hardcoded Keys**: Never include sensitive API keys, tokens, or credentials in the source code, configuration files (e.g., `application.properties`), or IDE settings committed to Git (e.g., `.idea/workspace.xml`). Use environment variables and placeholders (e.g., `${MY_API_KEY}`) instead.
+ - **Log Security**: NEVER log user-provided data (e.g., request parameters, headers, paths) directly without sanitization to prevent Log Injection. Use placeholders and only log trusted or sanitized values. NEVER log sensitive information like passwords, session tokens, or PII.
+- **Type Safety**: Avoid using generic wildcard types like `ResponseEntity>` or `ResponseEntity` in controllers. Always use specific DTOs or `ResponseEntity` to maintain clear API contracts and avoid Sonar issues.
+- **Validation**: Use `@Column` annotations for explicit database mapping. Use unique constraints where appropriate (e.g., login, email).
+- **JSON Handling**: Use `tools.jackson.databind.ObjectMapper` for JSON processing in tests.
+- **Auditing**: All major user interactions (login, registration, password changes, etc.) and significant system events must be logged to the `ActionLogService` to ensure a robust audit trail.
+- **Date/Time**: Use `LocalDate` for dates. Use `@JsonFormat(pattern = "yyyy-MM-dd")` for DTO date fields.
+- **Role-Based Access Control**: Enforce security in `SecurityConfig` and use the `Role` enum.
+
+### 3. Testing
+- Use JUnit 5 and MockMvc for controller testing.
+- Always include Spring Security in the test context if the endpoint is protected.
+- Keep tests isolated from the database using a `test` profile if needed.
+
+## Frontend Development (Angular)
+
+### 1. Architecture
+- **Templates & Styles**: ALWAYS extract templates and styles into separate `.html` and `.css` files. For very small components (typically < 60 lines total for the `.ts` file), inlining templates and styles is acceptable to reduce file clutter. Do NOT use deprecated `*ngIf` or `*ngFor` regardless of inlining.
+- **Standalone Components**: All new components must be `standalone: true`.
+- **Control Flow**: Use modern Angular control flow (`@if`, `@for`, `@empty`) instead of `*ngIf` and `*ngFor`.
+- **Styles**: Use Scoped CSS within the component or external `.css` files. Prefer Material Design for UI elements. Avoid duplicate selectors and unused styles in `.css` files.
+
+### 2. State & Data
+- **Signals**: Use Angular Signals for reactive state management (e.g., `currentUser` in `AuthService`).
+- **Services**: Centralize all API calls in services.
+- **Relative URLs**: Use relative paths (e.g., `/api/...`) for API calls to support the Nginx/dev-server proxying.
+
+### 3. UI/UX (Angular Material)
+- **Icons**: Use Material Icons (already configured in `index.html`).
+- **Theming**: Follow the `indigo-pink` theme.
+- **Accessibility (A11y)**: Use appropriate Material components for forms and buttons. Ensure every interactive element (e.g., elements with `(click)`) is keyboard-accessible. Use proper HTML elements like `` or add keyboard event listeners like `(keydown.enter)`.
+- **Consistency**: Follow the [Frontend Style Guideline](frontend-style-guideline.md) for all UI changes to ensure consistent look and feel.
+
+### 4. Testing (Vitest/Angular Testing Library)
+- **Providers**: Use `provideHttpClient()`, `provideHttpClientTesting()`, and `provideAnimationsAsync()` for test setup.
+- **Clean Environment**: Initialize the test environment in `src/test.ts` using `BrowserDynamicTestingModule`.
+- **Playwright**: Always execute Playwright tests with the `--reporter=line` option to ensure consistent output and avoid interactive hanging.
+
+## Deployment & Environments
+- **Local Dev**: Use `npm start` (Angular) and the Spring Boot application (IntelliJ). The Angular proxy (`proxy.conf.json`) handles routing to the backend on `localhost:8080`.
+- **Docker**: Use `docker compose up --build`. Nginx handles the reverse proxying of `/api` requests to the `backend` container.
+- **AWS ECS (Fargate)**:
+ - **Resource Allocation**: To minimize costs, always use the minimal required resources for demo services. The standard allocation is **256 CPU units and 512 MiB memory**. Do not increase these without explicit justification and user approval.
+ - **Single Task Enforcement**: Ensure only one task runs per service by setting `desired-count` to 1 and `maximum-percent` to 100 during deployments to avoid overlapping costs.
+ - **Task Definitions**: Always update the local task definition files in `deploy/aws/` and `task-def.json` at the root when making live changes to ensure consistency across deployments.
+
+## Static Analysis & Linting
+
+Before committing code, ensure it passes all static analysis checks. The CI/CD pipeline will fail if there are any violations.
+
+### 1. Backend (Java)
+Run Checkstyle and PMD locally to identify code style and potential bugs.
+
+Run Checkstyle:
+```bash
+mvn checkstyle:check
+```
+
+Run PMD:
+```bash
+mvn pmd:check
+```
+
+### 2. Frontend (Angular)
+Run ESLint to identify code style issues in TypeScript and HTML files.
+
+```bash
+cd frontend
+npm run lint
+```
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 000000000..8dea6c227
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.12/apache-maven-3.9.12-bin.zip
diff --git a/.sonar-export/sonar-issues-enriched.json b/.sonar-export/sonar-issues-enriched.json
new file mode 100644
index 000000000..87679c79f
--- /dev/null
+++ b/.sonar-export/sonar-issues-enriched.json
@@ -0,0 +1,610 @@
+[
+ {
+ "ordinal": 1,
+ "key": "AZxoSLyeSm898HwP_JIB",
+ "rule": "typescript:S1874",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "OPEN",
+ "resolution": null,
+ "message": "The signature \u0027(): Provider[]\u0027 of \u0027provideAnimations\u0027 is deprecated.",
+ "component": "JuergGood_angularai:frontend/src/app/app.config.ts",
+ "file": "frontend/src/app/app.config.ts",
+ "project": "JuergGood_angularai",
+ "line": 32,
+ "textRange": {
+ "startLine": 32,
+ "endLine": 32,
+ "startOffset": 4,
+ "endOffset": 21
+ },
+ "effort": "15min",
+ "debt": "15min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxoSLyeSm898HwP_JIB\u0026id=JuergGood_angularai",
+ "sourceSnippet": " 29: headerName: \u0027X-XSRF-TOKEN\u0027\r\n 30: })\r\n 31: ),\r\n\u003e\u003e 32: provideAnimations(),\r\n 33: provideTranslateService({\r\n 34: defaultLanguage: \u0027en\u0027\r\n 35: }),"
+ },
+ {
+ "ordinal": 2,
+ "key": "AZxjQFb4ixv-IQC_v9a8",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 17,
+ "endLine": 17,
+ "startOffset": 23,
+ "endOffset": 29
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a8\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 3,
+ "key": "AZxjQFb4ixv-IQC_v9a_",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 26,
+ "endLine": 26,
+ "startOffset": 8,
+ "endOffset": 14
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a_\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 4,
+ "key": "AZxjQFb4ixv-IQC_v9bB",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 34,
+ "endLine": 34,
+ "startOffset": 25,
+ "endOffset": 31
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bB\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 5,
+ "key": "AZxjQFchixv-IQC_v9bO",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Unexpected duplicate selector \"body.theme-dark .mat-expansion-panel-header\", first used at line 541",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "file": "frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 550,
+ "endLine": 550,
+ "startOffset": 0,
+ "endOffset": 45
+ },
+ "effort": "1min",
+ "debt": "1min",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bO\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 6,
+ "key": "AZxjQFchixv-IQC_v9bM",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary\", first used at line 131",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "file": "frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 136,
+ "endLine": 136,
+ "startOffset": 0,
+ "endOffset": 54
+ },
+ "effort": "1min",
+ "debt": "1min",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bM\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 7,
+ "key": "AZxjQFchixv-IQC_v9bN",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary:disabled\", first used at line 164",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "file": "frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 174,
+ "endLine": 174,
+ "startOffset": 0,
+ "endOffset": 63
+ },
+ "effort": "1min",
+ "debt": "1min",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bN\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 8,
+ "key": "AZxjQFb4ixv-IQC_v9bA",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 28,
+ "endLine": 28,
+ "startOffset": 54,
+ "endOffset": 60
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bA\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 9,
+ "key": "AZxjQFb4ixv-IQC_v9bD",
+ "rule": "javascript:S1226",
+ "severity": "MINOR",
+ "type": "BUG",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Introduce a new variable or use its initial value before reassigning \"t\".",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 37,
+ "endLine": 37,
+ "startOffset": 8,
+ "endOffset": 28
+ },
+ "effort": "5min",
+ "debt": "5min",
+ "cleanCodeAttribute": "CLEAR",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bD\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 10,
+ "key": "AZxjQFb4ixv-IQC_v9bE",
+ "rule": "javascript:S1226",
+ "severity": "MINOR",
+ "type": "BUG",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Introduce a new variable or use its initial value before reassigning \"y\".",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 38,
+ "endLine": 38,
+ "startOffset": 8,
+ "endOffset": 38
+ },
+ "effort": "5min",
+ "debt": "5min",
+ "cleanCodeAttribute": "CLEAR",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bE\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 11,
+ "key": "AZxjQFb4ixv-IQC_v9bF",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 39,
+ "endLine": 39,
+ "startOffset": 7,
+ "endOffset": 13
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bF\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 12,
+ "key": "AZxjQFchixv-IQC_v9bL",
+ "rule": "css:S4657",
+ "severity": "CRITICAL",
+ "type": "BUG",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Unexpected shorthand \"background\" after \"background-color\"",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "file": "frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 315,
+ "endLine": 315,
+ "startOffset": 0,
+ "endOffset": 37
+ },
+ "effort": "5min",
+ "debt": "5min",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "HIGH"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bL\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 13,
+ "key": "AZxjQFchixv-IQC_v9bP",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Unexpected duplicate selector \"body.theme-dark\", first used at line 611",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "file": "frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 709,
+ "endLine": 709,
+ "startOffset": 0,
+ "endOffset": 17
+ },
+ "effort": "1min",
+ "debt": "1min",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bP\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 14,
+ "key": "AZxjQFb4ixv-IQC_v9a-",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 24,
+ "endLine": 24,
+ "startOffset": 27,
+ "endOffset": 33
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a-\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 15,
+ "key": "AZxjQFb4ixv-IQC_v9a9",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "CLOSED",
+ "resolution": "REMOVED",
+ "message": "Prefer `globalThis` over `window`.",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "file": "frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "line": null,
+ "textRange": {
+ "startLine": 24,
+ "endLine": 24,
+ "startOffset": 8,
+ "endOffset": 14
+ },
+ "effort": "2min",
+ "debt": "2min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a9\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 16,
+ "key": "AZxjQFafixv-IQC_v9ay",
+ "rule": "typescript:S1874",
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "status": "OPEN",
+ "resolution": null,
+ "message": "\u0027provideAnimations\u0027 is deprecated.",
+ "component": "JuergGood_angularai:frontend/src/app/app.config.ts",
+ "file": "frontend/src/app/app.config.ts",
+ "project": "JuergGood_angularai",
+ "line": 4,
+ "textRange": {
+ "startLine": 4,
+ "endLine": 4,
+ "startOffset": 9,
+ "endOffset": 26
+ },
+ "effort": "15min",
+ "debt": "15min",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "comments": [
+
+ ],
+ "flows": [
+
+ ],
+ "quickFixAvailable": null,
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFafixv-IQC_v9ay\u0026id=JuergGood_angularai",
+ "sourceSnippet": " 1: import { ApplicationConfig, provideBrowserGlobalErrorListeners, ErrorHandler, provideAppInitializer, inject } from \u0027@angular/core\u0027;\r\n 2: import { provideRouter } from \u0027@angular/router\u0027;\r\n 3: import { provideHttpClient, withFetch, withXsrfConfiguration, withInterceptors } from \u0027@angular/common/http\u0027;\r\n\u003e\u003e 4: import { provideAnimations } from \u0027@angular/platform-browser/animations\u0027;\r\n 5: import { provideTranslateService } from \u0027@ngx-translate/core\u0027;\r\n 6: import { provideTranslateHttpLoader } from \u0027@ngx-translate/http-loader\u0027;\r\n 7: "
+ }
+]
diff --git a/.sonar-export/sonar-issues-for-junie.json b/.sonar-export/sonar-issues-for-junie.json
new file mode 100644
index 000000000..344cfbdc3
--- /dev/null
+++ b/.sonar-export/sonar-issues-for-junie.json
@@ -0,0 +1,290 @@
+[
+ {
+ "ordinal": 1,
+ "file": "frontend/src/app/app.config.ts",
+ "line": 32,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "typescript:S1874",
+ "message": "The signature \u0027(): Provider[]\u0027 of \u0027provideAnimations\u0027 is deprecated.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxoSLyeSm898HwP_JIB\u0026id=JuergGood_angularai",
+ "sourceSnippet": " 29: headerName: \u0027X-XSRF-TOKEN\u0027\r\n 30: })\r\n 31: ),\r\n\u003e\u003e 32: provideAnimations(),\r\n 33: provideTranslateService({\r\n 34: defaultLanguage: \u0027en\u0027\r\n 35: }),"
+ },
+ {
+ "ordinal": 2,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a8\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 3,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a_\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 4,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bB\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 5,
+ "file": "frontend/src/styles.css",
+ "line": null,
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "rule": "css:S4666",
+ "message": "Unexpected duplicate selector \"body.theme-dark .mat-expansion-panel-header\", first used at line 541",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bO\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 6,
+ "file": "frontend/src/styles.css",
+ "line": null,
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "rule": "css:S4666",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary\", first used at line 131",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bM\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 7,
+ "file": "frontend/src/styles.css",
+ "line": null,
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "rule": "css:S4666",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary:disabled\", first used at line 164",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bN\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 8,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bA\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 9,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "BUG",
+ "rule": "javascript:S1226",
+ "message": "Introduce a new variable or use its initial value before reassigning \"t\".",
+ "cleanCodeAttribute": "CLEAR",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bD\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 10,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "BUG",
+ "rule": "javascript:S1226",
+ "message": "Introduce a new variable or use its initial value before reassigning \"y\".",
+ "cleanCodeAttribute": "CLEAR",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bE\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 11,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9bF\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 12,
+ "file": "frontend/src/styles.css",
+ "line": null,
+ "severity": "CRITICAL",
+ "type": "BUG",
+ "rule": "css:S4657",
+ "message": "Unexpected shorthand \"background\" after \"background-color\"",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "HIGH"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bL\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 13,
+ "file": "frontend/src/styles.css",
+ "line": null,
+ "severity": "MAJOR",
+ "type": "CODE_SMELL",
+ "rule": "css:S4666",
+ "message": "Unexpected duplicate selector \"body.theme-dark\", first used at line 611",
+ "cleanCodeAttribute": "LOGICAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFchixv-IQC_v9bP\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 14,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a-\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 15,
+ "file": "frontend/src/index.html",
+ "line": null,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "javascript:S7764",
+ "message": "Prefer `globalThis` over `window`.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFb4ixv-IQC_v9a9\u0026id=JuergGood_angularai",
+ "sourceSnippet": null
+ },
+ {
+ "ordinal": 16,
+ "file": "frontend/src/app/app.config.ts",
+ "line": 4,
+ "severity": "MINOR",
+ "type": "CODE_SMELL",
+ "rule": "typescript:S1874",
+ "message": "\u0027provideAnimations\u0027 is deprecated.",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueUrl": "https://sonarcloud.io/project/issues?open=AZxjQFafixv-IQC_v9ay\u0026id=JuergGood_angularai",
+ "sourceSnippet": " 1: import { ApplicationConfig, provideBrowserGlobalErrorListeners, ErrorHandler, provideAppInitializer, inject } from \u0027@angular/core\u0027;\r\n 2: import { provideRouter } from \u0027@angular/router\u0027;\r\n 3: import { provideHttpClient, withFetch, withXsrfConfiguration, withInterceptors } from \u0027@angular/common/http\u0027;\r\n\u003e\u003e 4: import { provideAnimations } from \u0027@angular/platform-browser/animations\u0027;\r\n 5: import { provideTranslateService } from \u0027@ngx-translate/core\u0027;\r\n 6: import { provideTranslateHttpLoader } from \u0027@ngx-translate/http-loader\u0027;\r\n 7: "
+ }
+]
diff --git a/.sonar-export/sonar-issues-full.json b/.sonar-export/sonar-issues-full.json
new file mode 100644
index 000000000..2be25aa38
--- /dev/null
+++ b/.sonar-export/sonar-issues-full.json
@@ -0,0 +1,900 @@
+{
+ "project": "JuergGood_angularai",
+ "statuses": "OPEN,CONFIRMED",
+ "impactSeverities": "",
+ "impactSoftwareQualities": "MAINTAINABILITY,RELIABILITY,SECURITY",
+ "exportedAt": "2026-03-10T22:03:11",
+ "repoRoot": "C:\\doc\\sw\\ai\\angularai\\angularai",
+ "total": 16,
+ "issues": [
+ {
+ "key": "AZxoSLyeSm898HwP_JIB",
+ "rule": "typescript:S1874",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/app/app.config.ts",
+ "project": "JuergGood_angularai",
+ "line": 32,
+ "hash": "d1797e4295616414e92b83b5a76a582c",
+ "textRange": {
+ "startLine": 32,
+ "endLine": 32,
+ "startOffset": 4,
+ "endOffset": 21
+ },
+ "flows": [
+
+ ],
+ "status": "OPEN",
+ "message": "The signature \u0027(): Provider[]\u0027 of \u0027provideAnimations\u0027 is deprecated.",
+ "effort": "15min",
+ "debt": "15min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "cwe",
+ "obsolete",
+ "type-dependent"
+ ],
+ "transitions": [
+ "accept",
+ "confirm",
+ "resolve",
+ "falsepositive",
+ "wontfix"
+ ],
+ "actions": [
+ "set_type",
+ "set_tags",
+ "comment",
+ "set_severity",
+ "assign"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-16T21:07:45+0000",
+ "updateDate": "2026-03-10T20:53:50+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9a8",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "2d55caa8e27355b0dad55aaf09f78621",
+ "textRange": {
+ "startLine": 17,
+ "endLine": 17,
+ "startOffset": 23,
+ "endOffset": 29
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-09T22:44:35+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9a_",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "4f9560fee3d0817557f572a1064921c8",
+ "textRange": {
+ "startLine": 26,
+ "endLine": 26,
+ "startOffset": 8,
+ "endOffset": 14
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-09T22:44:35+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9bB",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "2d55caa8e27355b0dad55aaf09f78621",
+ "textRange": {
+ "startLine": 34,
+ "endLine": 34,
+ "startOffset": 25,
+ "endOffset": 31
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-09T22:44:35+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFchixv-IQC_v9bO",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "hash": "f7eb2753e085011e55f0c6dc932bf7e5",
+ "textRange": {
+ "startLine": 550,
+ "endLine": 550,
+ "startOffset": 0,
+ "endOffset": 45
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Unexpected duplicate selector \"body.theme-dark .mat-expansion-panel-header\", first used at line 541",
+ "effort": "1min",
+ "debt": "1min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-08T08:11:16+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "LOGICAL",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFchixv-IQC_v9bM",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "hash": "5dbb5eb0d4d84deec3eb8d0ec243a70d",
+ "textRange": {
+ "startLine": 136,
+ "endLine": 136,
+ "startOffset": 0,
+ "endOffset": 54
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary\", first used at line 131",
+ "effort": "1min",
+ "debt": "1min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-07T13:23:20+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "LOGICAL",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFchixv-IQC_v9bN",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "hash": "88c41757803fcc0df0421daa097a8547",
+ "textRange": {
+ "startLine": 174,
+ "endLine": 174,
+ "startOffset": 0,
+ "endOffset": 63
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Unexpected duplicate selector \"html body.theme-dark .mat-mdc-button-base.mat-primary:disabled\", first used at line 164",
+ "effort": "1min",
+ "debt": "1min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-07T13:23:20+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "LOGICAL",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9bA",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "98ab68f7f3e793f1930d163fa0384f7c",
+ "textRange": {
+ "startLine": 28,
+ "endLine": 28,
+ "startOffset": 54,
+ "endOffset": 60
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-03T22:13:41+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9bD",
+ "rule": "javascript:S1226",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "471f638cd7021e8f77dc24d634b58784",
+ "textRange": {
+ "startLine": 37,
+ "endLine": 37,
+ "startOffset": 8,
+ "endOffset": 28
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Introduce a new variable or use its initial value before reassigning \"t\".",
+ "effort": "5min",
+ "debt": "5min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-01T16:03:28+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "BUG",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CLEAR",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9bE",
+ "rule": "javascript:S1226",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "27b7dda992563b634502fd00c688e436",
+ "textRange": {
+ "startLine": 38,
+ "endLine": 38,
+ "startOffset": 8,
+ "endOffset": 38
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Introduce a new variable or use its initial value before reassigning \"y\".",
+ "effort": "5min",
+ "debt": "5min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-01T16:03:28+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "BUG",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CLEAR",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9bF",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "6ba5bffb92dd4bdaf43841e0d36a8d76",
+ "textRange": {
+ "startLine": 39,
+ "endLine": 39,
+ "startOffset": 7,
+ "endOffset": 13
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-02-01T16:03:28+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFchixv-IQC_v9bL",
+ "rule": "css:S4657",
+ "severity": "CRITICAL",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "hash": "b6b3e09dfa5ddf3ecba5e620758a6e71",
+ "textRange": {
+ "startLine": 315,
+ "endLine": 315,
+ "startOffset": 0,
+ "endOffset": 37
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Unexpected shorthand \"background\" after \"background-color\"",
+ "effort": "5min",
+ "debt": "5min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-01-22T23:34:17+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "BUG",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "LOGICAL",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "RELIABILITY",
+ "severity": "HIGH"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFchixv-IQC_v9bP",
+ "rule": "css:S4666",
+ "severity": "MAJOR",
+ "component": "JuergGood_angularai:frontend/src/styles.css",
+ "project": "JuergGood_angularai",
+ "hash": "78498f77ee1ca6c84e5d6e211da7f528",
+ "textRange": {
+ "startLine": 709,
+ "endLine": 709,
+ "startOffset": 0,
+ "endOffset": 17
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Unexpected duplicate selector \"body.theme-dark\", first used at line 611",
+ "effort": "1min",
+ "debt": "1min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-01-22T23:34:17+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "LOGICAL",
+ "cleanCodeAttributeCategory": "INTENTIONAL",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "MEDIUM"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9a-",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "1f54e742240c303fb82f8ad1b6f95d34",
+ "textRange": {
+ "startLine": 24,
+ "endLine": 24,
+ "startOffset": 27,
+ "endOffset": 33
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-01-22T21:48:29+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFb4ixv-IQC_v9a9",
+ "rule": "javascript:S7764",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/index.html",
+ "project": "JuergGood_angularai",
+ "hash": "1f54e742240c303fb82f8ad1b6f95d34",
+ "textRange": {
+ "startLine": 24,
+ "endLine": 24,
+ "startOffset": 8,
+ "endOffset": 14
+ },
+ "flows": [
+
+ ],
+ "resolution": "REMOVED",
+ "status": "CLOSED",
+ "message": "Prefer `globalThis` over `window`.",
+ "effort": "2min",
+ "debt": "2min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "es2020",
+ "portability"
+ ],
+ "transitions": [
+
+ ],
+ "actions": [
+ "comment"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-01-22T21:48:29+0000",
+ "updateDate": "2026-02-16T19:23:00+0000",
+ "closeDate": "2026-02-16T19:23:00+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ },
+ {
+ "key": "AZxjQFafixv-IQC_v9ay",
+ "rule": "typescript:S1874",
+ "severity": "MINOR",
+ "component": "JuergGood_angularai:frontend/src/app/app.config.ts",
+ "project": "JuergGood_angularai",
+ "line": 4,
+ "hash": "b0b7d0e2941bc07c4179d42d9bd42f29",
+ "textRange": {
+ "startLine": 4,
+ "endLine": 4,
+ "startOffset": 9,
+ "endOffset": 26
+ },
+ "flows": [
+
+ ],
+ "status": "OPEN",
+ "message": "\u0027provideAnimations\u0027 is deprecated.",
+ "effort": "15min",
+ "debt": "15min",
+ "assignee": "JuergGood-0OBYU@github",
+ "author": "sub@goodfamily.ch",
+ "tags": [
+ "cwe",
+ "obsolete",
+ "type-dependent"
+ ],
+ "transitions": [
+ "accept",
+ "confirm",
+ "resolve",
+ "falsepositive",
+ "wontfix"
+ ],
+ "actions": [
+ "set_type",
+ "set_tags",
+ "comment",
+ "set_severity",
+ "assign"
+ ],
+ "comments": [
+
+ ],
+ "creationDate": "2026-01-02T17:20:54+0000",
+ "updateDate": "2026-03-10T20:53:50+0000",
+ "type": "CODE_SMELL",
+ "organization": "juerggood",
+ "cleanCodeAttribute": "CONVENTIONAL",
+ "cleanCodeAttributeCategory": "CONSISTENT",
+ "impacts": [
+ {
+ "softwareQuality": "MAINTAINABILITY",
+ "severity": "LOW"
+ }
+ ],
+ "issueStatus": "OPEN",
+ "projectName": "angularai-parent",
+ "internalTags": [
+
+ ]
+ }
+ ]
+}
diff --git a/README.md b/README.md
new file mode 100644
index 000000000..70ccb7363
--- /dev/null
+++ b/README.md
@@ -0,0 +1,197 @@
+
+
+
+
+
+
+
+
+# AngularAI
+
+**AI‑Powered Software Engineering Platform**
+
+AngularAI explores a new idea: **AI analyzing software engineering itself.**
+Instead of only helping developers write code, the system can analyze architecture,
+tasks, and project documentation to generate insights about a software system.
+
+Live demo:
+https://goodone.ch
+
+---
+
+# 🚀 What Makes This Project Interesting
+
+Most AI projects focus on:
+
+• chatbots
+• prompt frameworks
+• API wrappers
+
+AngularAI explores something different:
+
+> What if AI becomes part of the runtime of the application and continuously
+> analyzes the engineering process itself?
+
+The platform can:
+
+• explain system architecture
+• detect development risks
+• generate sprint retrospectives
+• identify architectural drift
+
+---
+
+# ✨ AI Features
+
+## Architecture Q&A
+
+Ask natural‑language questions about the system architecture.
+
+Example:
+
+• Which components interact with the database?
+• How does authentication work?
+• How is reCAPTCHA verified?
+
+The AI answers using internal architecture documentation.
+
+---
+
+## AI Risk Radar
+
+Automatically detect recurring engineering risks.
+
+Examples:
+
+• tasks marked DONE but still containing open items
+• missing verification sections
+• documentation inconsistencies
+
+Helps teams detect **systematic quality problems** early.
+
+---
+
+## AI Sprint Retrospective
+
+Generate **AI‑assisted sprint retrospectives** based on development tasks.
+
+The system analyzes:
+
+• task completion patterns
+• recurring blockers
+• documentation quality
+
+This helps teams continuously improve their process.
+
+---
+
+## ADR Drift Detection
+
+Architecture Decision Records (ADR) define architectural intent.
+
+AngularAI monitors implementation and detects when systems drift away from
+those decisions.
+
+This helps maintain **long‑term architectural integrity**.
+
+---
+
+# 🧠 Architecture
+
+See the **[System Overview](doc/architecture/system-overview.md)** for details.
+
+Architecture layers:
+
+Frontend
+Angular UI for interacting with AI features
+
+Backend
+Spring Boot API exposing architecture and task data
+
+AI Layer
+LLM‑powered analysis of architecture and development data
+
+Data Sources
+Tasks, documentation, ADRs, architecture knowledge
+
+---
+
+# 🎬 Demo
+
+
+
+Explore the live platform:
+
+https://goodone.ch
+
+---
+
+# ⚡ Quick Start
+
+Clone the repository:
+
+```
+git clone https://github.com/JuergGood/angularai
+```
+
+Start the stack:
+
+```
+cp .env.example .env
+docker compose -f deploy/dev/docker-compose.yml up --build
+```
+
+Application endpoints:
+
+Frontend
+http://localhost
+
+Backend API
+http://localhost:8080/api
+
+H2 Console
+http://localhost:8080/h2-console
+
+Mailpit
+http://localhost:8025
+
+---
+
+# 📚 Documentation
+
+Documentation is located in the `doc` directory.
+
+Key entry points:
+
+Architecture
+Please refer to the **[Architecture Index](doc/architecture/index.md)** for more details.
+
+User Guide
+`doc/user-guide/user-guide.md`
+
+Admin Guide
+`doc/admin-guide/admin-guide.md`
+
+Deployment
+`doc/infrastructure/Deployment.md`
+
+---
+
+# 🎯 Vision
+
+AngularAI explores how **AI can augment software engineering workflows**.
+
+Instead of AI replacing developers, the platform focuses on helping teams:
+
+• understand complex architectures
+• detect engineering risks
+• analyze development processes
+• preserve architectural intent
+
+---
+
+# ⭐ Support
+
+If you find this project interesting, please consider starring the repository.
+
+It helps others discover the project and encourages further development.
diff --git a/assets/angularai-hero-banner.png b/assets/angularai-hero-banner.png
new file mode 100644
index 000000000..2d64638cf
Binary files /dev/null and b/assets/angularai-hero-banner.png differ
diff --git a/assets/angularai-social-card.png b/assets/angularai-social-card.png
new file mode 100644
index 000000000..9d7dc9d9e
Binary files /dev/null and b/assets/angularai-social-card.png differ
diff --git a/assets/architecture-overview-snippet.md b/assets/architecture-overview-snippet.md
new file mode 100644
index 000000000..cbc48c4e5
--- /dev/null
+++ b/assets/architecture-overview-snippet.md
@@ -0,0 +1,10 @@
+
+## 1‑Minute Architecture Overview
+
+
+
+AngularAI integrates AI directly into the runtime of the application.
+
+User interactions and project data are analyzed by an AI reasoning layer
+that generates insights such as architecture explanations, development risk
+detection, sprint retrospectives and ADR drift analysis.
diff --git a/assets/feature-cards-section.md b/assets/feature-cards-section.md
new file mode 100644
index 000000000..b62ed28e5
--- /dev/null
+++ b/assets/feature-cards-section.md
@@ -0,0 +1,13 @@
+
+## Key Platform Capabilities
+
+
+
+Architecture Q&A
+AI Risk Radar
+
+
+Sprint Retrospective
+ADR Drift Detection
+
+
diff --git a/assets/feature-overview-snippet.md b/assets/feature-overview-snippet.md
new file mode 100644
index 000000000..bcae2faf2
--- /dev/null
+++ b/assets/feature-overview-snippet.md
@@ -0,0 +1,4 @@
+
+## Platform Capabilities
+
+
diff --git a/assets/github-badges-snippet.md b/assets/github-badges-snippet.md
new file mode 100644
index 000000000..1081dd3ac
--- /dev/null
+++ b/assets/github-badges-snippet.md
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/assets/github-banner.png b/assets/github-banner.png
new file mode 100644
index 000000000..120ee04c8
Binary files /dev/null and b/assets/github-banner.png differ
diff --git a/assets/github-social-preview.png b/assets/github-social-preview.png
new file mode 100644
index 000000000..521657072
Binary files /dev/null and b/assets/github-social-preview.png differ
diff --git a/assets/intelligence-map-snippet.md b/assets/intelligence-map-snippet.md
new file mode 100644
index 000000000..f258db2e2
--- /dev/null
+++ b/assets/intelligence-map-snippet.md
@@ -0,0 +1,7 @@
+
+## Software Engineering Intelligence Map
+
+
+
+The platform analyzes architecture documentation, development tasks and
+architecture decisions using an AI reasoning engine to generate engineering insights.
diff --git a/assets/readme-hero-snippet.md b/assets/readme-hero-snippet.md
new file mode 100644
index 000000000..bbc78fdb4
--- /dev/null
+++ b/assets/readme-hero-snippet.md
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+**AngularAI** is an experimental platform exploring how **AI can analyze and improve software engineering workflows**.
+
+The system can:
+
+• answer architecture questions
+• detect development risks
+• generate sprint retrospectives
+• identify ADR drift
+
+Explore the live demo: https://goodone.ch
diff --git a/assets/repo-description.txt b/assets/repo-description.txt
new file mode 100644
index 000000000..381688c53
--- /dev/null
+++ b/assets/repo-description.txt
@@ -0,0 +1,12 @@
+
+AngularAI is an experimental AI-powered software engineering platform.
+
+The application integrates AI directly into the runtime to analyze architecture documentation,
+development tasks, and engineering workflows.
+
+Features include:
+
+• Architecture Q&A powered by internal documentation
+• AI Risk Radar detecting recurring quality issues
+• Automated Sprint Retrospectives
+• ADR Drift detection
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 000000000..2a79bb822
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,10 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!mvnw
+!mvnw.cmd
+.mvn/
+.git/
+.gitignore
+*.iml
+.idea/
+Dockerfile
diff --git a/backend/.gitattributes b/backend/.gitattributes
new file mode 100644
index 000000000..3b41682ac
--- /dev/null
+++ b/backend/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 000000000..667aaef0c
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 000000000..f11c90f3d
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,22 @@
+# Build stage
+FROM maven:3-eclipse-temurin-25-alpine AS build
+WORKDIR /app
+COPY pom.xml .
+COPY backend/pom.xml backend/
+COPY backend/src backend/src
+RUN mvn -f backend/pom.xml clean package -DskipTests
+
+# Run stage
+FROM eclipse-temurin:25-jre-alpine
+# Update OS packages to fix security vulnerabilities
+RUN apk update && apk upgrade --no-cache
+# Create a non-root user
+RUN addgroup -S spring && adduser -S spring -G spring
+WORKDIR /app
+COPY doc doc
+COPY --from=build /app/backend/target/aibackend-*.jar app.jar
+# Set permissions
+RUN chown -R spring:spring /app
+EXPOSE 8080
+USER spring:spring
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/backend/HELP.md b/backend/HELP.md
new file mode 100644
index 000000000..630922d49
--- /dev/null
+++ b/backend/HELP.md
@@ -0,0 +1,30 @@
+# Getting Started
+
+### Reference Documentation
+For further reference, please consider the following sections:
+
+* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
+* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.1/maven-plugin)
+* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.1/maven-plugin/build-image.html)
+* [Spring Web](https://docs.spring.io/spring-boot/4.0.1/reference/web/servlet.html)
+* [Spring Data JPA](https://docs.spring.io/spring-boot/4.0.1/reference/data/sql.html#data.sql.jpa-and-spring-data)
+* [Spring Security](https://docs.spring.io/spring-boot/4.0.1/reference/web/spring-security.html)
+
+### Guides
+The following guides illustrate how to use some features concretely:
+
+* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
+* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
+* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
+* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
+* [Securing a Web Application](https://spring.io/guides/gs/securing-web/)
+* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
+* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/)
+
+### Maven Parent overrides
+
+Due to Maven's design, elements are inherited from the parent POM to the project POM.
+While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent.
+To prevent this, the project POM contains empty overrides for these elements.
+If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.
+
diff --git a/backend/checkstyle.xml b/backend/checkstyle.xml
new file mode 100644
index 000000000..0bd52549f
--- /dev/null
+++ b/backend/checkstyle.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/cp.txt b/backend/cp.txt
new file mode 100644
index 000000000..30cf8cea2
--- /dev/null
+++ b/backend/cp.txt
@@ -0,0 +1 @@
+C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-h2console\4.0.1\spring-boot-h2console-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot\4.0.1\spring-boot-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-context\7.0.2\spring-context-7.0.2.jar;C:\Users\sub\.m2\repository\jakarta\servlet\jakarta.servlet-api\6.1.0\jakarta.servlet-api-6.1.0.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-data-jpa\4.0.1\spring-boot-starter-data-jpa-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter\4.0.1\spring-boot-starter-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-logging\4.0.1\spring-boot-starter-logging-4.0.1.jar;C:\Users\sub\.m2\repository\ch\qos\logback\logback-classic\1.5.22\logback-classic-1.5.22.jar;C:\Users\sub\.m2\repository\ch\qos\logback\logback-core\1.5.22\logback-core-1.5.22.jar;C:\Users\sub\.m2\repository\org\apache\logging\log4j\log4j-to-slf4j\2.25.3\log4j-to-slf4j-2.25.3.jar;C:\Users\sub\.m2\repository\org\apache\logging\log4j\log4j-api\2.25.3\log4j-api-2.25.3.jar;C:\Users\sub\.m2\repository\org\slf4j\jul-to-slf4j\2.0.17\jul-to-slf4j-2.0.17.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-autoconfigure\4.0.1\spring-boot-autoconfigure-4.0.1.jar;C:\Users\sub\.m2\repository\jakarta\annotation\jakarta.annotation-api\3.0.0\jakarta.annotation-api-3.0.0.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-jdbc\4.0.1\spring-boot-starter-jdbc-4.0.1.jar;C:\Users\sub\.m2\repository\com\zaxxer\HikariCP\7.0.2\HikariCP-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-data-jpa\4.0.1\spring-boot-data-jpa-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-data-commons\4.0.1\spring-boot-data-commons-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-persistence\4.0.1\spring-boot-persistence-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\data\spring-data-commons\4.0.1\spring-data-commons-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-hibernate\4.0.1\spring-boot-hibernate-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-jpa\4.0.1\spring-boot-jpa-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-orm\7.0.2\spring-orm-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\data\spring-data-jpa\4.0.1\spring-data-jpa-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-tx\7.0.2\spring-tx-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\spring-aspects\7.0.2\spring-aspects-7.0.2.jar;C:\Users\sub\.m2\repository\org\aspectj\aspectjweaver\1.9.25.1\aspectjweaver-1.9.25.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-jdbc\4.0.1\spring-boot-jdbc-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-sql\4.0.1\spring-boot-sql-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-transaction\4.0.1\spring-boot-transaction-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-jdbc\7.0.2\spring-jdbc-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-security\4.0.1\spring-boot-starter-security-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-security\4.0.1\spring-boot-security-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\security\spring-security-config\7.0.2\spring-security-config-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\spring-aop\7.0.2\spring-aop-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\spring-beans\7.0.2\spring-beans-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-webmvc\4.0.1\spring-boot-starter-webmvc-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-jackson\4.0.1\spring-boot-starter-jackson-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-jackson\4.0.1\spring-boot-jackson-4.0.1.jar;C:\Users\sub\.m2\repository\tools\jackson\core\jackson-databind\3.0.3\jackson-databind-3.0.3.jar;C:\Users\sub\.m2\repository\tools\jackson\core\jackson-core\3.0.3\jackson-core-3.0.3.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-tomcat\4.0.1\spring-boot-starter-tomcat-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-tomcat-runtime\4.0.1\spring-boot-starter-tomcat-runtime-4.0.1.jar;C:\Users\sub\.m2\repository\org\apache\tomcat\embed\tomcat-embed-core\11.0.15\tomcat-embed-core-11.0.15.jar;C:\Users\sub\.m2\repository\org\apache\tomcat\embed\tomcat-embed-websocket\11.0.15\tomcat-embed-websocket-11.0.15.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-tomcat\4.0.1\spring-boot-tomcat-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-http-converter\4.0.1\spring-boot-http-converter-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-web\7.0.2\spring-web-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-webmvc\4.0.1\spring-boot-webmvc-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-servlet\4.0.1\spring-boot-servlet-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-webmvc\7.0.2\spring-webmvc-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-actuator\4.0.1\spring-boot-starter-actuator-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-micrometer-metrics\4.0.1\spring-boot-starter-micrometer-metrics-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-micrometer-metrics\4.0.1\spring-boot-micrometer-metrics-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-micrometer-observation\4.0.1\spring-boot-micrometer-observation-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-actuator-autoconfigure\4.0.1\spring-boot-actuator-autoconfigure-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-actuator\4.0.1\spring-boot-actuator-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-health\4.0.1\spring-boot-health-4.0.1.jar;C:\Users\sub\.m2\repository\io\micrometer\micrometer-observation\1.16.1\micrometer-observation-1.16.1.jar;C:\Users\sub\.m2\repository\io\micrometer\micrometer-commons\1.16.1\micrometer-commons-1.16.1.jar;C:\Users\sub\.m2\repository\io\micrometer\micrometer-jakarta9\1.16.1\micrometer-jakarta9-1.16.1.jar;C:\Users\sub\.m2\repository\io\micrometer\micrometer-registry-prometheus\1.16.1\micrometer-registry-prometheus-1.16.1.jar;C:\Users\sub\.m2\repository\org\jspecify\jspecify\1.0.0\jspecify-1.0.0.jar;C:\Users\sub\.m2\repository\io\micrometer\micrometer-core\1.16.1\micrometer-core-1.16.1.jar;C:\Users\sub\.m2\repository\org\hdrhistogram\HdrHistogram\2.2.2\HdrHistogram-2.2.2.jar;C:\Users\sub\.m2\repository\org\latencyutils\LatencyUtils\2.0.3\LatencyUtils-2.0.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-core\1.4.3\prometheus-metrics-core-1.4.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-model\1.4.3\prometheus-metrics-model-1.4.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-config\1.4.3\prometheus-metrics-config-1.4.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-tracer-common\1.4.3\prometheus-metrics-tracer-common-1.4.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-exposition-formats\1.4.3\prometheus-metrics-exposition-formats-1.4.3.jar;C:\Users\sub\.m2\repository\io\prometheus\prometheus-metrics-exposition-textformats\1.4.3\prometheus-metrics-exposition-textformats-1.4.3.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-validation\4.0.1\spring-boot-starter-validation-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-validation\4.0.1\spring-boot-validation-4.0.1.jar;C:\Users\sub\.m2\repository\org\apache\tomcat\embed\tomcat-embed-el\11.0.15\tomcat-embed-el-11.0.15.jar;C:\Users\sub\.m2\repository\org\hibernate\validator\hibernate-validator\9.0.1.Final\hibernate-validator-9.0.1.Final.jar;C:\Users\sub\.m2\repository\jakarta\validation\jakarta.validation-api\3.1.1\jakarta.validation-api-3.1.1.jar;C:\Users\sub\.m2\repository\de\codecentric\spring-boot-admin-starter-client\4.0.0\spring-boot-admin-starter-client-4.0.0.jar;C:\Users\sub\.m2\repository\de\codecentric\spring-boot-admin-client\4.0.0\spring-boot-admin-client-4.0.0.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-restclient\4.0.1\spring-boot-starter-restclient-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-restclient\4.0.1\spring-boot-restclient-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-http-client\4.0.1\spring-boot-http-client-4.0.1.jar;C:\Users\sub\.m2\repository\com\h2database\h2\2.4.240\h2-2.4.240.jar;C:\Users\sub\.m2\repository\org\postgresql\postgresql\42.7.8\postgresql-42.7.8.jar;C:\Users\sub\.m2\repository\org\checkerframework\checker-qual\3.49.5\checker-qual-3.49.5.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-test\4.0.1\spring-boot-starter-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-test\4.0.1\spring-boot-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-test-autoconfigure\4.0.1\spring-boot-test-autoconfigure-4.0.1.jar;C:\Users\sub\.m2\repository\com\jayway\jsonpath\json-path\2.10.0\json-path-2.10.0.jar;C:\Users\sub\.m2\repository\org\slf4j\slf4j-api\2.0.17\slf4j-api-2.0.17.jar;C:\Users\sub\.m2\repository\net\minidev\json-smart\2.6.0\json-smart-2.6.0.jar;C:\Users\sub\.m2\repository\net\minidev\accessors-smart\2.6.0\accessors-smart-2.6.0.jar;C:\Users\sub\.m2\repository\org\ow2\asm\asm\9.7.1\asm-9.7.1.jar;C:\Users\sub\.m2\repository\org\assertj\assertj-core\3.27.6\assertj-core-3.27.6.jar;C:\Users\sub\.m2\repository\net\bytebuddy\byte-buddy\1.17.8\byte-buddy-1.17.8.jar;C:\Users\sub\.m2\repository\org\awaitility\awaitility\4.3.0\awaitility-4.3.0.jar;C:\Users\sub\.m2\repository\org\hamcrest\hamcrest\3.0\hamcrest-3.0.jar;C:\Users\sub\.m2\repository\org\junit\jupiter\junit-jupiter\6.0.1\junit-jupiter-6.0.1.jar;C:\Users\sub\.m2\repository\org\junit\jupiter\junit-jupiter-api\6.0.1\junit-jupiter-api-6.0.1.jar;C:\Users\sub\.m2\repository\org\opentest4j\opentest4j\1.3.0\opentest4j-1.3.0.jar;C:\Users\sub\.m2\repository\org\junit\platform\junit-platform-commons\6.0.1\junit-platform-commons-6.0.1.jar;C:\Users\sub\.m2\repository\org\apiguardian\apiguardian-api\1.1.2\apiguardian-api-1.1.2.jar;C:\Users\sub\.m2\repository\org\junit\jupiter\junit-jupiter-params\6.0.1\junit-jupiter-params-6.0.1.jar;C:\Users\sub\.m2\repository\org\junit\jupiter\junit-jupiter-engine\6.0.1\junit-jupiter-engine-6.0.1.jar;C:\Users\sub\.m2\repository\org\junit\platform\junit-platform-engine\6.0.1\junit-platform-engine-6.0.1.jar;C:\Users\sub\.m2\repository\org\mockito\mockito-core\5.20.0\mockito-core-5.20.0.jar;C:\Users\sub\.m2\repository\net\bytebuddy\byte-buddy-agent\1.17.8\byte-buddy-agent-1.17.8.jar;C:\Users\sub\.m2\repository\org\objenesis\objenesis\3.3\objenesis-3.3.jar;C:\Users\sub\.m2\repository\org\mockito\mockito-junit-jupiter\5.20.0\mockito-junit-jupiter-5.20.0.jar;C:\Users\sub\.m2\repository\org\skyscreamer\jsonassert\1.5.3\jsonassert-1.5.3.jar;C:\Users\sub\.m2\repository\com\vaadin\external\google\android-json\0.0.20131108.vaadin1\android-json-0.0.20131108.vaadin1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-core\7.0.2\spring-core-7.0.2.jar;C:\Users\sub\.m2\repository\commons-logging\commons-logging\1.3.5\commons-logging-1.3.5.jar;C:\Users\sub\.m2\repository\org\springframework\spring-test\7.0.2\spring-test-7.0.2.jar;C:\Users\sub\.m2\repository\org\xmlunit\xmlunit-core\2.10.4\xmlunit-core-2.10.4.jar;C:\Users\sub\.m2\repository\org\springframework\security\spring-security-test\7.0.2\spring-security-test-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\security\spring-security-core\7.0.2\spring-security-core-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\security\spring-security-crypto\7.0.2\spring-security-crypto-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\spring-expression\7.0.2\spring-expression-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\security\spring-security-web\7.0.2\spring-security-web-7.0.2.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-data-jpa-test\4.0.1\spring-boot-starter-data-jpa-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-jdbc-test\4.0.1\spring-boot-starter-jdbc-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-jdbc-test\4.0.1\spring-boot-jdbc-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-data-jpa-test\4.0.1\spring-boot-data-jpa-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-jpa-test\4.0.1\spring-boot-jpa-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-webmvc-test\4.0.1\spring-boot-starter-webmvc-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-jackson-test\4.0.1\spring-boot-starter-jackson-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-webmvc-test\4.0.1\spring-boot-webmvc-test-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-web-server\4.0.1\spring-boot-web-server-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-resttestclient\4.0.1\spring-boot-resttestclient-4.0.1.jar;C:\Users\sub\.m2\repository\com\atlassian\oai\swagger-request-validator-mockmvc\2.44.1\swagger-request-validator-mockmvc-2.44.1.jar;C:\Users\sub\.m2\repository\com\atlassian\oai\swagger-request-validator-core\2.44.1\swagger-request-validator-core-2.44.1.jar;C:\Users\sub\.m2\repository\io\swagger\parser\v3\swagger-parser\2.1.22\swagger-parser-2.1.22.jar;C:\Users\sub\.m2\repository\io\swagger\parser\v3\swagger-parser-v2-converter\2.1.22\swagger-parser-v2-converter-2.1.22.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-core\1.6.14\swagger-core-1.6.14.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-models\1.6.14\swagger-models-1.6.14.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-annotations\1.6.14\swagger-annotations-1.6.14.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-parser\1.0.70\swagger-parser-1.0.70.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-parser-safe-url-resolver\1.0.70\swagger-parser-safe-url-resolver-1.0.70.jar;C:\Users\sub\.m2\repository\io\swagger\swagger-compat-spec-parser\1.0.70\swagger-compat-spec-parser-1.0.70.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\json-patch\1.13\json-patch-1.13.jar;C:\Users\sub\.m2\repository\org\apache\httpcomponents\httpclient\4.5.14\httpclient-4.5.14.jar;C:\Users\sub\.m2\repository\org\apache\httpcomponents\httpcore\4.4.16\httpcore-4.4.16.jar;C:\Users\sub\.m2\repository\commons-codec\commons-codec\1.19.0\commons-codec-1.19.0.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-models\2.2.21\swagger-models-2.2.21.jar;C:\Users\sub\.m2\repository\io\swagger\parser\v3\swagger-parser-core\2.1.22\swagger-parser-core-2.1.22.jar;C:\Users\sub\.m2\repository\io\swagger\parser\v3\swagger-parser-v3\2.1.22\swagger-parser-v3-2.1.22.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-core\2.2.21\swagger-core-2.2.21.jar;C:\Users\sub\.m2\repository\io\swagger\parser\v3\swagger-parser-safe-url-resolver\2.1.22\swagger-parser-safe-url-resolver-2.1.22.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\dataformat\jackson-dataformat-yaml\2.20.1\jackson-dataformat-yaml-2.20.1.jar;C:\Users\sub\.m2\repository\commons-io\commons-io\2.15.1\commons-io-2.15.1.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\json-schema-validator\2.2.14\json-schema-validator-2.2.14.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\jackson-coreutils-equivalence\1.0\jackson-coreutils-equivalence-1.0.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\jackson-coreutils\2.0\jackson-coreutils-2.0.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\msg-simple\1.2\msg-simple-1.2.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\btf\1.3\btf-1.3.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\json-schema-core\1.2.14\json-schema-core-1.2.14.jar;C:\Users\sub\.m2\repository\com\github\java-json-tools\uri-template\0.10\uri-template-0.10.jar;C:\Users\sub\.m2\repository\org\mozilla\rhino\1.7.7.2\rhino-1.7.7.2.jar;C:\Users\sub\.m2\repository\com\sun\mail\mailapi\1.6.2\mailapi-1.6.2.jar;C:\Users\sub\.m2\repository\joda-time\joda-time\2.10.5\joda-time-2.10.5.jar;C:\Users\sub\.m2\repository\com\googlecode\libphonenumber\libphonenumber\8.11.1\libphonenumber-8.11.1.jar;C:\Users\sub\.m2\repository\net\sf\jopt-simple\jopt-simple\5.0.4\jopt-simple-5.0.4.jar;C:\Users\sub\.m2\repository\com\google\guava\guava\33.3.1-jre\guava-33.3.1-jre.jar;C:\Users\sub\.m2\repository\com\google\guava\failureaccess\1.0.2\failureaccess-1.0.2.jar;C:\Users\sub\.m2\repository\com\google\guava\listenablefuture\9999.0-empty-to-avoid-conflict-with-guava\listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar;C:\Users\sub\.m2\repository\com\google\errorprone\error_prone_annotations\2.28.0\error_prone_annotations-2.28.0.jar;C:\Users\sub\.m2\repository\com\google\j2objc\j2objc-annotations\3.0.0\j2objc-annotations-3.0.0.jar;C:\Users\sub\.m2\repository\com\google\code\findbugs\jsr305\3.0.2\jsr305-3.0.2.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\datatype\jackson-datatype-jdk8\2.20.1\jackson-datatype-jdk8-2.20.1.jar;C:\Users\sub\.m2\repository\javax\xml\bind\jaxb-api\2.3.1\jaxb-api-2.3.1.jar;C:\Users\sub\.m2\repository\javax\activation\javax.activation-api\1.2.0\javax.activation-api-1.2.0.jar;C:\Users\sub\.m2\repository\jakarta\xml\bind\jakarta.xml.bind-api\4.0.4\jakarta.xml.bind-api-4.0.4.jar;C:\Users\sub\.m2\repository\jakarta\activation\jakarta.activation-api\2.1.4\jakarta.activation-api-2.1.4.jar;C:\Users\sub\.m2\repository\org\glassfish\jaxb\jaxb-runtime\4.0.6\jaxb-runtime-4.0.6.jar;C:\Users\sub\.m2\repository\org\glassfish\jaxb\jaxb-core\4.0.6\jaxb-core-4.0.6.jar;C:\Users\sub\.m2\repository\org\eclipse\angus\angus-activation\2.0.3\angus-activation-2.0.3.jar;C:\Users\sub\.m2\repository\org\glassfish\jaxb\txw2\4.0.6\txw2-4.0.6.jar;C:\Users\sub\.m2\repository\com\sun\istack\istack-commons-runtime\4.1.2\istack-commons-runtime-4.1.2.jar;C:\Users\sub\.m2\repository\org\springdoc\springdoc-openapi-starter-webmvc-ui\2.8.5\springdoc-openapi-starter-webmvc-ui-2.8.5.jar;C:\Users\sub\.m2\repository\org\springdoc\springdoc-openapi-starter-webmvc-api\2.8.5\springdoc-openapi-starter-webmvc-api-2.8.5.jar;C:\Users\sub\.m2\repository\org\springdoc\springdoc-openapi-starter-common\2.8.5\springdoc-openapi-starter-common-2.8.5.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-core-jakarta\2.2.28\swagger-core-jakarta-2.2.28.jar;C:\Users\sub\.m2\repository\org\apache\commons\commons-lang3\3.19.0\commons-lang3-3.19.0.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-annotations-jakarta\2.2.28\swagger-annotations-jakarta-2.2.28.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-models-jakarta\2.2.28\swagger-models-jakarta-2.2.28.jar;C:\Users\sub\.m2\repository\org\webjars\webjars-locator-lite\1.1.2\webjars-locator-lite-1.1.2.jar;C:\Users\sub\.m2\repository\org\webjars\swagger-ui\5.31.0\swagger-ui-5.31.0.jar;C:\Users\sub\.m2\repository\org\flywaydb\flyway-core\11.14.1\flyway-core-11.14.1.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\core\jackson-databind\2.20.1\jackson-databind-2.20.1.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\core\jackson-annotations\2.20\jackson-annotations-2.20.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\core\jackson-core\2.20.1\jackson-core-2.20.1.jar;C:\Users\sub\.m2\repository\org\flywaydb\flyway-database-postgresql\11.14.1\flyway-database-postgresql-11.14.1.jar;C:\Users\sub\.m2\repository\com\github\ua-parser\uap-java\1.6.1\uap-java-1.6.1.jar;C:\Users\sub\.m2\repository\org\yaml\snakeyaml\2.5\snakeyaml-2.5.jar;C:\Users\sub\.m2\repository\org\apache\commons\commons-collections4\4.4\commons-collections4-4.4.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-starter-mail\4.0.1\spring-boot-starter-mail-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\boot\spring-boot-mail\4.0.1\spring-boot-mail-4.0.1.jar;C:\Users\sub\.m2\repository\org\springframework\spring-context-support\7.0.2\spring-context-support-7.0.2.jar;C:\Users\sub\.m2\repository\jakarta\mail\jakarta.mail-api\2.1.5\jakarta.mail-api-2.1.5.jar;C:\Users\sub\.m2\repository\org\eclipse\angus\angus-mail\2.0.5\angus-mail-2.0.5.jar;C:\Users\sub\.m2\repository\com\bucket4j\bucket4j-core\8.10.1\bucket4j-core-8.10.1.jar;C:\Users\sub\.m2\repository\io\jsonwebtoken\jjwt-api\0.13.0\jjwt-api-0.13.0.jar;C:\Users\sub\.m2\repository\io\jsonwebtoken\jjwt-impl\0.13.0\jjwt-impl-0.13.0.jar;C:\Users\sub\.m2\repository\io\jsonwebtoken\jjwt-jackson\0.12.6\jjwt-jackson-0.12.6.jar;C:\Users\sub\.m2\repository\org\projectlombok\lombok\1.18.42\lombok-1.18.42.jar;C:\Users\sub\.m2\repository\org\hibernate\orm\hibernate-envers\7.2.0.Final\hibernate-envers-7.2.0.Final.jar;C:\Users\sub\.m2\repository\org\hibernate\orm\hibernate-core\7.2.0.Final\hibernate-core-7.2.0.Final.jar;C:\Users\sub\.m2\repository\jakarta\persistence\jakarta.persistence-api\3.2.0\jakarta.persistence-api-3.2.0.jar;C:\Users\sub\.m2\repository\jakarta\transaction\jakarta.transaction-api\2.0.1\jakarta.transaction-api-2.0.1.jar;C:\Users\sub\.m2\repository\com\fasterxml\classmate\1.7.1\classmate-1.7.1.jar;C:\Users\sub\.m2\repository\jakarta\inject\jakarta.inject-api\2.0.1\jakarta.inject-api-2.0.1.jar;C:\Users\sub\.m2\repository\org\antlr\antlr4-runtime\4.13.2\antlr4-runtime-4.13.2.jar;C:\Users\sub\.m2\repository\org\jboss\logging\jboss-logging\3.6.1.Final\jboss-logging-3.6.1.Final.jar;C:\Users\sub\.m2\repository\org\hibernate\models\hibernate-models\1.0.1\hibernate-models-1.0.1.jar;C:\Users\sub\.m2\repository\io\smallrye\jandex\3.3.2\jandex-3.3.2.jar;C:\Users\sub\.m2\repository\net\logstash\logback\logstash-logback-encoder\8.0\logstash-logback-encoder-8.0.jar;C:\Users\sub\.m2\repository\org\springframework\ai\spring-ai-ollama-spring-boot-starter\1.0.0-M6\spring-ai-ollama-spring-boot-starter-1.0.0-M6.jar;C:\Users\sub\.m2\repository\org\springframework\ai\spring-ai-spring-boot-autoconfigure\1.0.0-M6\spring-ai-spring-boot-autoconfigure-1.0.0-M6.jar;C:\Users\sub\.m2\repository\org\springframework\ai\spring-ai-ollama\1.0.0-M6\spring-ai-ollama-1.0.0-M6.jar;C:\Users\sub\.m2\repository\org\springframework\ai\spring-ai-core\1.0.0-M6\spring-ai-core-1.0.0-M6.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\module\jackson-module-jsonSchema\2.20.1\jackson-module-jsonSchema-2.20.1.jar;C:\Users\sub\.m2\repository\javax\validation\validation-api\1.1.0.Final\validation-api-1.1.0.Final.jar;C:\Users\sub\.m2\repository\io\swagger\core\v3\swagger-annotations\2.2.25\swagger-annotations-2.2.25.jar;C:\Users\sub\.m2\repository\com\github\victools\jsonschema-module-swagger-2\4.37.0\jsonschema-module-swagger-2-4.37.0.jar;C:\Users\sub\.m2\repository\org\antlr\ST4\4.3.4\ST4-4.3.4.jar;C:\Users\sub\.m2\repository\org\antlr\antlr-runtime\3.5.3\antlr-runtime-3.5.3.jar;C:\Users\sub\.m2\repository\io\projectreactor\reactor-core\3.8.1\reactor-core-3.8.1.jar;C:\Users\sub\.m2\repository\org\reactivestreams\reactive-streams\1.0.4\reactive-streams-1.0.4.jar;C:\Users\sub\.m2\repository\org\springframework\spring-messaging\7.0.2\spring-messaging-7.0.2.jar;C:\Users\sub\.m2\repository\io\micrometer\context-propagation\1.2.0\context-propagation-1.2.0.jar;C:\Users\sub\.m2\repository\com\knuddels\jtokkit\1.1.0\jtokkit-1.1.0.jar;C:\Users\sub\.m2\repository\com\github\victools\jsonschema-generator\4.37.0\jsonschema-generator-4.37.0.jar;C:\Users\sub\.m2\repository\com\github\victools\jsonschema-module-jackson\4.37.0\jsonschema-module-jackson-4.37.0.jar;C:\Users\sub\.m2\repository\org\springframework\ai\spring-ai-retry\1.0.0-M6\spring-ai-retry-1.0.0-M6.jar;C:\Users\sub\.m2\repository\org\springframework\retry\spring-retry\2.0.11\spring-retry-2.0.11.jar;C:\Users\sub\.m2\repository\org\springframework\spring-webflux\7.0.2\spring-webflux-7.0.2.jar;C:\Users\sub\.m2\repository\com\fasterxml\jackson\datatype\jackson-datatype-jsr310\2.20.1\jackson-datatype-jsr310-2.20.1.jar
\ No newline at end of file
diff --git a/backend/data/dependency-check/publishedSuppressions.xml b/backend/data/dependency-check/publishedSuppressions.xml
new file mode 100644
index 000000000..e34b692a1
--- /dev/null
+++ b/backend/data/dependency-check/publishedSuppressions.xml
@@ -0,0 +1,2056 @@
+
+
+
+
+ ^pkg:maven/org\.vaadin\.addon/easyuploads@.*$
+ cpe:/a:vaadin:vaadin
+
+
+
+ ^pkg:maven/ch\.qos\.logback/logback-classic@.*$
+ cpe:/a:qos:slf4j
+
+
+
+ ^pkg:maven/org\.eclipse\.microprofile\.config/microprofile-config-api@.*$
+ cpe:/a:payara:payara
+
+
+
+ ^pkg:maven/org\.apache\.james/apache-mime4j@.*$
+ cpe:/a:apache:james
+
+
+
+ ^pkg:maven/org\.postgresql/r2dbc-postgresql@.*$
+ cpe:/a:postgresql:postgresql
+
+
+
+ ^pkg:maven/org\.mockito/mockito-junit-jupiter@.*$
+ cpe:/a:junit:junit4
+
+
+
+ ^pkg:maven/org\.robolectric/junit@.*$
+ cpe:/a:junit:junit4
+
+
+
+ ^pkg:maven/com\.openhtmltopdf/openhtmltopdf-jsoup-dom-converter@.*$
+ cpe:/a:jsoup:jsoup
+
+
+
+ ^pkg:maven/com\.vladsch\.flexmark/flexmark-ext-xwiki-macros@.*$
+ cpe:/a:xwiki:xwiki
+
+
+
+ ^pkg:maven/com\.vladsch\.flexmark/flexmark-ext-macros@.*$
+ cpe:/a:processing:processing
+
+
+
+ ^pkg:maven/org\.jfrog\.artifactory\.client/artifactory-java-client-api@.*$
+ cpe:/a:jfrog:artifactory
+
+
+
+ ^pkg:maven/org\.jetbrains\.kotlin/kotlin-annotation-processing-gradle@.*$
+ cpe:/a:processing:processing
+
+
+
+ ^pkg:maven/org\.testcontainers/mysql@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/org\.mariadb/r2dbc-mariadb@.*$
+ cpe:/a:mariadb:mariadb
+
+
+
+ ^pkg:maven/org\.testcontainers/mariadb@.*$
+ cpe:/a:mariadb:mariadb
+
+
+
+ ^pkg:maven/org\.apache\.camel/camel-activemq@.*$
+ cpe:/a:apache:activemq
+
+
+
+ ^pkg:maven/org\.jruby\.rack/jruby-rack@.*$
+ cpe:/a:jruby:jruby
+
+
+
+ ^pkg:maven/org\.jruby/dirgra@.*$
+ cpe:/a:jruby:jruby
+
+
+
+ ^pkg:maven/org\.apache\.datasketches/datasketches-java@.*$
+ cpe:/a:sketch:sketch
+
+
+
+ ^pkg:maven/org\.locationtech\.spatial4j/spatial4j@.*$
+ cpe:/a:pro_search:pro_search
+
+
+
+ ^pkg:maven/com\.ko-sys\.av/airac@.*$
+ cpe:/a:keybase:keybase
+
+
+
+ ^pkg:maven/software\.aws\.rds/aws-mysql-jdbc@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/net\.openhft/chronicle-wire@.*$
+ cpe:/a:wire:wire
+
+
+
+ ^pkg:maven/com\.zendesk/mysql-binlog-connector-java@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/io\.debezium/debezium-connector-mysql@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/org\.apache\.hbase/hbase-zookeeper@.*$
+ cpe:/a:apache:zookeeper
+
+
+
+ ^pkg:maven/org\.ejbca\.cvc/cert-cvc@.*$
+ cpe:/a:primekey:ejbca
+
+
+
+ ^pkg:maven/org\.apache\.twill/twill-zookeeper@.*$
+ cpe:/a:apache:zookeeper
+
+
+
+ ^pkg:maven/org\.pf4j/pf4j@.*$
+ cpe:/a:sonatype:nexus
+
+
+
+ ^pkg:maven/org\.apache\.iceberg/iceberg-hive-metastore@.*$
+ cpe:/a:apache:hive
+
+
+
+ ^pkg:maven/org\.apache\.hbase/hbase-hadoop-compat@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:maven/org\.apache\.flink/flink-rpc-akka-loader@.*$
+ cpe:/a:akka:akka
+
+
+
+ ^pkg:maven/org\.apache\.flink/flink-hadoop-fs@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:maven/com\.azure\.resourcemanager/azure-resourcemanager-appplatform@.*$
+ cpe:/a:microsoft:platform_sdk
+
+
+
+ ^pkg:maven/org\.clojure/data\.priority-map@.*$
+ cpe:/a:priority-software:priority
+
+
+
+ ^pkg:maven/com\.amazonaws/aws-java-sdk-prometheus@.*$
+ cpe:/a:prometheus:prometheus
+
+
+
+ ^pkg:maven/org\.hibernate/hibernate-commons-annotations@.*$
+ cpe:/a:hibernate:hibernate_orm
+
+
+
+ ^pkg:maven/software\.aws\.rds/aws-mysql-jdbc@.*$
+ cpe:/a:mariadb:mariadb
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/org\.jfrog\.artifactory\.client/artifactory-java-client-httpClient@.*$
+ cpe:/a:jfrog:artifactory
+
+
+
+ ^pkg:maven/io\.opentracing\.contrib/opentracing-apache-httpclient@.*$
+ cpe:/a:apache:httpclient
+
+
+
+ ^pkg:maven/org\.codehaus\.jackson/jackson-xc@.*$
+ cpe:/a:fasterxml:jackson-databind
+
+
+
+ 5b8f86fea035328fc9e8c660773037a3401ce25f
+ .*
+
+
+
+ ^pkg:maven/org\.wildfly\.wildfly-http-client/wildfly-http-ejb-client@.*$
+ cpe:/a:redhat:jboss-ejb-client
+
+
+
+ ^pkg:maven/org\.jgroups\.kubernetes/jgroups-kubernetes@.*$
+ cpe:/a:redhat:jgroups
+
+
+
+ ^pkg:maven/org\.apache\.james/queue-activemq-guice@.*$
+ cpe:/a:apache:activemq
+
+
+
+ ^pkg:maven/io\.projectreactor\.rabbitmq/reactor-rabbitmq@.*$
+ cpe:/a:vmware:rabbitmq
+
+
+
+ ^pkg:maven/org\.apache\.james/james-server-queue-activemq@.*$
+ cpe:/a:apache:activemq
+
+
+
+ ^pkg:maven/org\.apache\.james/apache-jsieve-core@.*$
+ cpe:/a:apache:james
+
+
+
+ ^.*$
+ CVE-2021-4277
+
+
+
+ ^pkg:maven/com\.google\.crypto\.tink/apps-webpush@.*$
+ cpe:/a:google:google_apps
+
+
+
+ ^pkg:maven/org\.apache\.hadoop\.thirdparty/hadoop-shaded-guava@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:maven/com\.datastax\.oss/native-protocol@.*$
+ cpe:/a:apache:cassandra
+
+
+
+ ^pkg:maven/org\.openrewrite\.recipe/rewrite-jhipster@.*$
+ cpe:/a:jhipster:jhipster
+
+
+
+ ^pkg:maven/jakarta\.resource/jakarta\.resource-api@.*$
+ cpe:/a:payara:payara
+
+
+
+ ^pkg:maven/org\.eclipse\.microprofile\.jwt/microprofile-jwt-auth-api@.*$
+ cpe:/a:payara:payara
+
+
+
+ ^pkg:maven/org\.apache\.hadoop\.thirdparty/hadoop-shaded-protobuf_3_7@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:maven/org\.codehaus\.woodstox/stax2-api@.*$
+ cpe:/a:fasterxml:woodstox
+
+
+
+ ^pkg:maven/com\.oracle\.database\.nls/orai18n@.*$
+ cpe:/a:oracle:database
+
+
+
+ ^pkg:maven/com\.oracle\.database\.nls/orai18n@.*$
+ cpe:/a:oracle:oracle_database
+
+
+
+ ^pkg:maven/org\.apache\.iceberg/iceberg-orc@.*$
+ cpe:/a:apache:orc
+
+
+
+ ^pkg:maven/org\.apache\.iceberg/iceberg-flink-1\.15@.*$
+ cpe:/a:apache:flink
+
+
+
+ ^pkg:maven/com\.googlecode\.javaewah/JavaEWAH@.*$
+ cpe:/a:google:google_search
+
+
+
+ ^pkg:maven/org\.apache\.flink/flink-s3-fs-hadoop@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:maven/com\.microsoft\.azure/azure-cosmosdb-direct@.*$
+ cpe:/a:microsoft:platform_sdk
+
+
+
+ ^pkg:maven/org\.apache\.spark/spark-token-provider-kafka-0-10_2\.12@.*$
+ cpe:/a:apache:kafka
+
+
+
+ ^pkg:maven/com\.github\.luben/zstd-jni@.*$
+ cpe:/a:freebsd:freebsd
+
+
+
+ ^pkg:maven/io\.kamon/kamon-prometheus_2\.13@.*$
+ cpe:/a:prometheus:prometheus
+
+
+
+ ^pkg:maven/com\.github\.dasniko/testcontainers-keycloak@.*$
+ cpe:/a:keycloak:keycloak
+
+
+
+ ^pkg:maven/org\.apache\.kerby/zookeeper-backend@.*$
+ cpe:/a:apache:zookeeper
+
+
+
+ ^pkg:maven/javax\.resource/connector@.*$
+ cpe:/a:sun:j2ee
+
+
+
+ ^pkg:maven/org\.springframework\.cloud/spring-cloud-sleuth-autoconfigure@.*$
+ cpe:/a:vmware:spring_cloud_config
+
+
+
+ ^pkg:maven/org\.jfrog\.artifactory\.client/artifactory-java-client-services@.*$
+ cpe:/a:jfrog:artifactory
+
+
+
+ ^pkg:maven/org\.springframework\.integration/spring-integration-ftp@.*$
+ cpe:/a:vmware:spring_integration
+
+
+
+ ^pkg:maven/org\.jboss\.resteasy\.microprofile/microprofile-config@.*$
+ cpe:/a:redhat:resteasy
+
+
+
+ ^pkg:maven/org\.apache\.ignite/ignite-log4j2@.*$
+ cpe:/a:apache:log4j
+
+
+
+ ^pkg:maven/org\.apache\.directory\.api/api-ldap-net-mina@.*$
+ cpe:/a:apache:mina
+
+
+
+ ^pkg:maven/io\.quarkiverse\.openapi\.generator/quarkus-openapi-generator@.*$
+ cpe:/a:openapi-generator:openapi_generator
+
+
+
+ ^pkg:nuget/FluentFTP@.*$
+ cpe:/a:ftp:ftp
+
+
+
+ ^pkg:nuget/KubernetesClient@.*$
+ cpe:/a:kubernetes:kubernetes
+
+
+
+ ^pkg:maven/org\.apache\.sling/org\.apache\.sling\.commons\.johnzon@.*$
+ cpe:/a:apache:sling_commons_json
+
+
+
+ ^pkg:nuget/AspNetCoreRateLimit\.Redis@.*$
+ cpe:/a:asp-project:asp-project
+
+
+
+ ^pkg:maven/org\.jruby/jzlib@.*$
+ cpe:/a:jruby:jruby
+
+
+
+ ^pkg:maven/org\.jboss\.resteasy\.microprofile/.*$
+ cpe:/a:redhat:resteasy
+
+
+
+ ^pkg:maven/org\.jboss\.resteasy\.microprofile/microprofile-rest-client@.*$
+ cpe:/a:redhat:resteasy
+
+
+
+ ^pkg:maven/org\.apache\.sling/org\.apache\.sling\.commons\.osgi@.*$
+ cpe:/a:apache:sling
+
+
+
+ ^pkg:nuget/Minio\.AspNetCore@.*$
+ cpe:/a:minio:minio
+
+
+
+ ^pkg:maven/org\.apache\.thrift/libfb303@.*$
+ cpe:/a:apache:thrift
+
+
+
+ ^pkg:maven/org\.apache\.cxf/cxf-rt-bindings-soap@.*$
+ cpe:/a:apache:soap
+
+
+
+ ^pkg:maven/com\.itextpdf\.licensing/licensing-base@.*$
+ cpe:/a:itextpdf:itext
+
+
+
+ ^pkg:maven/com\.itextpdf\.licensing/licensing-remote@.*$
+ cpe:/a:itextpdf:itext
+
+
+
+ ^pkg:maven/io\.github\.detekt\.sarif4k/sarif4k-jvm@.*$
+ cpe:/a:detekt:detekt
+
+
+
+ ^pkg:maven/com\.lightbend\.akka\.grpc/.*$
+ cpe:/a:akka:akka
+ cpe:/a:lightbend:akka
+
+
+
+ ^pkg:maven/com\.lightbend\.akka/akka-persistence-r2dbc.*$
+ cpe:/a:akka:akka
+ cpe:/a:lightbend:akka
+
+
+
+ ^pkg:maven/com\.lightbend\.akka/akka-projection-.*$
+ cpe:/a:akka:akka
+ cpe:/a:lightbend:akka
+
+
+
+ ^pkg:maven/org\.apache\.jackrabbit/oak-.*$
+ cpe:/a:apache:jackrabbit
+
+
+
+ ^pkg:maven/org\.apache\.jackrabbit/oak-core@.*$
+ cpe:/a:apache:jackrabbit
+
+
+
+ ^pkg:maven/com\.vaadin/vaadin-swing-kit-flow@.*$
+ cpe:/a:vaadin:flow
+
+
+
+ ^pkg:maven/org\.apache\.sling/org\.apache\.sling\.commons\.johnzon@.*$
+ cpe:/a:apache:sling
+
+
+
+ ^pkg:maven/org\.apache\.geronimo\.specs/geronimo-saaj_1\.3_spec@.*$
+ cpe:/a:apache:soap
+
+
+
+ ^pkg:maven/org\.ops4j\.pax\.logging/pax-logging-log4j2@.*$
+ cpe:/a:apache:log4j
+
+
+
+ ^pkg:maven/software\.amazon\.awssdk\.crt/aws-crt@.*$
+ cpe:/a:amazon:aws-sdk-java
+
+
+
+ ^pkg:maven/com\.adobe\.cq/core\.wcm\.components\.core@.*$
+ cpe:/a:adobe:download_manager
+
+
+
+ ^pkg:maven/com\.adobe\.cq/core\.wcm\.components\.core@.*$
+ cpe:/a:adobe:experience_manager
+
+
+
+ ^pkg:maven/com\.adobe\.cq/core\.wcm\.components\.core@.*$
+ cpe:/a:adobe:experience_manager_forms
+
+
+
+ ^pkg:maven/com\.adobe\.cq/core\.wcm\.components\.core@.*$
+ cpe:/a:adobe:form_client
+
+
+
+ ^pkg:maven/com\.adobe\.cq/core\.wcm\.components\.core@.*$
+ cpe:/a:list_site_pro:list_site_pro
+
+
+
+ ^pkg:maven/org\.springframework\.plugin/spring-plugin-core@.*$
+ cpe:/a:vmware:spring
+
+
+
+ ^pkg:maven/org\.springframework(?!\.kafka).*$
+ CVE-2023-34040
+
+
+
+ ^pkg:maven/org\.logback-extensions/logback-ext-spring@.*$
+ cpe:/a:qos:logback
+
+
+
+ ^pkg:npm/mysql@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/net\.rossillo\.mvc\.cache/spring-mvc-cache-control@.*$
+ cpe:/a:spring:spring
+
+
+
+ ^pkg:maven/ch\.qos\.logback\.contrib/logback-json-core@.*$
+ cpe:/a:json-c:json-c
+
+
+
+ ^pkg:maven/ch\.qos\.logback\.contrib/logback-json-classic@.*$
+ cpe:/a:json-c:json-c
+
+
+
+ ^pkg:maven/io\.asyncer/r2dbc-mysql@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/io\.netty\.incubator/netty-incubator-codec-native-quic@.*$
+ cpe:/a:chromium:chromium
+
+
+
+ ^pkg:maven/xalan/xalan@.*$
+ cpe:/a:apache:commons_bcel
+
+
+
+ ^pkg:nuget/CommandLineParser@.*$
+ cpe:/a:line:line
+
+
+
+ ^pkg:maven/org\.flywaydb/flyway-database-postgresql@.*$
+ cpe:/a:postgresql:postgresql
+
+
+
+ ^pkg:maven/net\.lbruun\.springboot/preliquibase-spring-boot-starter@.*$
+ cpe:/a:liquibase:liquibase
+
+
+
+ ^pkg:maven/rubygems/.*@.*$
+ cpe:/a:rubygems:rubygems
+
+
+
+ ^pkg:maven/org\.apache\.parquet/parquet-avro@.*$
+ cpe:/a:apache:avro
+
+
+
+ ^pkg:maven/org\.apache\.camel/camel-reactive-executor-tomcat@.*$
+ cpe:/a:apache_tomcat:apache_tomcat
+
+
+
+ ^pkg:maven/info\.picocli/picocli@.*$
+ cpe:/a:line:line
+
+
+
+ ^pkg:maven/io\.r2dbc/r2dbc-mssql@.*$
+ cpe:/a:microsoft:sql_server
+
+
+
+ ^pkg:maven/org\.thymeleaf\.extras/thymeleaf-extras-java8time@.*$
+ cpe:/a:thymeleaf:thymeleaf
+
+
+
+ ^pkg:maven/org\.keycloak/keycloak-model-infinispan@.*$
+ cpe:/a:infinispan:infinispan
+
+
+
+ ^pkg:maven/org\.jgroups\.azure/jgroups-azure@.*$
+ cpe:/a:redhat:jgroups
+
+
+
+ ^pkg:maven/com\.bornium/oauth2-openid@.*$
+ cpe:/a:openid:openid
+
+
+
+ ^pkg:maven/org\.hsqldb/hsqldb@.*$
+ cpe:/a:hyper:hyper
+
+
+
+ ^pkg:maven/org\.jboss\.activemq\.artemis\.integration/artemis-wildfly-integration@.*$
+ cpe:/a:redhat:wildfly
+
+
+
+ ^pkg:npm/bare-os@.*$
+ cpe:/a:bareos:bareos
+
+
+
+ ^pkg:maven/org\.apache\.camel\.quarkus/camel-quarkus-core@.*$
+ cpe:/a:apache:camel
+
+
+
+ ^pkg:maven/org\.apache\.rat/apache-rat@.*$
+ cpe:/a:line:line
+
+
+
+ ^pkg:nuget/MagicFileEncoding@.*$
+ cpe:/a:file:file
+
+
+
+ ^pkg:nuget/MongoDB\.Bson@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:maven/io\.opentelemetry\.contrib/opentelemetry-prometheus-client-bridge@.*$
+ cpe:/a:prometheus:prometheus
+
+
+
+ ^pkg:maven/org\.springframework\.batch\.extensions/spring-batch-excel@.*$
+ cpe:/a:pivotal_software:spring_batch
+
+
+
+ ^pkg:maven/org\.glassfish(?!\.main).*$
+ cpe:/a:eclipse:glassfish
+
+
+
+ ^pkg:maven/org\.apache\.shiro\.crypto/shiro.*@2.0.0$
+ CVE-2023-34478
+ CVE-2023-46749
+ CVE-2023-46750
+
+
+
+ ^pkg:maven/org\.apache\.shiro/shiro.*@2.0.0$
+ CVE-2023-34478
+ CVE-2023-46749
+ CVE-2023-46750
+
+
+
+^pkg:(?!maven/org\.clojure/clojure@).*$
+cpe:/a:clojure:clojure
+
+
+
+ ^pkg:maven/io\.pivotal\.cfenv/java-cfenv@.*$
+ cpe:/a:vmware:spring_framework
+
+
+
+ ^pkg:maven/io\.pivotal\.cfenv/java-cfenv-jdbc@.*$
+ cpe:/a:vmware:spring_framework
+
+
+
+ ^pkg:maven/io\.pivotal\.cfenv/java-cfenv-boot@.*$
+ cpe:/a:vmware:spring_framework
+
+
+
+ ^pkg:maven/org\.togglz/togglz-mongodb@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:nuget/dbup-postgresql@.*$
+ cpe:/a:postgresql:postgresql
+
+
+
+ ^pkg:maven/org\.eclipse\.jetty\.toolchain/.*@.*$
+ cpe:/a:jetty:jetty
+ cpe:/a:eclipse:jetty
+
+
+
+^pkg:generic/Mono.Cecil@.*$
+cpe:/a:cecil:cecil
+
+
+
+ ^pkg:maven/com\.google\.http-client/google-http-client-protobuf@.*$
+ cpe:/a:google:protobuf-java
+
+
+
+ ^pkg:maven/io\.zipkin\.contrib\.brave-propagation-w3c/brave-propagation-tracecontext@.*$
+ cpe:/a:brave:brave
+
+
+
+ ^pkg:maven/io\.micrometer/micrometer-tracing-bridge-brave@.*$
+ cpe:/a:brave:brave
+
+
+
+^pkg:maven/org\.junit\..*/junit-.*@.*$
+cpe:/a:1e:platform
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-jarmode-tools@.*$
+ cpe:/a:vmware:tools
+
+
+
+ ^pkg:maven/org\.apache\.sandesha2/sandesha2.*$
+ cpe:/a:apache:axis2:
+ cpe:/a:apache:axis:
+
+
+
+^pkg:maven/org\.apache\.axis2.*$
+cpe:/a:apache:axis:
+
+
+
+ ^pkg:maven/org\.eclipse\.jetty/jetty-openid@.*$
+ cpe:/a:openid:openid
+
+
+
+ ^pkg:maven/org\.springframework\.security/spring-security-oauth2-resource-server@.*$
+ cpe:/a:vmware:server
+
+
+
+ ^pkg:maven/io\.pivotal\.cfenv/java-cfenv-boot@.*$
+ cpe:/a:vmware:spring_boot
+
+
+
+ ^pkg:maven/com\.yahoo\.datasketches/sketches-core@.*$
+ cpe:/a:sketch:sketch
+
+
+
+ ^pkg:maven/com\.azure/azure-core-http-netty@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/com\.azure/azure-core@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/org\.tukaani/xz@.*$
+ cpe:/a:tukaani:xz
+
+
+
+ ^pkg:maven/org\.bouncycastle/bc(pg)?-fips@.*$
+ cpe:/a:bouncycastle:legion-of-the-bouncy-castle
+
+
+
+ ^pkg:maven/org\.bouncycastle/bc(pg)?-fips@.*$
+ cpe:/a:bouncycastle:bouncy_castle_for_java
+
+
+
+ ^pkg:maven/commons-discovery/commons-discovery@.*$
+ cpe:/a:spirit-project:spirit
+
+
+
+ ^pkg:maven/org\.jmdns/jmdns@.*$
+ cpe:/a:openhab:openhab
+
+
+
+ ^pkg:maven/com\.azure/azure-identity@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/com\.apollographql\.federation/federation-graphql-java-support@.*$
+ cpe:/a:apollo(_project)?:apollo.*
+
+
+
+ ^pkg:maven/fi\.solita\.clamav/clamav-client@.*$
+ cpe:/a:clamav:clamav
+
+
+
+ ^pkg:maven/jakarta\.json/jakarta\.json-api@.*$
+ cpe:/a:eclipse:glassfish
+
+
+
+ ^pkg:maven/org\.glassfish\.jaxb/jaxb-runtime@.*$
+ cpe:/a:eclipse:glassfish
+
+
+
+ ^pkg:maven/org\.apache\.ftpserver/ftplet-api@.*$
+ cpe:/a:apache:apache_http_server
+
+
+
+ ^pkg:maven/org\.apache\.ftpserver/ftpserver-core@.*$
+ cpe:/a:apache:apache_http_server
+
+
+
+^pkg:maven/org\.apache\.ftpserver/ftplet-api@.*$
+^cpe:/a:apache:mina:.*
+
+
+
+^pkg:maven/io\.prometheus/prometheus-.*$
+cpe:/a:prometheus:prometheus
+
+
+
+ ^(mysql:mysql-connector-java|com\.mysql:mysql-connector-j|org\.drizzle\.jdbc:drizzle-jdbc):.*$
+ cpe:/a:mysql:mysql:
+ cpe:/a:oracle:mysql:
+
+
+
+
+ ^pkg:nuget/IronPython@.*$
+ cpe:/a:python:python
+
+
+
+ ^pkg:(?!maven/com\.graphql-java/graphql-java@).*$
+ cpe:/a:graphql-java:graphql-java:
+
+
+
+ ^pkg:maven/com\.azure/azure-json@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/org\.agrona/agrona@.*$
+ cpe:/a:protonmail:protonmail
+
+
+
+ ^pkg:maven/org\.mortbay\.jasper/apache-el@.*$
+ cpe:/a:eclipse:jetty
+
+
+
+ ^pkg:maven/com\.maciejwalkowiak\.spring/wiremock-spring-boot@.*$
+ cpe:/a:wiremock:wiremock
+ cpe:/a:wire:wire
+
+
+
+ ^pkg:maven/com\.azure/azure-core-amqp@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/io\.etcd/jetcd-.*@.*$
+ cpe:/a:redhat:etcd
+ cpe:/a:etcd:etcd
+
+
+
+ ^pkg:maven/com\.azure/azure-core-management@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/com\.amazonaws/aws-java-sdk-opensearch@.*$
+ cpe:/a:amazon:opensearch
+
+
+
+ ^pkg:maven/org\.webjars\.npm/url-parse@.*$
+ cpe:/a:parse-url(_project)?:parse-url.*
+
+
+
+ ^pkg:maven/com\.datomic/memcache-asg-java-client@.*$
+ cpe:/a:memcache(_project)?:memcache.*
+
+
+
+ ^pkg:maven/com\.amazonaws/aws-java-sdk-marketplacedeployment@.*$
+ cpe:/a:amazon:aws_deployment_framework
+
+
+
+ ^pkg:maven/cd\.go\.plugin\.base/gocd-plugin-base@.*$
+ cpe:/a:thoughtworks:gocd
+
+
+
+ .*/node-windows/bin/sudowin/sudo.exe
+ cpe:/a:sudo:sudo
+ cpe:/a:sudo(_project)?:sudo.*
+
+
+
+
+ ^pkg:maven/com\.datomic/memcache-asg-java-client@.*$
+ cpe:/a:memcached:memcached
+
+
+
+ ^pkg:maven/fish\.payara\.security\.connectors/security-connectors-api@.*$
+ cpe:/a:payara:payara
+
+
+
+ ^pkg:maven/com\.microsoft\.azure/msal4j-persistence-extension@.*$
+ cpe:/a:microsoft:authentication_library
+
+
+
+ ^pkg:maven/com\.google\.cloud\.tools/jib-build-plan@.*$
+ cpe:/a:jib(_project)?:jib.*
+
+
+
+ ^pkg:maven/com\.azure/azure-identity-extensions@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/com\.google\.cloud\.opentelemetry/detector-resources-support@.*$
+ cpe:/a:opentelemetry:opentelemetry
+
+
+
+ ^pkg:maven/(?!io\.grpc/).*$
+ cpe:/a:grpc:grpc
+
+
+
+^pkg:maven\/.*$
+cpe:/a:sms:sms
+
+
+
+ ^pkg:maven/com\.sap\.cloud\.db\.jdbc/ngdbc@.*$
+ cpe:/a:sap:hana
+
+
+
+ ^pkg:maven/com\.google\.cloud\.opentelemetry/shared-resourcemapping@.*$
+ cpe:/a:opentelemetry:opentelemetry
+
+
+
+ ^pkg:maven/com\.google\.cloud\.opentelemetry/exporter-metrics@.*$
+ cpe:/a:opentelemetry:opentelemetry
+
+
+
+ ^pkg:maven/dev\.zio/zio-akka-cluster_2\.13@.*$
+ cpe:/a:akka:akka
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-starter-data-rest@.*$
+ cpe:/a:vmware:spring_data_rest
+
+
+
+ ^pkg:nuget/Npgsql\.EntityFrameworkCore\.PostgreSQL@.*$
+ cpe:/a:postgresql:postgresql
+
+
+
+ ^pkg:nuget/System\.Threading\.Tasks\.Extensions@.*$
+ cpe:/a:tasks:tasks
+
+
+
+ ^pkg:maven/com\.splunk\.logging/splunk-library-javalogging@.*$
+ cpe:/a:splunk:splunk
+
+
+
+ ^pkg:nuget/System\.ServiceModel\.NetTcp@.*$
+ cpe:/a:tcp:tcp
+
+
+
+ ^pkg:maven/org\.springframework\.ai/spring-ai-spring-boot-autoconfigure@.*$
+ cpe:/a:vmware:spring_boot
+
+
+
+ ^pkg:nuget/Serilog\.Sinks\.Graylog@.*$
+ cpe:/a:graylog:graylog
+
+
+
+ ^pkg:maven/com\.hazelcast\.marketing/hazelcast-license-extractor@.*$
+ cpe:/a:hazelcast:hazelcast
+
+
+
+ ^pkg:maven/com\.google\.devtools\.ksp/symbol-processing@.*$
+ cpe:/a:processing:processing
+
+
+
+ ^pkg:maven/com\.google\.devtools\.ksp/symbol-processing-cmdline@.*$
+ cpe:/a:processing:processing
+
+
+
+ ^pkg:maven/com\.google\.devtools\.ksp/symbol-processing-api@.*$
+ cpe:/a:processing:processing
+
+
+
+^pkg:maven/com\.azure\.resourcemanager/.*$
+cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/org\.apache\.sling/org\.apache\.sling\.javax\.activation@.*$
+ cpe:/a:apache:sling
+
+
+
+ ^pkg:maven/org\.eclipse\.platform/org\.eclipse\.osgi@.*$
+ cpe:/a:eclipse:platform
+
+
+
+ ^pkg:maven/org\.eclipse\.platform/org\.eclipse\.osgi@.*$
+ cpe:/a:eclipse:equinox
+
+
+
+ ^pkg:maven/io\.nlopez\.compose\.rules/detekt@.*$
+ cpe:/a:detekt:detekt
+
+
+
+ ^pkg:maven/io\.quarkiverse\.wiremock/quarkus-wiremock@.*$
+ cpe:/a:wiremock:wiremock
+
+
+
+ ^pkg:maven/com\.github\.jpmsilva\.jsystemd/jsystemd-core@.*$
+ cpe:/a:systemd(_project)?:systemd.*
+
+
+
+ ^pkg:maven/com\.azure/azure-ai-openai@.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:nuget/Microsoft\.AspNetCore\.Authentication\.OpenIdConnect@.*$
+ cpe:/a:openid:openid_connect
+
+
+
+ ^pkg:composer/phpunit/php-invoker@.*$
+ cpe:/a:phpunit(_project)?:phpunit.*
+
+
+
+ ^pkg:composer/phpunit/php-text-template@.*$
+ cpe:/a:phpunit(_project)?:phpunit.*
+
+
+
+ ^pkg:composer/spatie/laravel-.*$
+ cpe:/a:laravel:laravel
+
+
+
+
+ ^pkg:maven/org\.opensearch\.plugin/transport-netty4-client@.*$
+ cpe:/a:netty:netty
+
+
+
+ ^pkg:npm/opener@.*$
+ cpe:/a:opener(_project)?:opener.*
+
+
+
+ ^pkg:maven/com\.oracle\.database\.nls/orai18n@.*$
+ cpe:/a:oracle:text
+
+
+
+ ^pkg:nuget/Microsoft\.AspNet\.TelemetryCorrelation@.*$
+ cpe:/a:microsoft:asp.net
+
+
+
+ ^pkg:nuget/Akka\.Cluster\.Hosting@.*$
+ cpe:/a:akka:akka
+
+
+
+ ^pkg:maven/org\.eclipse\.core/org\.eclipse\.core\.jobs@.*$
+ cpe:/a:jobs-plugin(_project)?:jobs-plugin.*
+
+
+
+ ^pkg:maven/org\.eclipse\.core/org\.eclipse\.core\.commands@.*$
+ cpe:/a:eclipse:equinox
+
+
+
+ ^pkg:maven/edu\.washington\.cs\.knowitall/opennlp-chunk-models@.*$
+ cpe:/a:apache:opennlp
+
+
+
+ ^pkg:maven/com\.microsoft\.azure/azure-eventhubs.*$
+ cpe:/a:microsoft:azure_cli
+
+
+
+ ^pkg:maven/org\.nokogiri/nekodtd@.*$
+ cpe:/a:nokogiri:nokogiri
+
+
+
+ ^pkg:maven/io\.micrometer/micrometer-registry-prometheus-simpleclient@.*$
+ cpe:/a:prometheus:prometheus
+
+
+
+ ^pkg:maven/opensymphony/oscache@.*$
+ cpe:/a:tag(_project)?:tag.*
+
+
+
+ ^pkg:maven/org\.apache\.directory\.api/api-i18n@.*$
+ cpe:/a:i18n(_project)?:i18n.*
+
+
+
+ ^pkg:maven/io\.github\.x-stream/mxparser@.*$
+ cpe:/a:x-stream:xstream
+
+
+
+ ^pkg:maven/org\.apache\.xmlgraphics/batik-i18n@.*$
+ cpe:/a:apache:xml_graphics_batik
+
+
+
+ ^pkg:maven/org\.openrewrite\.recipe/rewrite-jenkins@.*$
+ cpe:/a:jenkins:github
+
+
+
+ ^pkg:maven/io\.grpc/grpc-netty@.*$
+ cpe:/a:netty:netty
+
+
+
+ ^pkg:maven/io\.grpc/grpc-netty-shaded@.*$
+ cpe:/a:netty:netty
+
+
+
+ ^pkg:maven/edu\.washington\.cs\.knowitall/opennlp-postag-models@.*$
+ cpe:/a:apache:opennlp
+
+
+
+ ^pkg:maven/io\.projectreactor\.netty/reactor-netty.*@.*$
+ cpe:/a:netty:netty
+
+
+
+ ^pkg:maven/org\.ccil\.cowan\.tagsoup/tagsoup@.*$
+ cpe:/a:tag(_project)?:tag.*
+
+
+
+ ^pkg:maven/org\.apache\.groovy/groovy-json@.*$
+ cpe:/a:apache:groovy
+
+
+
+
+ ^pkg:maven/javax\.resource/connector-api@.*$
+ cpe:/a:sun:j2ee
+
+
+
+ ^pkg:maven/io\.quarkus/quarkus-hibernate-validator@.*$
+ cpe:/a:hibernate:hibernate-validator
+
+
+
+ ^pkg:maven/org\.mortbay\.jasper/apache-jsp@.*$
+ cpe:/a:apache:tomcat
+
+
+
+ ^pkg:maven/com\.almworks\.sqlite4java/sqlite4java@.*$
+ cpe:/a:sqlite:sqlite
+
+
+
+ ^pkg:maven/io\.quarkiverse\.wiremock/quarkus-wiremock@.*$
+ cpe:/a:wire:wire
+
+
+
+ ^pkg:maven/org\.jooq.*/jooq-meta-extensions-liquibase@.*$
+ cpe:/a:liquibase:liquibase
+
+
+
+ ^pkg:maven/org\.springframework\.ai/spring-ai-mongodb-atlas-store@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:maven/org\.xmlunit/xmlunit-core@.*$
+ cpe:/a:ada:ada
+
+
+
+ ^pkg:maven/io\.debezium/mysql-binlog-connector-java@.*$
+ cpe:/a:mysql:mysql
+
+
+
+ ^pkg:maven/edu\.washington\.cs\.knowitall/opennlp-tokenize-models@.*$
+ cpe:/a:apache:opennlp
+
+
+
+ ^pkg:nuget/Microsoft\.AspNetCore\.Authentication\.OpenIdConnect@.*$
+ cpe:/a:openid:openid
+
+
+
+ ^pkg:maven/co\.elastic\.apm/.*
+ cpe:/a:elastic:elastic_agent
+ CVE-2019-7617
+
+
+
+ ^pkg:maven/org\.eclipse\.core/org\.eclipse\.core\.expressions@.*$
+ cpe:/a:eclipse:org.eclipse.core.runtime
+
+
+
+ ^pkg:maven/org\.eclipse\.platform/org\.eclipse\.equinox\.supplement@.*$
+ cpe:/a:eclipse:platform
+
+
+
+ ^pkg:maven/com\.hazelcast/hazelcast-eureka-two@.*$
+ cpe:/a:hazelcast:hazelcast
+
+
+
+ ^pkg:maven/com\.pinterest\.ktlint/ktlint-cli-reporter-checkstyle@.*$
+ cpe:/a:checkstyle:checkstyle
+
+
+
+ ^pkg:pypi/(?!sentry@).*$
+ cpe:/a:sentry:sentry:
+
+
+
+ ^pkg:maven/org\.spdx/spdx-java-model-2_X@.*$
+ cpe:/a:x.org:x.org
+
+
+
+ ^pkg:maven/org\.eclipse\.angus/angus-activation@.*$
+ cpe:/a:eclipse:angus_mail
+
+
+
+ ^pkg:maven/com\.azure\.resourcemanager/azure-resourcemanager-msi@.*$
+ cpe:/a:microsoft:azure_identity_sdk
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-starter-liquibase@.*$
+ cpe:/a:liquibase:liquibase
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-liquibase@.*$
+ cpe:/a:liquibase:liquibase
+
+
+
+ ^pkg:maven/io\.micronaut\.jsonschema/micronaut-json-schema-utils@.*$
+ cpe:/a:utils(_project)?:utils.*
+ cpe:/a:cron-utils(_project)?:cron-utils.*
+
+
+
+ ^pkg:maven/jakarta\.enterprise/jakarta\.enterprise\.lang-model@.*$
+ cpe:/a:model(_project)?:model.*
+
+
+
+ ^pkg:maven/org\.jetbrains\.exposed/exposed-kotlin-datetime@.*$
+ cpe:/a:jetbrains:kotlin
+
+
+
+ ^pkg:maven/io\.opentelemetry/opentelemetry-exporter-prometheus@.*$
+ cpe:/a:prometheus:prometheus
+
+
+
+ ^pkg:maven/com\.solace/solace-messaging-client@.*$
+ cpe:/a:solace:pubsub\+
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-mongodb@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-starter-mongodb@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-data-mongodb@.*$
+ cpe:/a:mongodb:mongodb
+
+
+
+ ^pkg:maven/org\.apache\.hadoop\.thirdparty/hadoop-shaded-protobuf_3_25@.*$
+ cpe:/a:apache:hadoop
+
+
+
+ ^pkg:(?!maven/nu\.validator/validator@|npm/vnu-jar).*
+ cpe:/a:validator:validator
+
+
+
+ ^pkg:maven/net\.java\.dev\.jna/jna-jpms@.*$
+ cpe:/a:oracle:java_se
+
+
+
+ ^pkg:maven/org\.apache\.felix/org\.apache\.felix\.framework@.*$
+ cpe:/a:sun:sun_ftp
+
+
+
+ ^pkg:nuget/DotNumerics@.*$
+ cpe:/a:lapack_project:lapack
+
+
+
+ ^pkg:nuget/DotNumerics@.*$
+ cpe:/a:singular:singular
+
+
+
+ ^pkg:maven/org\.testcontainers/testcontainers-postgresql@.*$
+ cpe:/a:postgresql:postgresql
+
+
+
+ ^pkg:maven/org\.apache\.cxf\.karaf/cxf-karaf-commands@.*$
+ cpe:/a:apache:karaf
+
+
+
+ ^pkg:maven/org\.springframework\.boot/spring-boot-batch@.*$
+ cpe:/a:pivotal_software:spring_batch
+
+
\ No newline at end of file
diff --git a/backend/data/dependency-check/publishedSuppressions.xml.properties b/backend/data/dependency-check/publishedSuppressions.xml.properties
new file mode 100644
index 000000000..446008860
--- /dev/null
+++ b/backend/data/dependency-check/publishedSuppressions.xml.properties
@@ -0,0 +1,2 @@
+#Mon Mar 09 22:21:15 CET 2026
+LAST_UPDATED=1773091275
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 000000000..017bb7c1d
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,373 @@
+
+
+ 4.0.0
+
+ ch.goodone.angularai
+ angularai-parent
+ 1.1.1-SNAPSHOT
+ ../pom.xml
+
+ aibackend
+ aibackend
+ Demo project for Spring Boot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 21
+ 21
+ src/main/java
+ src/test/java
+ **/*Test.java
+ target/site/jacoco/jacoco.xml
+
+
+
+ org.springframework.boot
+ spring-boot-h2console
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ io.micrometer
+ micrometer-registry-prometheus
+
+
+ io.micrometer
+ micrometer-registry-cloudwatch2
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ de.codecentric
+ spring-boot-admin-starter-client
+ ${spring-boot-admin.version}
+
+
+
+ com.h2database
+ h2
+ runtime
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+ com.atlassian.oai
+ swagger-request-validator-mockmvc
+ 2.44.1
+ test
+
+
+ javax.xml.bind
+ jaxb-api
+ 2.3.1
+
+
+ jakarta.xml.bind
+ jakarta.xml.bind-api
+
+
+ org.glassfish.jaxb
+ jaxb-runtime
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ 2.8.5
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-database-postgresql
+
+
+ com.github.ua-parser
+ uap-java
+ 1.6.1
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+ com.bucket4j
+ bucket4j-core
+ 8.10.1
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.13.0
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.13.0
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+ org.hibernate.orm
+ hibernate-envers
+
+
+ net.logstash.logback
+ logstash-logback-encoder
+ 8.0
+
+
+ org.springframework.ai
+ spring-ai-starter-model-ollama
+
+
+ org.springframework.ai
+ spring-ai-starter-model-openai
+
+
+ com.pgvector
+ pgvector
+ 0.1.6
+
+
+ org.awaitility
+ awaitility
+ test
+
+
+
+
+
+
+ src/main/resources
+ false
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+
+ properties
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ @{argLine} -javaagent:${org.mockito:mockito-core:jar} -XX:+EnableDynamicAgentLoading
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ full
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+ 3.6.0
+
+ ${project.basedir}/checkstyle.xml
+ true
+ true
+ false
+
+
+
+ validate
+ validate
+
+ check
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ build-info
+
+
+
+
+
+ org.springdoc
+ springdoc-openapi-maven-plugin
+ 1.4
+
+
+ integration-test
+
+ generate
+
+
+
+
+ http://localhost:8080/v3/api-docs
+ openapi.json
+ ${project.build.directory}
+ true
+
+
+
+
+
+
+
+ openapi
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ pre-integration-test
+
+ start
+
+
+
+ post-integration-test
+
+ stop
+
+
+
+
+
+ org.springdoc
+ springdoc-openapi-maven-plugin
+
+ false
+
+
+
+
+
+
+ generate-erd
+
+
+
+ de.elnarion.maven
+ plantuml-generator-maven-plugin
+ 3.0.1
+
+
+ generate-plantuml-diagram
+
+ generate
+
+ process-classes
+
+
+ ch.goodone.angularai.backend.model
+
+ ${project.basedir}/../doc/ai/db
+ erd.puml
+ false
+ true
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-antrun-plugin
+ 3.1.0
+
+
+ remove-package-names
+ process-classes
+
+ run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/snyk-backend.sarif.json b/backend/snyk-backend.sarif.json
new file mode 100644
index 000000000..59eb236df
Binary files /dev/null and b/backend/snyk-backend.sarif.json differ
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/AibackendApplication.java b/backend/src/main/java/ch/goodone/angularai/backend/AibackendApplication.java
new file mode 100644
index 000000000..b578e3b91
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/AibackendApplication.java
@@ -0,0 +1,26 @@
+package ch.goodone.angularai.backend;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+
+@SpringBootApplication(exclude = {
+ org.springframework.ai.model.openai.autoconfigure.OpenAiAudioTranscriptionAutoConfiguration.class,
+ org.springframework.ai.model.openai.autoconfigure.OpenAiAudioSpeechAutoConfiguration.class,
+ org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration.class,
+ org.springframework.ai.model.openai.autoconfigure.OpenAiEmbeddingAutoConfiguration.class,
+ org.springframework.ai.model.openai.autoconfigure.OpenAiImageAutoConfiguration.class,
+ org.springframework.ai.model.openai.autoconfigure.OpenAiModerationAutoConfiguration.class
+})
+@ConfigurationPropertiesScan
+public class AibackendApplication {
+
+ public static void main(String[] args) {
+ run(args);
+ }
+
+ public static org.springframework.context.ConfigurableApplicationContext run(String[] args) {
+ return SpringApplication.run(AibackendApplication.class, args);
+ }
+
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/DataInitializer.java b/backend/src/main/java/ch/goodone/angularai/backend/DataInitializer.java
new file mode 100644
index 000000000..6f4d776f1
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/DataInitializer.java
@@ -0,0 +1,16 @@
+package ch.goodone.angularai.backend;
+
+import ch.goodone.angularai.backend.service.DataInitializerService;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class DataInitializer {
+
+ @Bean
+ CommandLineRunner initData(DataInitializerService dataInitializerService) {
+ return args -> dataInitializerService.seedData();
+ }
+
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProperties.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProperties.java
new file mode 100644
index 000000000..6e948176c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProperties.java
@@ -0,0 +1,33 @@
+package ch.goodone.angularai.backend.ai;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "app.ai")
+public class AiProperties {
+
+ private CapabilityConfig quickAdd;
+ private CapabilityConfig architecture;
+ private CapabilityConfig retrospective;
+ private CapabilityConfig embedding;
+ private Map pricing;
+
+ @Data
+ public static class CapabilityConfig {
+ private String provider;
+ private String model;
+ private boolean enabled = true;
+ private int topK = 6;
+ }
+
+ @Data
+ public static class ModelPrice {
+ private Double inputPricePer1k;
+ private Double outputPricePer1k;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProviderService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProviderService.java
new file mode 100644
index 000000000..126a5c5f1
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/AiProviderService.java
@@ -0,0 +1,59 @@
+package ch.goodone.angularai.backend.ai;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.context.ApplicationContext;
+import org.springframework.stereotype.Service;
+
+/**
+ * Service to provide AI models (Chat and Embedding) based on configured capabilities.
+ * Supports switching providers via configuration.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AiProviderService {
+
+ private final ApplicationContext context;
+ private final AiProperties aiProperties;
+
+ public EmbeddingModel getEmbeddingModel() {
+ return getEmbeddingModel(aiProperties.getEmbedding());
+ }
+
+ private EmbeddingModel getEmbeddingModel(AiProperties.CapabilityConfig config) {
+ validateConfig(config);
+ String provider = config.getProvider().toLowerCase();
+ String beanName = provider.equals("openai") ? "openAiEmbeddingModel" : provider + "EmbeddingModel";
+ log.debug("Resolving EmbeddingModel bean: {}", beanName);
+ return context.getBean(beanName, EmbeddingModel.class);
+ }
+
+ public ChatModel getQuickAddChatModel() {
+ return getChatModel(aiProperties.getQuickAdd());
+ }
+
+ public ChatModel getArchitectureChatModel() {
+ return getChatModel(aiProperties.getArchitecture());
+ }
+
+ public ChatModel getRetrospectiveChatModel() {
+ return getChatModel(aiProperties.getRetrospective() != null ? aiProperties.getRetrospective() : aiProperties.getArchitecture());
+ }
+
+ private ChatModel getChatModel(AiProperties.CapabilityConfig config) {
+ validateConfig(config);
+ String provider = config.getProvider().toLowerCase();
+ String beanName = provider.equals("openai") ? "openAiChatModel" : provider + "ChatModel";
+ log.debug("Resolving ChatModel bean: {}", beanName);
+ return context.getBean(beanName, ChatModel.class);
+ }
+
+ private void validateConfig(AiProperties.CapabilityConfig config) {
+ if (config == null || config.getProvider() == null) {
+ throw new IllegalArgumentException("AI capability configuration is missing or provider not specified");
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/MockAiConfiguration.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/MockAiConfiguration.java
new file mode 100644
index 000000000..08cd83420
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/MockAiConfiguration.java
@@ -0,0 +1,80 @@
+package ch.goodone.angularai.backend.ai;
+
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.embedding.Embedding;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.embedding.EmbeddingResponse;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+import java.util.List;
+
+/**
+ * Mock AI configuration for CI/CD environments where no LLM is running.
+ */
+@Configuration
+@Profile("mock")
+public class MockAiConfiguration {
+
+ @Bean
+ public ChatModel mockChatModel() {
+ return new ChatModel() {
+ @Override
+ public ChatResponse call(Prompt prompt) {
+ String input = "";
+ if (!prompt.getInstructions().isEmpty()) {
+ Message firstMessage = prompt.getInstructions().get(0);
+ // Try to get content via getText() which is common in Spring AI 1.0.0
+ input = firstMessage.getText();
+ }
+
+ String responseText = "Mock AI response";
+
+ // If it looks like Quick Add parsing (looking for JSON)
+ if (input != null && (input.contains("{") || input.contains("JSON"))) {
+ responseText = "{\"title\": \"Mock Task\", \"description\": \"Mocked by CI\", \"priority\": \"HIGH\"}";
+ }
+
+ // If it looks like Architecture page Q&A
+ if (input != null && (input.contains("Architecture") || input.contains("sources"))) {
+ responseText = "Based on the documentation, the architecture is a monorepo. Sources: [doc/knowledge/junie-tasks/taskset-9/AI-ARCH-Summary.md]";
+ }
+
+ Generation generation = new Generation(new AssistantMessage(responseText));
+ return new ChatResponse(List.of(generation));
+ }
+
+ // Minimal implementation of other required methods if any
+ };
+ }
+
+ @Bean
+ public EmbeddingModel mockEmbeddingModel() {
+ return new EmbeddingModel() {
+ @Override
+ public float[] embed(String text) {
+ return new float[384];
+ }
+
+ @Override
+ public float[] embed(Document document) {
+ return new float[384];
+ }
+
+ @Override
+ public EmbeddingResponse call(org.springframework.ai.embedding.EmbeddingRequest request) {
+ List embeddings = request.getInstructions().stream()
+ .map(text -> new Embedding(new float[384], 0))
+ .toList();
+ return new EmbeddingResponse(embeddings);
+ }
+ };
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/OpenAiManualConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/OpenAiManualConfig.java
new file mode 100644
index 000000000..a9d461e98
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/OpenAiManualConfig.java
@@ -0,0 +1,150 @@
+package ch.goodone.angularai.backend.ai;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.embedding.Embedding;
+import org.springframework.ai.embedding.EmbeddingModel;
+import org.springframework.ai.embedding.EmbeddingRequest;
+import org.springframework.ai.embedding.EmbeddingResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.MediaType;
+import org.springframework.web.client.RestClient;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Manual implementation of OpenAI Chat and Embedding models to workaround
+ * binary compatibility issues between Spring AI 1.0.0 and Spring Boot 4.x.
+ */
+@Configuration
+@Slf4j
+public class OpenAiManualConfig {
+ private static final String MODEL_FIELD = "model";
+ private static final String ROLE_FIELD = "role";
+ private static final String CONTENT_FIELD = "content";
+ private static final String AUTH_HEADER = "Authorization";
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ @Value("${spring.ai.openai.api-key}")
+ private String apiKey;
+
+ @Value("${spring.ai.openai.chat.options.model:gpt-4o}")
+ private String chatModel;
+
+ @Value("${spring.ai.openai.embedding.options.model:text-embedding-3-small}")
+ private String embeddingModel;
+
+ private final RestClient restClient = RestClient.builder()
+ .baseUrl("https://api.openai.com/v1")
+ .build();
+
+ @Bean
+ public ChatModel openAiChatModel() {
+ log.info("Creating manual OpenAiChatModel bean (workaround for Spring Boot 4.x compatibility)");
+ return new ChatModel() {
+ @Override
+ public ChatResponse call(Prompt prompt) {
+ if (!org.springframework.util.StringUtils.hasText(apiKey) || "dummy".equals(apiKey)) {
+ log.error("OpenAI API key is missing or dummy. AI features will fail. Please set OPENAI_API_KEY environment variable.");
+ throw new IllegalStateException("OpenAI API key is missing or dummy. Please set OPENAI_API_KEY environment variable.");
+ }
+
+ String systemMessage = prompt.getInstructions().stream()
+ .filter(m -> m.getMessageType().name().equals("SYSTEM"))
+ .map(org.springframework.ai.chat.messages.Message::getText)
+ .collect(Collectors.joining("\n"));
+
+ String userMessage = prompt.getInstructions().stream()
+ .filter(m -> m.getMessageType().name().equals("USER"))
+ .map(org.springframework.ai.chat.messages.Message::getText)
+ .collect(Collectors.joining("\n"));
+
+ Map request = Map.of(
+ MODEL_FIELD, chatModel,
+ "messages", List.of(
+ Map.of(ROLE_FIELD, "system", CONTENT_FIELD, systemMessage),
+ Map.of(ROLE_FIELD, "user", CONTENT_FIELD, userMessage)
+ )
+ );
+
+ Map response = restClient.post()
+ .uri("/chat/completions")
+ .header(AUTH_HEADER, BEARER_PREFIX + apiKey)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(request)
+ .retrieve()
+ .body(Map.class);
+
+ List> choices = (List>) response.get("choices");
+ String content = (String) ((Map) choices.get(0).get("message")).get(CONTENT_FIELD);
+
+ Generation generation = new Generation(new AssistantMessage(content));
+ return new ChatResponse(List.of(generation));
+ }
+ };
+ }
+
+ @Bean
+ public EmbeddingModel openAiEmbeddingModel() {
+ log.info("Creating manual OpenAiEmbeddingModel bean (workaround for Spring Boot 4.x compatibility)");
+ return new EmbeddingModel() {
+ @Override
+ public float[] embed(String text) {
+ EmbeddingResponse response = call(new EmbeddingRequest(List.of(text), null));
+ if (response.getResults().isEmpty()) {
+ log.warn("Embedding response is empty, likely due to missing API key or dummy provider. Returning zero vector.");
+ return new float[0];
+ }
+ return response.getResults().get(0).getOutput();
+ }
+
+ @Override
+ public float[] embed(org.springframework.ai.document.Document document) {
+ return embed(document.getFormattedContent());
+ }
+
+ @Override
+ public EmbeddingResponse call(EmbeddingRequest request) {
+ if (!org.springframework.util.StringUtils.hasText(apiKey) || "dummy".equals(apiKey)) {
+ log.warn("OpenAI API key is missing or dummy. Returning empty embeddings. Please set OPENAI_API_KEY.");
+ return new EmbeddingResponse(List.of());
+ }
+
+ Map body = Map.of(
+ MODEL_FIELD, embeddingModel,
+ "input", request.getInstructions()
+ );
+
+ Map response = restClient.post()
+ .uri("/embeddings")
+ .header(AUTH_HEADER, BEARER_PREFIX + apiKey)
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(body)
+ .retrieve()
+ .body(Map.class);
+
+ List> data = (List>) response.get("data");
+ List embeddings = data.stream()
+ .map(d -> {
+ List list = (List) d.get("embedding");
+ float[] vector = new float[list.size()];
+ for (int i = 0; i < list.size(); i++) {
+ vector[i] = list.get(i).floatValue();
+ }
+ return new Embedding(vector, (Integer) d.get("index"));
+ })
+ .toList();
+
+ return new EmbeddingResponse(embeddings);
+ }
+ };
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftAiService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftAiService.java
new file mode 100644
index 000000000..09d5a72f2
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftAiService.java
@@ -0,0 +1,39 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftRequest;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftResponse;
+import ch.goodone.angularai.backend.ai.prompt.StructuredOutputService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class AdrDriftAiService {
+
+ private final AiProviderService aiProviderService;
+ private final StructuredOutputService structuredOutputService;
+
+ @Value("classpath:prompts/adr-drift/v1/generate.st")
+ private Resource generatePromptResource;
+
+ public AdrDriftResponse detect(AdrDriftRequest request, String adrContext, String taskContext) {
+ Map templateModel = new HashMap<>();
+ templateModel.put("fromDate", request.getFromDate() != null ? request.getFromDate() : "Unspecified");
+ templateModel.put("toDate", request.getToDate() != null ? request.getToDate() : "Unspecified");
+ templateModel.put("adrContext", adrContext);
+ templateModel.put("taskContext", taskContext);
+
+ return structuredOutputService.call(
+ aiProviderService.getRetrospectiveChatModel(),
+ generatePromptResource,
+ templateModel,
+ AdrDriftResponse.class
+ );
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCase.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCase.java
new file mode 100644
index 000000000..56f3368e6
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCase.java
@@ -0,0 +1,8 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.dto.AdrDriftRequest;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftResponse;
+
+public interface AdrDriftUseCase {
+ AdrDriftResponse execute(AdrDriftRequest request);
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCaseImpl.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCaseImpl.java
new file mode 100644
index 000000000..928c082b9
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AdrDriftUseCaseImpl.java
@@ -0,0 +1,220 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftRequest;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftResponse;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import ch.goodone.angularai.backend.model.DocChunk;
+import ch.goodone.angularai.backend.model.DocEmbedding;
+import ch.goodone.angularai.backend.model.DocSource;
+import ch.goodone.angularai.backend.repository.DocChunkRepository;
+import ch.goodone.angularai.backend.repository.DocEmbeddingRepository;
+import ch.goodone.angularai.backend.repository.DocSourceRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AdrDriftUseCaseImpl implements AdrDriftUseCase {
+
+ private static final String TASKSET_PREFIX = "taskset-";
+
+ private final DocSourceRepository sourceRepository;
+ private final DocChunkRepository chunkRepository;
+ private final DocEmbeddingRepository embeddingRepository;
+ private final AdrDriftAiService aiService;
+ private final AiObservabilityService observabilityService;
+ private final AiProperties aiProperties;
+ private final AiProviderService aiProviderService;
+
+ @Override
+ @Transactional(readOnly = true)
+ public AdrDriftResponse execute(AdrDriftRequest request) {
+ log.info("Detecting ADR drift for request: {}", request);
+
+ List adrSources = resolveAdrSources(request);
+ if (adrSources.isEmpty()) {
+ return emptyResponse();
+ }
+
+ StringBuilder adrContext = new StringBuilder();
+ Set allSources = new HashSet<>();
+ List queries = extractQueriesAndContext(adrSources, adrContext, allSources);
+
+ Set relevantTaskChunks = retrieveRelevantTaskChunks(request, queries);
+ if (relevantTaskChunks.isEmpty()) {
+ relevantTaskChunks = fallbackToRecentTasks(request);
+ }
+
+ StringBuilder taskContext = new StringBuilder();
+ for (DocChunk taskChunk : relevantTaskChunks) {
+ taskContext.append("Task Source: ").append(taskChunk.getSource().getPath()).append("\n");
+ taskContext.append(taskChunk.getContent()).append("\n---\n");
+ allSources.add(taskChunk.getSource().getPath());
+ }
+
+ return callAiForDriftDetection(request, adrContext.toString(), taskContext.toString(), allSources);
+ }
+
+ private List resolveAdrSources(AdrDriftRequest request) {
+ List adrPaths = request.getAdrDocPaths();
+ if (adrPaths == null || adrPaths.isEmpty()) {
+ adrPaths = List.of("doc/knowledge/adrs/");
+ }
+
+ List adrSources = new ArrayList<>();
+ for (String path : adrPaths) {
+ adrSources.addAll(sourceRepository.findByPathContaining(path));
+ }
+ if (adrSources.isEmpty()) {
+ log.warn("No ADR sources found for paths: {}", adrPaths);
+ }
+ return adrSources;
+ }
+
+ private AdrDriftResponse emptyResponse() {
+ return AdrDriftResponse.builder()
+ .principles(new ArrayList<>())
+ .potentialDrifts(new ArrayList<>())
+ .confidence(0.1)
+ .sources(new ArrayList<>())
+ .build();
+ }
+
+ private List extractQueriesAndContext(List adrSources, StringBuilder contextBuilder, Set allSources) {
+ List queries = new ArrayList<>();
+ for (DocSource adrSource : adrSources) {
+ List chunks = chunkRepository.findBySource(adrSource);
+ for (DocChunk chunk : chunks) {
+ String content = chunk.getContent();
+ extractMatches(content, Pattern.compile("#### (ADR-\\d+: .+)"), queries);
+ extractMatches(content, Pattern.compile("\\*\\*Decision:\\*\\* (.+)", Pattern.CASE_INSENSITIVE), queries);
+
+ contextBuilder.append("ADR Source: ").append(adrSource.getPath()).append("\n");
+ contextBuilder.append(content).append("\n---\n");
+ allSources.add(adrSource.getPath());
+ }
+ }
+ return queries;
+ }
+
+ private void extractMatches(String content, Pattern pattern, List queries) {
+ Matcher matcher = pattern.matcher(content);
+ while (matcher.find()) {
+ queries.add(matcher.group(1));
+ }
+ }
+
+ private Set retrieveRelevantTaskChunks(AdrDriftRequest request, List queries) {
+ Set relevantTaskChunks = new HashSet<>();
+ if (aiProperties.getEmbedding() == null || !aiProperties.getEmbedding().isEnabled()) {
+ return relevantTaskChunks;
+ }
+
+ String embeddingModelName = aiProperties.getEmbedding().getModel();
+ for (String query : queries) {
+ processQueryForRelevantChunks(request, query, embeddingModelName, relevantTaskChunks);
+ }
+ return relevantTaskChunks;
+ }
+
+ private void processQueryForRelevantChunks(AdrDriftRequest request, String query, String modelName, Set relevantChunks) {
+ try {
+ float[] queryVector = aiProviderService.getEmbeddingModel().embed(query);
+ List similarEmbeddings = embeddingRepository.findTopKSimilar(Arrays.toString(queryVector), modelName, 5);
+ for (DocEmbedding embedding : similarEmbeddings) {
+ DocChunk chunk = embedding.getChunk();
+ if (isChunkRelevant(chunk, request)) {
+ relevantChunks.add(chunk);
+ }
+ }
+ } catch (Exception e) {
+ log.warn("Failed to find similar embeddings for query '{}': {}", query, e.getMessage());
+ }
+ }
+
+ private boolean isChunkRelevant(DocChunk chunk, AdrDriftRequest request) {
+ DocSource source = chunk.getSource();
+ String path = source.getPath();
+ return path.contains(TASKSET_PREFIX)
+ && isWithinDateRange(source, request.getFromDate(), request.getToDate())
+ && isSelectedTaskset(source, request.getTasksets());
+ }
+
+ private Set fallbackToRecentTasks(AdrDriftRequest request) {
+ log.info("Using fallback (recent tasks).");
+ Set relevantTaskChunks = new HashSet<>();
+ List recentTaskSources = sourceRepository.findByPathContaining(TASKSET_PREFIX).stream()
+ .filter(s -> isWithinDateRange(s, request.getFromDate(), request.getToDate()))
+ .filter(s -> isSelectedTaskset(s, request.getTasksets()))
+ .limit(20)
+ .toList();
+ for (DocSource s : recentTaskSources) {
+ relevantTaskChunks.addAll(chunkRepository.findBySource(s));
+ }
+ return relevantTaskChunks;
+ }
+
+ private AdrDriftResponse callAiForDriftDetection(AdrDriftRequest request, String adrContext, String taskContext, Set allSources) {
+ String provider = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getProvider()
+ : aiProperties.getArchitecture().getProvider();
+ String model = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getModel()
+ : aiProperties.getArchitecture().getModel();
+
+ try {
+ AdrDriftResponse response = observabilityService.recordCall(
+ "adr-drift-detect",
+ provider,
+ model,
+ "v1",
+ "ADR Drift Detection Request",
+ () -> aiService.detect(request, adrContext, taskContext)
+ );
+ if (response.getSources() == null || response.getSources().isEmpty()) {
+ response.setSources(new ArrayList<>(allSources));
+ }
+ return response;
+ } catch (Exception e) {
+ log.error("AI ADR Drift detection failed: {}", e.getMessage());
+ return AdrDriftResponse.builder()
+ .principles(new ArrayList<>())
+ .potentialDrifts(new ArrayList<>())
+ .confidence(0.0)
+ .sources(new ArrayList<>(allSources))
+ .build();
+ }
+ }
+
+ private boolean isWithinDateRange(DocSource source, LocalDate from, LocalDate to) {
+ LocalDate lastIndexedDate = source.getLastIndexed().toLocalDate();
+ return (from == null || !lastIndexedDate.isBefore(from)) &&
+ (to == null || !lastIndexedDate.isAfter(to));
+ }
+
+ private boolean isSelectedTaskset(DocSource source, List tasksets) {
+ if (tasksets == null || tasksets.isEmpty()) {
+ return true;
+ }
+ for (String ts : tasksets) {
+ if (source.getPath().contains(TASKSET_PREFIX + ts) || source.getPath().contains("taskset/" + ts)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AiApplicationService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AiApplicationService.java
new file mode 100644
index 000000000..fc46b4e6c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/AiApplicationService.java
@@ -0,0 +1,84 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.dto.ArchitectureExplainRequest;
+import ch.goodone.angularai.backend.ai.dto.ArchitectureExplainResult;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftRequest;
+import ch.goodone.angularai.backend.ai.dto.AdrDriftResponse;
+import ch.goodone.angularai.backend.ai.dto.QuickAddParseRequest;
+import ch.goodone.angularai.backend.ai.dto.QuickAddParseResult;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarRequest;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarResponse;
+import ch.goodone.angularai.backend.ai.exception.AiDisabledException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+/**
+ * Application service facade for AI operations.
+ */
+@Service
+@RequiredArgsConstructor
+public class AiApplicationService {
+
+ private final QuickAddParseUseCase quickAddParseUseCase;
+ private final ArchitectureExplainUseCase architectureExplainUseCase;
+ private final RiskRadarUseCase riskRadarUseCase;
+ private final AdrDriftUseCase adrDriftUseCase;
+
+ @Value("${app.ai.enabled:false}")
+ private boolean aiEnabled;
+
+ @Value("${app.ai.disabled-message:AI features are currently disabled by the administrator.}")
+ private String aiDisabledMessage;
+
+ /**
+ * Parses a quick-add task string into structured data.
+ *
+ * @param request The parse request.
+ * @param login The login of the user requesting the parse.
+ * @return The parsed result.
+ */
+ public QuickAddParseResult parseQuickAdd(QuickAddParseRequest request, String login) {
+ checkAiEnabled();
+ return quickAddParseUseCase.execute(request, login);
+ }
+
+ /**
+ * Explains the project architecture.
+ *
+ * @param request The explanation request.
+ * @return The explanation result.
+ */
+ public ArchitectureExplainResult explainArchitecture(ArchitectureExplainRequest request) {
+ checkAiEnabled();
+ return architectureExplainUseCase.execute(request);
+ }
+
+ /**
+ * Generates a Risk Radar report.
+ *
+ * @param request The Risk Radar request.
+ * @return The Risk Radar response.
+ */
+ public RiskRadarResponse generateRiskRadar(RiskRadarRequest request) {
+ checkAiEnabled();
+ return riskRadarUseCase.execute(request);
+ }
+
+ /**
+ * Detects ADR drift.
+ *
+ * @param request The drift request.
+ * @return The drift response.
+ */
+ public AdrDriftResponse detectAdrDrift(AdrDriftRequest request) {
+ checkAiEnabled();
+ return adrDriftUseCase.execute(request);
+ }
+
+ private void checkAiEnabled() {
+ if (!aiEnabled) {
+ throw new AiDisabledException(aiDisabledMessage);
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/ArchitectureExplainUseCase.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/ArchitectureExplainUseCase.java
new file mode 100644
index 000000000..29724f8c8
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/ArchitectureExplainUseCase.java
@@ -0,0 +1,102 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.ArchitectureExplainRequest;
+import ch.goodone.angularai.backend.ai.dto.ArchitectureExplainResult;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import ch.goodone.angularai.backend.ai.prompt.StructuredOutputService;
+import ch.goodone.angularai.backend.docs.retrieval.DocRetrievalService;
+import ch.goodone.angularai.backend.model.DocChunk;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Use case for explaining the project architecture using AI based on a question.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class ArchitectureExplainUseCase {
+
+ private final AiProviderService aiProviderService;
+ private final StructuredOutputService structuredOutputService;
+ private final DocRetrievalService retrievalService;
+ private final AiObservabilityService observabilityService;
+ private final AiProperties aiProperties;
+
+ @Value("classpath:prompts/architecture/v1/explain.st")
+ private Resource explainPromptResource;
+
+ /**
+ * Executes the architecture explanation logic.
+ *
+ * @param request The request containing the question.
+ * @return The explanation result.
+ */
+ public ArchitectureExplainResult execute(ArchitectureExplainRequest request) {
+ if (aiProperties.getArchitecture() != null && !aiProperties.getArchitecture().isEnabled()) {
+ return new ArchitectureExplainResult(
+ "AI Architecture Explanation is currently disabled by configuration.",
+ List.of("Disabled"),
+ List.of()
+ );
+ }
+
+ int topK = aiProperties.getArchitecture().getTopK();
+ List chunks = retrievalService.retrieve(request.getQuestion(), topK);
+
+ String context = chunks.isEmpty()
+ ? "No additional context available."
+ : formatContext(chunks);
+
+ String provider = aiProperties.getArchitecture().getProvider();
+ String model = aiProperties.getArchitecture().getModel();
+
+ try {
+ ArchitectureExplainResult result = observabilityService.recordCall(
+ "architecture-explain",
+ provider,
+ model,
+ "v1",
+ request.getQuestion(),
+ () -> structuredOutputService.call(
+ aiProviderService.getArchitectureChatModel(),
+ explainPromptResource,
+ Map.of(
+ "userInput", request.getQuestion(),
+ "context", context
+ ),
+ ArchitectureExplainResult.class
+ )
+ );
+ log.debug("AI Architecture Explain Result: {}", result);
+ return result;
+ } catch (Exception e) {
+ log.error("AI Architecture Explain failed: {}", e.getMessage());
+ return new ArchitectureExplainResult(
+ "AI service failed to explain architecture: " + e.getMessage(),
+ List.of("Error"),
+ List.of()
+ );
+ }
+ }
+
+ private String formatContext(List chunks) {
+ StringBuilder sb = new StringBuilder();
+ for (DocChunk chunk : chunks) {
+ sb.append("Source: ").append(chunk.getSource().getPath()).append("\n");
+ if (chunk.getHeading() != null) {
+ sb.append("Heading: ").append(chunk.getHeading()).append("\n");
+ }
+ sb.append("Content: ").append(chunk.getContent()).append("\n\n");
+ }
+ return sb.toString();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/QuickAddParseUseCase.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/QuickAddParseUseCase.java
new file mode 100644
index 000000000..e6de3deb9
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/QuickAddParseUseCase.java
@@ -0,0 +1,164 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.QuickAddParseRequest;
+import ch.goodone.angularai.backend.ai.dto.QuickAddParseResult;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import ch.goodone.angularai.backend.ai.prompt.StructuredOutputService;
+import ch.goodone.angularai.backend.service.TaskParserService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Use case for parsing a quick-add task string into structured data using AI.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class QuickAddParseUseCase {
+
+ private final AiProviderService aiProviderService;
+ private final StructuredOutputService structuredOutputService;
+ private final TaskParserService taskParserService;
+ private final AiObservabilityService observabilityService;
+ private final AiProperties aiProperties;
+ private final ch.goodone.angularai.backend.service.ActionLogService actionLogService;
+
+ @Value("classpath:prompts/quick-add/v1/parse.st")
+ private Resource parsePromptResource;
+
+ /**
+ * Executes the quick-add parse logic.
+ *
+ * @param request The parse request containing the raw text.
+ * @param login The user login.
+ * @return The parsed structured result.
+ */
+ public QuickAddParseResult execute(QuickAddParseRequest request, String login) {
+ String input = request.getText();
+ log.debug("Executing QuickAddParse for input: '{}' (user: {})", input, login);
+ actionLogService.log(login, "AI_QUICK_ADD_PARSE", "Parsed input: " + input);
+
+ // 1. Deterministic pre-parse
+ TaskParserService.ParsedTask deterministic = taskParserService.parse(input);
+
+ // 2. Decide if AI is needed
+ boolean aiNeeded = isAiNeeded(input, deterministic);
+
+ QuickAddParseResult result;
+ if (aiNeeded) {
+ result = callAi(input);
+ result = merge(result, deterministic, true);
+ } else {
+ result = fromDeterministic(deterministic);
+ }
+
+ log.debug("QuickAddParse result: {}", result);
+ return result;
+ }
+
+ private boolean isAiNeeded(String input, TaskParserService.ParsedTask deterministic) {
+ // AI is disabled by config
+ if (aiProperties.getQuickAdd() != null && !aiProperties.getQuickAdd().isEnabled()) {
+ return false;
+ }
+
+ // AI is needed if:
+ // - Deterministic parser couldn't find a due date OR priority OR status
+ // - OR input is long (likely natural language)
+ // - OR we want AI to provide description, category or additional tags
+ if (input.split("\\s+").length > 4) {
+ return true;
+ }
+ return deterministic.dueDate() == null && deterministic.priority() == ch.goodone.angularai.backend.model.Priority.MEDIUM;
+ }
+
+ private QuickAddParseResult fromDeterministic(TaskParserService.ParsedTask d) {
+ return new QuickAddParseResult(
+ d.title(),
+ d.description(),
+ null,
+ 1.0,
+ d.tags(),
+ List.of("Parsed deterministically"),
+ d.dueDate() != null ? d.dueDate().toString() : null,
+ d.dueTime() != null ? d.dueTime().toString() : null,
+ d.priority() != null ? d.priority().name() : null,
+ d.status() != null ? d.status().name() : null,
+ false
+ );
+ }
+
+ private QuickAddParseResult callAi(String input) {
+ String provider = aiProperties.getQuickAdd().getProvider();
+ String model = aiProperties.getQuickAdd().getModel();
+
+ try {
+ return observabilityService.recordCall(
+ "quick-add-parse",
+ provider,
+ model,
+ "v2",
+ input,
+ () -> structuredOutputService.call(
+ aiProviderService.getQuickAddChatModel(),
+ parsePromptResource,
+ Map.of("userInput", input),
+ QuickAddParseResult.class
+ )
+ );
+ } catch (Exception e) {
+ log.error("AI Quick Add Parse failed: {}", e.getMessage());
+ // Fallback to empty result if AI fails
+ return new QuickAddParseResult(null, null, null, 0.0, List.of(), List.of("AI parsing failed: " + e.getMessage()), null, null, null, null, false);
+ }
+ }
+
+ private QuickAddParseResult merge(QuickAddParseResult ai, TaskParserService.ParsedTask deterministic, boolean aiUsed) {
+ // Deterministic attributes take precedence as they are very reliable
+ String finalDueDate = deterministic.dueDate() != null ? deterministic.dueDate().toString() : ai.dueDate();
+ String finalDueTime = deterministic.dueTime() != null ? deterministic.dueTime().toString() : ai.dueTime();
+ String finalPriority = deterministic.priority() != null ? deterministic.priority().name() : ai.priority();
+ String finalStatus = deterministic.status() != null ? deterministic.status().name() : ai.status();
+
+ // For Title and Description, we trust AI if confidence is high,
+ // because it is better at summarizing and splitting into title/description.
+ // Otherwise, we use the cleaned title from the deterministic parser.
+ String finalTitle;
+ String finalDescription;
+ if (ai.confidence() != null && ai.confidence() >= 0.8 && ai.title() != null && !ai.title().isEmpty()) {
+ finalTitle = ai.title();
+ finalDescription = ai.description();
+ } else {
+ finalTitle = (deterministic.title() != null && !deterministic.title().isEmpty()) ? deterministic.title() : ai.title();
+ finalDescription = (ai.description() != null && !ai.description().isEmpty()) ? ai.description() : deterministic.description();
+ }
+
+ // For tags, we merge both lists
+ java.util.Set allTags = new java.util.HashSet<>(deterministic.tags());
+ if (ai.tags() != null) {
+ allTags.addAll(ai.tags());
+ }
+
+ return new QuickAddParseResult(
+ finalTitle,
+ finalDescription,
+ ai.category(),
+ ai.confidence(),
+ new java.util.ArrayList<>(allTags),
+ ai.assumptions(),
+ finalDueDate,
+ finalDueTime,
+ finalPriority,
+ finalStatus,
+ aiUsed
+ );
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveAiService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveAiService.java
new file mode 100644
index 000000000..8851f44c3
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveAiService.java
@@ -0,0 +1,41 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveRequest;
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveResponse;
+import ch.goodone.angularai.backend.ai.prompt.StructuredOutputService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class RetrospectiveAiService {
+
+ private final AiProviderService aiProviderService;
+ private final StructuredOutputService structuredOutputService;
+
+ @Value("classpath:prompts/retrospective/v1/generate.st")
+ private Resource generatePromptResource;
+
+ public RetrospectiveResponse generate(RetrospectiveRequest request, String context) {
+ Map templateModel = new HashMap<>();
+ templateModel.put("fromDate", request.getFromDate());
+ templateModel.put("toDate", request.getToDate());
+ templateModel.put("tasksets", request.getTasksets() != null ? request.getTasksets() : "All");
+ templateModel.put("tags", request.getTags() != null ? request.getTags() : "None");
+ templateModel.put("mode", request.getMode() != null ? request.getMode() : "Standard");
+ templateModel.put("context", context);
+
+ return structuredOutputService.call(
+ aiProviderService.getRetrospectiveChatModel(),
+ generatePromptResource,
+ templateModel,
+ RetrospectiveResponse.class
+ );
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCase.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCase.java
new file mode 100644
index 000000000..d1537205d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCase.java
@@ -0,0 +1,13 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveRequest;
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveResponse;
+import ch.goodone.angularai.backend.ai.dto.TasksetInfo;
+
+import java.util.List;
+
+public interface RetrospectiveUseCase {
+ RetrospectiveResponse generateRetrospective(RetrospectiveRequest request);
+
+ List getAvailableTasksets();
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCaseImpl.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCaseImpl.java
new file mode 100644
index 000000000..806b60b4c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RetrospectiveUseCaseImpl.java
@@ -0,0 +1,141 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveRequest;
+import ch.goodone.angularai.backend.ai.dto.RetrospectiveResponse;
+import ch.goodone.angularai.backend.ai.dto.TasksetInfo;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import ch.goodone.angularai.backend.model.DocChunk;
+import ch.goodone.angularai.backend.model.DocSource;
+import ch.goodone.angularai.backend.repository.DocChunkRepository;
+import ch.goodone.angularai.backend.repository.DocSourceRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class RetrospectiveUseCaseImpl implements RetrospectiveUseCase {
+
+ private final DocSourceRepository sourceRepository;
+ private final DocChunkRepository chunkRepository;
+ private final RetrospectiveAiService aiService;
+ private final AiObservabilityService observabilityService;
+ private final AiProperties aiProperties;
+ private final TasksetService tasksetService;
+
+ @Override
+ public List getAvailableTasksets() {
+ return tasksetService.getTasksets();
+ }
+
+ @Override
+ public RetrospectiveResponse generateRetrospective(RetrospectiveRequest request) {
+ log.info("Generating retrospective for request: {}", request);
+
+ // 1. Find relevant sources (tasks)
+ List allTaskSources = sourceRepository.findByPathContaining("taskset-");
+
+ // 2. Filter by tasksets, dates, and tags if provided
+ List filteredSources = allTaskSources.stream()
+ .filter(s -> filterByTasksets(s, request.getTasksets()))
+ .filter(s -> filterByDates(s, request.getFromDate(), request.getToDate()))
+ .filter(s -> filterByTags(s, request.getTags()))
+ .toList();
+
+ // 3. Collect chunks
+ StringBuilder contextBuilder = new StringBuilder();
+ int chunkCount = 0;
+ for (DocSource source : filteredSources) {
+ List chunks = chunkRepository.findBySource(source);
+ contextBuilder.append("Source: ").append(source.getPath()).append("\n");
+ for (DocChunk chunk : chunks) {
+ contextBuilder.append(chunk.getContent()).append("\n");
+ chunkCount++;
+ }
+ contextBuilder.append("---\n");
+
+ // Limit context size to avoid exceeding token limits (rough estimate)
+ if (contextBuilder.length() > 32000) {
+ log.warn("Context limit reached, truncating retrospective sources");
+ break;
+ }
+ }
+
+ log.info("Collected {} sources and {} chunks for retrospective context", filteredSources.size(), chunkCount);
+
+ if (contextBuilder.isEmpty()) {
+ return RetrospectiveResponse.builder()
+ .summary("No task data found for the selected filters in ingested documentation.")
+ .confidence(0.1)
+ .build();
+ }
+
+ // 4. Call AI with observability
+ String provider = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getProvider()
+ : aiProperties.getArchitecture().getProvider();
+ String model = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getModel()
+ : aiProperties.getArchitecture().getModel();
+
+ try {
+ RetrospectiveResponse response = observabilityService.recordCall(
+ "retrospective-generate",
+ provider,
+ model,
+ "v1",
+ "Retrospective Request: " + request.getMode(),
+ () -> aiService.generate(request, contextBuilder.toString())
+ );
+ log.debug("AI Retrospective Response: {}", response);
+ return response;
+ } catch (Exception e) {
+ log.error("Retrospective generation failed: {}", e.getMessage(), e);
+ return RetrospectiveResponse.builder()
+ .summary("Failed to generate retrospective due to AI service error: " + e.getMessage())
+ .confidence(0.0)
+ .build();
+ }
+ }
+
+ private boolean filterByTasksets(DocSource source, List tasksets) {
+ if (tasksets == null || tasksets.isEmpty()) {
+ return true;
+ }
+ for (String ts : tasksets) {
+ // Check both "taskset-X" and just "taskset/X" depending on naming
+ if (source.getPath().contains("taskset-" + ts) || source.getPath().contains("taskset/" + ts)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean filterByDates(DocSource source, LocalDate from, LocalDate to) {
+ if (from == null && to == null) {
+ return true;
+ }
+ LocalDate lastIndexedDate = source.getLastIndexed().toLocalDate();
+ return (from == null || !lastIndexedDate.isBefore(from)) && (to == null || !lastIndexedDate.isAfter(to));
+ }
+
+ private boolean filterByTags(DocSource source, List tags) {
+ if (tags == null || tags.isEmpty()) {
+ return true;
+ }
+ List chunks = chunkRepository.findBySource(source);
+ for (String tag : tags) {
+ for (DocChunk chunk : chunks) {
+ if (chunk.getContent().contains(tag)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarAiService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarAiService.java
new file mode 100644
index 000000000..cb8c9020b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarAiService.java
@@ -0,0 +1,40 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProviderService;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarRequest;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarResponse;
+import ch.goodone.angularai.backend.ai.prompt.StructuredOutputService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class RiskRadarAiService {
+
+ private final AiProviderService aiProviderService;
+ private final StructuredOutputService structuredOutputService;
+
+ @Value("classpath:prompts/risk-radar/v1/generate.st")
+ private Resource generatePromptResource;
+
+ public RiskRadarResponse generate(RiskRadarRequest request, String context) {
+ Map templateModel = new HashMap<>();
+ templateModel.put("fromDate", request.getFromDate() != null ? request.getFromDate() : "Unspecified");
+ templateModel.put("toDate", request.getToDate() != null ? request.getToDate() : "Unspecified");
+ templateModel.put("tasksets", request.getTasksets() != null ? String.join(", ", request.getTasksets()) : "All");
+ templateModel.put("tags", request.getTags() != null ? String.join(", ", request.getTags()) : "None");
+ templateModel.put("context", context);
+
+ return structuredOutputService.call(
+ aiProviderService.getRetrospectiveChatModel(),
+ generatePromptResource,
+ templateModel,
+ RiskRadarResponse.class
+ );
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCase.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCase.java
new file mode 100644
index 000000000..7b0366386
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCase.java
@@ -0,0 +1,8 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.dto.RiskRadarRequest;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarResponse;
+
+public interface RiskRadarUseCase {
+ RiskRadarResponse execute(RiskRadarRequest request);
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCaseImpl.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCaseImpl.java
new file mode 100644
index 000000000..f208150ff
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/RiskRadarUseCaseImpl.java
@@ -0,0 +1,452 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarRequest;
+import ch.goodone.angularai.backend.ai.dto.RiskRadarResponse;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import ch.goodone.angularai.backend.model.DocChunk;
+import ch.goodone.angularai.backend.model.DocSource;
+import ch.goodone.angularai.backend.repository.DocChunkRepository;
+import ch.goodone.angularai.backend.repository.DocSourceRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class RiskRadarUseCaseImpl implements RiskRadarUseCase {
+ private static final String CAT_QUALITY = "High Risk (Quality)";
+ private static final String CAT_PROCESS = "Process";
+ private static final String CAT_EFFICIENCY = "Efficiency";
+ private static final String CAT_TRACEABILITY = "Traceability";
+ private static final String STATUS_DONE = "status: DONE";
+ private static final String OPEN_ITEMS_PATTERN = "- Open items:\\s*(.+)";
+ private static final String ITERATIONS_PATTERN = "iterations:\\s*(\\d+)";
+
+ private final DocSourceRepository sourceRepository;
+ private final DocChunkRepository chunkRepository;
+ private final RiskRadarAiService aiService;
+ private final AiObservabilityService observabilityService;
+ private final AiProperties aiProperties;
+
+ @Override
+ public RiskRadarResponse execute(RiskRadarRequest request) {
+ log.info("Generating Risk Radar for request: {}", request);
+
+ List filteredSources = resolveFilteredSources(request);
+ if (filteredSources.isEmpty()) {
+ return emptyRiskResponse();
+ }
+
+ List deterministicRisks = new ArrayList<>();
+ StringBuilder contextBuilder = new StringBuilder();
+ processSources(filteredSources, deterministicRisks, contextBuilder);
+
+ return callAiForRiskRadar(request, contextBuilder.toString(), deterministicRisks);
+ }
+
+ private List resolveFilteredSources(RiskRadarRequest request) {
+ List allTaskSources = sourceRepository.findByPathContaining("taskset-");
+ return allTaskSources.stream()
+ .filter(s -> filterByTasksets(s, request.getTasksets()))
+ .filter(s -> filterByDates(s, request.getFromDate(), request.getToDate()))
+ .filter(s -> filterByTags(s, request.getTags()))
+ .sorted(Comparator.comparing(this::getEffectiveDate).reversed())
+ .toList();
+ }
+
+ private RiskRadarResponse emptyRiskResponse() {
+ return RiskRadarResponse.builder()
+ .highRisks(new ArrayList<>())
+ .processIssues(new ArrayList<>())
+ .documentationGaps(new ArrayList<>())
+ .qualityIssues(new ArrayList<>())
+ .confidence(0.1)
+ .build();
+ }
+
+ private void processSources(List filteredSources, List deterministicRisks, StringBuilder contextBuilder) {
+ int aiContextLimit = 50;
+ int processedForAi = 0;
+
+ for (DocSource source : filteredSources) {
+ List chunks = chunkRepository.findBySource(source);
+ String fullContent = chunks.stream().map(DocChunk::getContent).collect(Collectors.joining("\n"));
+ String taskKey = extractTaskKey(source.getPath());
+
+ analyzeDeterministic(taskKey, fullContent, deterministicRisks);
+
+ if (processedForAi < aiContextLimit) {
+ String relevantContent = extractRelevantContent(fullContent);
+ contextBuilder.append("### Task: ").append(taskKey).append("\n");
+ contextBuilder.append("Path: ").append(source.getPath()).append("\n");
+ contextBuilder.append(relevantContent).append("\n---\n");
+ processedForAi++;
+ }
+ }
+ }
+
+ private RiskRadarResponse callAiForRiskRadar(RiskRadarRequest request, String context, List deterministicRisks) {
+ String provider = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getProvider()
+ : aiProperties.getArchitecture().getProvider();
+ String model = aiProperties.getRetrospective() != null
+ ? aiProperties.getRetrospective().getModel()
+ : aiProperties.getArchitecture().getModel();
+
+ try {
+ RiskRadarResponse aiResponse = observabilityService.recordCall(
+ "risk-radar-generate",
+ provider,
+ model,
+ "v1",
+ "Risk Radar Request",
+ () -> aiService.generate(request, "DETERMINISTIC SIGNALS:\n" + formatDeterministic(deterministicRisks) + "\n\nTASK CONTENT:\n" + context)
+ );
+
+ mergeRisks(aiResponse, deterministicRisks);
+ return aiResponse;
+ } catch (Exception e) {
+ log.error("AI Risk Radar generation failed, returning deterministic results only: {}", e.getMessage());
+ return buildDeterministicOnlyResponse(deterministicRisks);
+ }
+ }
+
+ private String extractTaskKey(String path) {
+ // Example path: doc/knowledge/junie-tasks/taskset-9/p2/AI-RETRO-02-AI-Risk-Radar.md
+ String filename = path.substring(path.lastIndexOf("/") + 1);
+ if (filename.contains("-")) {
+ // Find the last index of a character that is part of the task key (e.g., AI-RETRO-02)
+ // It usually follows KEY-SUBKEY-NUMBER format
+ Pattern p = Pattern.compile("^([A-Z]+-[A-Z]+-\\d+)");
+ Matcher m = p.matcher(filename);
+ if (m.find()) {
+ return m.group(1);
+ }
+ }
+ return filename.replace(".md", "");
+ }
+
+ private void analyzeDeterministic(String taskKey, String content, List risks) {
+ checkMissingVerification(taskKey, content, risks);
+ checkDoneWithOpenItems(taskKey, content, risks);
+ checkHighIterations(taskKey, content, risks);
+ checkMissingPrCommitLinks(taskKey, content, risks);
+ }
+
+ private void checkMissingVerification(String taskKey, String content, List risks) {
+ if ((!content.contains("## Verification") || content.contains("_No verification steps defined._") || content.toLowerCase().contains("### manual\n\n- \n"))
+ && !content.contains("### Manual") && !content.contains("### Automated")) {
+ addRisk(risks, "Missing verification section", taskKey, CAT_QUALITY, "Add manual or automated verification steps to the task definition.");
+ }
+ }
+
+ private void checkDoneWithOpenItems(String taskKey, String content, List risks) {
+ if (content.contains(STATUS_DONE)) {
+ Pattern p = Pattern.compile(OPEN_ITEMS_PATTERN, Pattern.CASE_INSENSITIVE);
+ Matcher m = p.matcher(content);
+ String lastOpenItems = null;
+ while (m.find()) {
+ lastOpenItems = m.group(1).trim();
+ }
+ if (lastOpenItems != null && !lastOpenItems.equalsIgnoreCase("None") && !lastOpenItems.equalsIgnoreCase("-") && !lastOpenItems.isEmpty()) {
+ addRisk(risks, "Task marked as DONE with open items remaining", taskKey, CAT_PROCESS, "Complete remaining items or move them to a new task before closing.");
+ }
+ }
+ }
+
+ private void checkHighIterations(String taskKey, String content, List risks) {
+ Pattern iterPattern = Pattern.compile(ITERATIONS_PATTERN);
+ Matcher iterMatcher = iterPattern.matcher(content);
+ if (iterMatcher.find()) {
+ int iterations = Integer.parseInt(iterMatcher.group(1));
+ if (iterations > 3) {
+ addRisk(risks, "High number of iterations (" + iterations + ")", taskKey, CAT_EFFICIENCY, "Analyze root cause of multiple iterations; improve initial requirement clarity.");
+ }
+ }
+ }
+
+ private void checkMissingPrCommitLinks(String taskKey, String content, List risks) {
+ if (content.contains(STATUS_DONE)) {
+ boolean prEmpty = isPrEmpty(content);
+ boolean commitEmpty = isCommitEmpty(content);
+
+ if (prEmpty || commitEmpty) {
+ addRisk(risks, "DONE task missing PR or Commit links", taskKey, CAT_TRACEABILITY, "Ensure all completed tasks have links to the implementing PR and commits.");
+ }
+ }
+ }
+
+ private boolean isPrEmpty(String content) {
+ Pattern prPattern = Pattern.compile("pr:\\s*'([^']*)'");
+ Matcher prMatcher = prPattern.matcher(content);
+ return !prMatcher.find() || prMatcher.group(1).isEmpty();
+ }
+
+ private boolean isCommitEmpty(String content) {
+ Pattern commitPattern = Pattern.compile("commit:\\s*'([^']*)'");
+ Matcher commitMatcher = commitPattern.matcher(content);
+ return !commitMatcher.find() || commitMatcher.group(1).isEmpty();
+ }
+
+ private void addRisk(List risks, String pattern, String evidence, String category, String mitigation) {
+ risks.add(RiskRadarResponse.RiskItem.builder()
+ .pattern(pattern)
+ .evidence(List.of(evidence))
+ .mitigations(List.of(mitigation))
+ .category(category)
+ .build());
+ }
+
+ private String formatDeterministic(List risks) {
+ return risks.stream()
+ .map(r -> "- " + r.getPattern() + " (Evidence: " + String.join(", ", r.getEvidence()) + ")")
+ .collect(Collectors.joining("\n"));
+ }
+
+ private void mergeRisks(RiskRadarResponse aiResponse, List deterministic) {
+ ensureResponseListsNotNull(aiResponse);
+ deduplicateAiResponse(aiResponse);
+
+ Map grouped = groupDeterministic(deterministic);
+
+ for (RiskRadarResponse.RiskItem detItem : grouped.values()) {
+ if (!mergedIntoExisting(aiResponse, detItem)) {
+ addToAppropriateCategory(aiResponse, detItem);
+ }
+ }
+ }
+
+ private void ensureResponseListsNotNull(RiskRadarResponse aiResponse) {
+ if (aiResponse.getHighRisks() == null) {
+ aiResponse.setHighRisks(new ArrayList<>());
+ }
+ if (aiResponse.getProcessIssues() == null) {
+ aiResponse.setProcessIssues(new ArrayList<>());
+ }
+ if (aiResponse.getDocumentationGaps() == null) {
+ aiResponse.setDocumentationGaps(new ArrayList<>());
+ }
+ if (aiResponse.getQualityIssues() == null) {
+ aiResponse.setQualityIssues(new ArrayList<>());
+ }
+ }
+
+ private void deduplicateAiResponse(RiskRadarResponse aiResponse) {
+ aiResponse.getHighRisks().forEach(this::deduplicateLists);
+ aiResponse.getProcessIssues().forEach(this::deduplicateLists);
+ aiResponse.getDocumentationGaps().forEach(this::deduplicateLists);
+ aiResponse.getQualityIssues().forEach(this::deduplicateLists);
+ }
+
+ private Map groupDeterministic(List deterministic) {
+ Map grouped = new HashMap<>();
+ for (RiskRadarResponse.RiskItem item : deterministic) {
+ grouped.compute(item.getPattern(), (k, v) -> {
+ if (v == null) {
+ return item;
+ }
+ mergeEvidence(v, item);
+ return v;
+ });
+ }
+ return grouped;
+ }
+
+ private boolean mergedIntoExisting(RiskRadarResponse aiResponse, RiskRadarResponse.RiskItem detItem) {
+ return findAndMerge(aiResponse.getHighRisks(), detItem) ||
+ findAndMerge(aiResponse.getProcessIssues(), detItem) ||
+ findAndMerge(aiResponse.getDocumentationGaps(), detItem) ||
+ findAndMerge(aiResponse.getQualityIssues(), detItem);
+ }
+
+ private void addToAppropriateCategory(RiskRadarResponse aiResponse, RiskRadarResponse.RiskItem detItem) {
+ String cat = (detItem.getCategory() != null) ? detItem.getCategory().toLowerCase() : "";
+ if (cat.contains("high")) {
+ aiResponse.getHighRisks().add(detItem);
+ } else if (cat.contains("process") || cat.contains("efficiency")) {
+ aiResponse.getProcessIssues().add(detItem);
+ } else if (cat.contains("documentation") || cat.contains("traceability") || cat.contains("gap")) {
+ aiResponse.getDocumentationGaps().add(detItem);
+ } else {
+ aiResponse.getQualityIssues().add(detItem);
+ }
+ }
+
+ private boolean findAndMerge(List list, RiskRadarResponse.RiskItem detItem) {
+ for (RiskRadarResponse.RiskItem item : list) {
+ if (item.getPattern().equalsIgnoreCase(detItem.getPattern())) {
+ mergeEvidence(item, detItem);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void mergeEvidence(RiskRadarResponse.RiskItem target, RiskRadarResponse.RiskItem source) {
+ List combinedEvidence = new ArrayList<>(target.getEvidence());
+ for (String evidence : source.getEvidence()) {
+ if (!combinedEvidence.contains(evidence)) {
+ combinedEvidence.add(evidence);
+ }
+ }
+ target.setEvidence(combinedEvidence);
+
+ List combinedMitigations = new ArrayList<>(target.getMitigations());
+ for (String mitigation : source.getMitigations()) {
+ if (!combinedMitigations.contains(mitigation)) {
+ combinedMitigations.add(mitigation);
+ }
+ }
+ target.setMitigations(combinedMitigations);
+ }
+
+ private void deduplicateLists(RiskRadarResponse.RiskItem item) {
+ if (item.getEvidence() != null) {
+ item.setEvidence(item.getEvidence().stream().distinct().toList());
+ }
+ if (item.getMitigations() != null) {
+ item.setMitigations(item.getMitigations().stream().distinct().toList());
+ }
+ }
+
+ private RiskRadarResponse buildDeterministicOnlyResponse(List deterministic) {
+ List high = new ArrayList<>();
+ List process = new ArrayList<>();
+ List documentation = new ArrayList<>();
+ List quality = new ArrayList<>();
+
+ for (RiskRadarResponse.RiskItem item : deterministic) {
+ String cat = item.getCategory().toLowerCase();
+ if (cat.contains("high")) {
+ high.add(item);
+ } else if (cat.contains("process") || cat.contains("efficiency")) {
+ process.add(item);
+ } else if (cat.contains("documentation") || cat.contains("traceability")) {
+ documentation.add(item);
+ } else {
+ quality.add(item);
+ }
+ }
+
+ return RiskRadarResponse.builder()
+ .highRisks(high)
+ .processIssues(process)
+ .documentationGaps(documentation)
+ .qualityIssues(quality)
+ .confidence(0.5)
+ .build();
+ }
+
+ private boolean filterByTasksets(DocSource source, List tasksets) {
+ if (tasksets == null || tasksets.isEmpty()) {
+ return true;
+ }
+ for (String ts : tasksets) {
+ if (source.getPath().contains("taskset-" + ts) || source.getPath().contains("taskset/" + ts)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean filterByDates(DocSource source, LocalDate from, LocalDate to) {
+ if (from == null && to == null) {
+ return true;
+ }
+
+ LocalDate taskDate = getEffectiveDate(source).toLocalDate();
+
+ return (from == null || !taskDate.isBefore(from)) && (to == null || !taskDate.isAfter(to));
+ }
+
+ private LocalDateTime getEffectiveDate(DocSource source) {
+ if (source.getDocUpdatedAt() != null) {
+ return source.getDocUpdatedAt();
+ } else if (source.getDocCreatedAt() != null) {
+ return source.getDocCreatedAt();
+ } else {
+ return source.getLastIndexed();
+ }
+ }
+
+ private String extractRelevantContent(String content) {
+ StringBuilder sb = new StringBuilder();
+
+ // 1. YAML frontmatter
+ if (content.startsWith("---")) {
+ int endYaml = content.indexOf("---", 3);
+ if (endYaml != -1) {
+ sb.append(content, 0, endYaml + 3).append("\n");
+ }
+ }
+
+ // 2. Junie Log (most important for risk detection)
+ extractSection(content, "## Junie Log", sb, 10);
+
+ // 3. Verification
+ extractSection(content, "## Verification", sb, 0);
+
+ return sb.toString();
+ }
+
+ private void extractSection(String content, String sectionHeader, StringBuilder sb, int maxEntries) {
+ int start = content.indexOf(sectionHeader);
+ if (start == -1) {
+ return;
+ }
+
+ int end = content.indexOf("\n## ", start + sectionHeader.length());
+ if (end == -1) {
+ end = content.length();
+ }
+
+ String sectionContent = content.substring(start, end).trim();
+
+ if (maxEntries > 0 && sectionHeader.equals("## Junie Log")) {
+ String[] lines = sectionContent.split("\n");
+ StringBuilder logSb = new StringBuilder();
+ int entryCount = 0;
+ for (String line : lines) {
+ if (line.trim().startsWith("### 20")) {
+ entryCount++;
+ if (entryCount > maxEntries) {
+ logSb.append("... [log truncated after ").append(maxEntries).append(" entries] ...\n");
+ break;
+ }
+ }
+ logSb.append(line).append("\n");
+ }
+ sb.append(logSb.toString().trim()).append("\n");
+ } else {
+ sb.append(sectionContent).append("\n");
+ }
+ }
+
+ private boolean filterByTags(DocSource source, List tags) {
+ if (tags == null || tags.isEmpty()) {
+ return true;
+ }
+ List chunks = chunkRepository.findBySource(source);
+ for (String tag : tags) {
+ for (DocChunk chunk : chunks) {
+ if (chunk.getContent().contains(tag)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/application/TasksetService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/TasksetService.java
new file mode 100644
index 000000000..eb33995f9
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/application/TasksetService.java
@@ -0,0 +1,144 @@
+package ch.goodone.angularai.backend.ai.application;
+
+import ch.goodone.angularai.backend.ai.dto.TasksetInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class TasksetService {
+
+ @Value("${app.docs.root-path:doc}")
+ private String docsRootPath;
+
+ private List cachedTasksets;
+
+ private static final Pattern TASKSET_PATTERN = Pattern.compile("- \\*\\*Taskset (\\d+): (.*?)\\*\\*");
+
+ public List getTasksets() {
+ if (cachedTasksets != null) {
+ return cachedTasksets;
+ }
+
+ Path taskIndexPath = Paths.get(docsRootPath, "knowledge/junie-tasks/taskindex.md");
+ if (!Files.exists(taskIndexPath) && !Paths.get(docsRootPath).isAbsolute()) {
+ // Try parent if not found and path is relative (when running from backend/)
+ taskIndexPath = Paths.get("..", docsRootPath, "knowledge/junie-tasks/taskindex.md");
+ }
+
+ if (!Files.exists(taskIndexPath)) {
+ log.warn("Task index file not found at: {}. Returning default tasksets.", taskIndexPath.toAbsolutePath());
+ cachedTasksets = getDefaultTasksets();
+ return cachedTasksets;
+ }
+
+ try {
+ String content = Files.readString(taskIndexPath);
+ cachedTasksets = parseTasksets(content);
+ return cachedTasksets;
+ } catch (IOException e) {
+ log.error("Error reading task index file: {}", taskIndexPath, e);
+ cachedTasksets = getDefaultTasksets();
+ return cachedTasksets;
+ }
+ }
+
+ private List parseTasksets(String content) {
+ List tasksets = new ArrayList<>();
+ Matcher matcher = TASKSET_PATTERN.matcher(content);
+
+ while (matcher.find()) {
+ String id = matcher.group(1);
+ String title = matcher.group(2);
+
+ int start = matcher.end();
+ int end;
+ Matcher nextMatcher = TASKSET_PATTERN.matcher(content);
+ if (nextMatcher.find(start)) {
+ end = nextMatcher.start();
+ } else {
+ end = content.length();
+ }
+
+ String section = content.substring(start, end);
+
+ String description = extractField(section, "Description");
+ String keywordsStr = extractField(section, "Keywords");
+ String datesStr = extractField(section, "Dates");
+
+ if (keywordsStr.endsWith(".")) {
+ keywordsStr = keywordsStr.substring(0, keywordsStr.length() - 1);
+ }
+
+ List keywords = Arrays.stream(keywordsStr.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toList();
+
+ LocalDate startDate = null;
+ LocalDate endDate = null;
+ if (datesStr.contains(" to ")) {
+ String[] parts = datesStr.split(" to ");
+ try {
+ startDate = LocalDate.parse(parts[0].trim());
+ endDate = LocalDate.parse(parts[1].trim());
+ } catch (Exception e) {
+ log.warn("Failed to parse dates for taskset {}: {}", id, datesStr);
+ }
+ }
+
+ tasksets.add(TasksetInfo.builder()
+ .id(id)
+ .title(title)
+ .description(description)
+ .keywords(keywords)
+ .startDate(startDate)
+ .endDate(endDate)
+ .build());
+ }
+
+ return tasksets;
+ }
+
+ private String extractField(String section, String fieldName) {
+ // Look for the field name followed by colon and bold markers, then capture until next field or end
+ Pattern p = Pattern.compile(fieldName + ".*?:\\*\\*\\s*(.*?)(?=\\s*- \\*\\*|\\s*$)", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
+ Matcher m = p.matcher(section);
+ if (m.find()) {
+ String val = m.group(1).trim();
+ // Clean up possible trailing artifacts from non-greedy match
+ if (val.contains("- **")) {
+ val = val.substring(0, val.indexOf("- **")).trim();
+ }
+ return val;
+ }
+ return "";
+ }
+
+ private List getDefaultTasksets() {
+ // Fallback in case taskindex.md is missing
+ List defaults = new ArrayList<>();
+ for (int i = 1; i <= 9; i++) {
+ defaults.add(TasksetInfo.builder()
+ .id(String.valueOf(i))
+ .title("Taskset " + i)
+ .description("No description available")
+ .keywords(new ArrayList<>())
+ .build());
+ }
+ return defaults;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/config/AiEnabledFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/config/AiEnabledFilter.java
new file mode 100644
index 000000000..b11f8cf0d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/config/AiEnabledFilter.java
@@ -0,0 +1,44 @@
+package ch.goodone.angularai.backend.ai.config;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * Filter to block access to AI endpoints if AI features are disabled.
+ */
+@Component
+public class AiEnabledFilter extends OncePerRequestFilter {
+
+ @Value("${app.ai.enabled:false}")
+ private boolean aiEnabled;
+
+ @Value("${app.ai.disabled-message:AI features are currently disabled by the administrator.}")
+ private String aiDisabledMessage;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ String path = request.getRequestURI();
+
+ if (!aiEnabled && path.startsWith("/api/ai") && !path.startsWith("/api/ai/admin")) {
+ // Allow admin endpoints if any, but block main AI features.
+ // Actually, we should probably block all under /api/ai if aiEnabled is false.
+ // But let's check if there are any exceptions.
+
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("application/json");
+ response.getWriter().write("{\"error\": \"" + aiDisabledMessage + "\"}");
+ return;
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftRequest.java
new file mode 100644
index 000000000..27d5db665
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftRequest.java
@@ -0,0 +1,21 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AdrDriftRequest {
+ private LocalDate fromDate;
+ private LocalDate toDate;
+ private List adrDocPaths;
+ private List tasksets;
+ private String recaptchaToken;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftResponse.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftResponse.java
new file mode 100644
index 000000000..1338928d5
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AdrDriftResponse.java
@@ -0,0 +1,39 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AdrDriftResponse {
+ private List principles;
+ private List potentialDrifts;
+ private Double confidence;
+ private List sources;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class PrincipleItem {
+ private String statement;
+ private String adrSource;
+ }
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class DriftItem {
+ private String adrSource;
+ private List taskEvidence;
+ private String rationale;
+ private List remediation;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestActionRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestActionRequest.java
new file mode 100644
index 000000000..6b2d2a0e7
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestActionRequest.java
@@ -0,0 +1,12 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class AiCreditRequestActionRequest {
+ @NotNull
+ private AiCreditRequest.Status status;
+ private String note;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestCreateRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestCreateRequest.java
new file mode 100644
index 000000000..2ff89443b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestCreateRequest.java
@@ -0,0 +1,25 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class AiCreditRequestCreateRequest {
+ @NotNull
+ @Min(1)
+ private Integer amount;
+
+ @NotNull
+ private AiCreditRequest.Type type;
+
+ @NotBlank
+ @Size(max = 1000)
+ private String reason;
+
+ @NotBlank
+ private String captchaToken;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestDTO.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestDTO.java
new file mode 100644
index 000000000..ca66ef807
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/AiCreditRequestDTO.java
@@ -0,0 +1,34 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import lombok.Data;
+import java.time.LocalDateTime;
+
+@Data
+public class AiCreditRequestDTO {
+ private Long id;
+ private String userLogin;
+ private String userName;
+ private Integer requestedAmount;
+ private AiCreditRequest.Type requestType;
+ private String reason;
+ private String note;
+ private AiCreditRequest.Status status;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public static AiCreditRequestDTO fromEntity(AiCreditRequest entity) {
+ AiCreditRequestDTO dto = new AiCreditRequestDTO();
+ dto.setId(entity.getId());
+ dto.setUserLogin(entity.getUserLogin());
+ dto.setUserName(entity.getUserName());
+ dto.setRequestedAmount(entity.getRequestedAmount());
+ dto.setRequestType(entity.getRequestType());
+ dto.setReason(entity.getReason());
+ dto.setNote(entity.getNote());
+ dto.setStatus(entity.getStatus());
+ dto.setCreatedAt(entity.getCreatedAt());
+ dto.setUpdatedAt(entity.getUpdatedAt());
+ return dto;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainRequest.java
new file mode 100644
index 000000000..c3969f755
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainRequest.java
@@ -0,0 +1,22 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Request for the AI-powered Architecture explanation.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ArchitectureExplainRequest {
+ private String question;
+ private String recaptchaToken;
+
+ public ArchitectureExplainRequest(String question) {
+ this.question = question;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainResult.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainResult.java
new file mode 100644
index 000000000..6abc548fb
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/ArchitectureExplainResult.java
@@ -0,0 +1,21 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import java.util.List;
+
+/**
+ * Result of the AI-powered Architecture explanation.
+ */
+public record ArchitectureExplainResult(
+ String summary,
+ List highlights,
+ List sources
+) {
+ /**
+ * Represents a source used for the architecture explanation.
+ */
+ public record Source(
+ String title,
+ String path,
+ String relevance
+ ) {}
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseRequest.java
new file mode 100644
index 000000000..124b10a99
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseRequest.java
@@ -0,0 +1,22 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * Request for the AI-powered Quick Add parsing.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class QuickAddParseRequest {
+ private String text;
+ private String recaptchaToken;
+
+ public QuickAddParseRequest(String text) {
+ this.text = text;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseResult.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseResult.java
new file mode 100644
index 000000000..f1f11622c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/QuickAddParseResult.java
@@ -0,0 +1,20 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import java.util.List;
+
+/**
+ * Result of the AI-powered Quick Add parsing.
+ */
+public record QuickAddParseResult(
+ String title,
+ String description,
+ String category,
+ Double confidence,
+ List tags,
+ List assumptions,
+ String dueDate,
+ String dueTime,
+ String priority,
+ String status,
+ boolean aiUsed
+) {}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveRequest.java
new file mode 100644
index 000000000..d720b397a
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveRequest.java
@@ -0,0 +1,21 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RetrospectiveRequest {
+ private LocalDate fromDate;
+ private LocalDate toDate;
+ private List tasksets;
+ private List tags;
+ private String mode;
+ private String recaptchaToken;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveResponse.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveResponse.java
new file mode 100644
index 000000000..808a072f4
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RetrospectiveResponse.java
@@ -0,0 +1,30 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RetrospectiveResponse {
+ private String summary;
+ private List highlights;
+ private List problems;
+ private List suggestions;
+ private List actionItems;
+ private Double confidence;
+ private List sources;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class SourceRef {
+ private String id;
+ private String section;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarRequest.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarRequest.java
new file mode 100644
index 000000000..b08ea412c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarRequest.java
@@ -0,0 +1,20 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RiskRadarRequest {
+ private LocalDate fromDate;
+ private LocalDate toDate;
+ private List tasksets;
+ private List tags;
+ private String recaptchaToken;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarResponse.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarResponse.java
new file mode 100644
index 000000000..faadff916
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/RiskRadarResponse.java
@@ -0,0 +1,30 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RiskRadarResponse {
+ private List highRisks;
+ private List processIssues;
+ private List documentationGaps;
+ private List qualityIssues;
+ private Double confidence;
+
+ @Data
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class RiskItem {
+ private String pattern;
+ private List evidence;
+ private List mitigations;
+ private String category;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/TasksetInfo.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/TasksetInfo.java
new file mode 100644
index 000000000..ab38c5c3d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/dto/TasksetInfo.java
@@ -0,0 +1,22 @@
+package ch.goodone.angularai.backend.ai.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDate;
+import java.util.List;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class TasksetInfo {
+ private String id;
+ private String title;
+ private String description;
+ private List keywords;
+ private LocalDate startDate;
+ private LocalDate endDate;
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiDisabledException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiDisabledException.java
new file mode 100644
index 000000000..02841fabd
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiDisabledException.java
@@ -0,0 +1,10 @@
+package ch.goodone.angularai.backend.ai.exception;
+
+/**
+ * Exception thrown when AI features are disabled by the administrator.
+ */
+public class AiDisabledException extends AiException {
+ public AiDisabledException(String message) {
+ super(message);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiException.java
new file mode 100644
index 000000000..9e7a3dfb7
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiException.java
@@ -0,0 +1,15 @@
+package ch.goodone.angularai.backend.ai.exception;
+
+/**
+ * Base exception for all AI-related errors.
+ */
+public class AiException extends RuntimeException {
+
+ public AiException(String message) {
+ super(message);
+ }
+
+ public AiException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiParsingException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiParsingException.java
new file mode 100644
index 000000000..6be790d79
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiParsingException.java
@@ -0,0 +1,10 @@
+package ch.goodone.angularai.backend.ai.exception;
+
+/**
+ * Exception thrown when the AI output cannot be parsed into the expected format (502).
+ */
+public class AiParsingException extends AiException {
+ public AiParsingException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiProviderException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiProviderException.java
new file mode 100644
index 000000000..2320ac929
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiProviderException.java
@@ -0,0 +1,10 @@
+package ch.goodone.angularai.backend.ai.exception;
+
+/**
+ * Exception thrown when the AI provider is unavailable or returns an error (503).
+ */
+public class AiProviderException extends AiException {
+ public AiProviderException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiRateLimitException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiRateLimitException.java
new file mode 100644
index 000000000..3ed403100
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/exception/AiRateLimitException.java
@@ -0,0 +1,10 @@
+package ch.goodone.angularai.backend.ai.exception;
+
+/**
+ * Exception thrown when the AI provider rate limit is exceeded (429).
+ */
+public class AiRateLimitException extends AiException {
+ public AiRateLimitException(String message) {
+ super(message);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/AiObservabilityService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/AiObservabilityService.java
new file mode 100644
index 000000000..eb0976ef4
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/AiObservabilityService.java
@@ -0,0 +1,238 @@
+package ch.goodone.angularai.backend.ai.observability;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import ch.goodone.angularai.backend.ai.usage.AiCreditExhaustedException;
+import ch.goodone.angularai.backend.ai.usage.AiUsageCostService;
+import ch.goodone.angularai.backend.ai.usage.AiUsageService;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.MDC;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * Service for AI observability: logging, metrics, and correlation IDs.
+ */
+@Service
+@Slf4j
+public class AiObservabilityService {
+
+ private final MeterRegistry meterRegistry;
+ private final AiUsageService aiUsageService;
+ private final AiUsageCostService aiUsageCostService;
+ private final UserRepository userRepository;
+ private final ActionLogService actionLogService;
+
+ public AiObservabilityService(MeterRegistry meterRegistry,
+ AiUsageService aiUsageService,
+ @Lazy AiUsageCostService aiUsageCostService,
+ UserRepository userRepository,
+ ActionLogService actionLogService) {
+ this.meterRegistry = meterRegistry;
+ this.aiUsageService = aiUsageService;
+ this.aiUsageCostService = aiUsageCostService;
+ this.userRepository = userRepository;
+ this.actionLogService = actionLogService;
+ }
+
+ private final ThreadLocal usageCapture = ThreadLocal.withInitial(UsageCapture::new);
+
+ @Data
+ public static class UsageCapture {
+ private Usage usage;
+ private String output;
+ }
+
+ @Value("${app.ai.logging.detailed:false}")
+ private boolean detailedLogging;
+
+ private static final String REQUEST_ID = "requestId";
+ private static final String USER_ID = "userLogin";
+ private static final String AI_MODEL = "aiModel";
+ private static final String AI_PROVIDER = "aiProvider";
+ private static final String AI_PROMPT_VERSION = "aiPromptVersion";
+ private static final String TAG_OPERATION = "operation";
+ private static final String TAG_PROVIDER = "provider";
+ private static final String TAG_MODEL = "model";
+ private static final String TAG_SUCCESS = "success";
+ private static final String TAG_REASON = "reason";
+ private static final String TAG_ERROR = "error";
+
+ /**
+ * Reports usage from a call to be recorded by the current observability context.
+ */
+ public void reportUsage(Usage usage, String output) {
+ UsageCapture capture = usageCapture.get();
+ capture.setUsage(usage);
+ capture.setOutput(output);
+ }
+
+ /**
+ * Records an AI call with metadata and timing.
+ *
+ * @param operation The operation name (e.g., "quick-add-parse").
+ * @param provider The AI provider.
+ * @param model The AI model.
+ * @param promptVersion The prompt version.
+ * @param call The actual AI call to execute.
+ * @param The result type.
+ * @return The result of the AI call.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public T recordCall(String operation, String provider, String model, String promptVersion, Supplier call) {
+ return recordCall(operation, provider, model, promptVersion, null, call);
+ }
+
+ /**
+ * Records an AI call with metadata, timing and optional input for detailed logging.
+ *
+ * @param operation The operation name.
+ * @param provider The AI provider.
+ * @param model The AI model.
+ * @param promptVersion The prompt version.
+ * @param input Optional input for detailed logging.
+ * @param call The actual AI call to execute.
+ * @param The result type.
+ * @return The result of the AI call.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public T recordCall(String operation, String provider, String model, String promptVersion, String input, Supplier call) {
+ String requestId = UUID.randomUUID().toString().substring(0, 8);
+ setupMdc(requestId, provider, model, promptVersion);
+
+ long startTime = System.nanoTime();
+ boolean success = false;
+ Optional currentUser = getCurrentUser();
+ String currentUserLogin = currentUser.map(User::getLogin).orElse("anonymous");
+ MDC.put(USER_ID, sanitizeLog(currentUserLogin));
+
+ try {
+ logAiCallStart(operation, provider, model, promptVersion, input);
+
+ if (currentUser.isPresent() && !aiUsageService.hasRemainingCredits(currentUser.get())) {
+ log.warn("AI Credits Exhausted for user: {}", currentUserLogin);
+ throw new AiCreditExhaustedException("AI daily credit limit reached.");
+ }
+
+ // Prepare usage capture
+ UsageCapture currentCapture = usageCapture.get();
+ currentCapture.setUsage(null);
+ currentCapture.setOutput(null);
+
+ T result = call.get();
+ success = true;
+
+ handleSuccessfulAiCall(currentUser, operation, model, input, provider);
+ return result;
+ } catch (AiCreditExhaustedException e) {
+ handleRejectedAiCall(operation, e);
+ throw e;
+ } catch (Exception e) {
+ handleFailedAiCall(operation, provider, e);
+ throw e;
+ } finally {
+ finalizeAiCall(startTime, operation, currentUserLogin, success, provider, model);
+ cleanupMdc();
+ usageCapture.remove();
+ }
+ }
+
+ private void setupMdc(String requestId, String provider, String model, String promptVersion) {
+ MDC.put(REQUEST_ID, sanitizeLog(requestId));
+ MDC.put(AI_PROVIDER, sanitizeLog(provider));
+ MDC.put(AI_MODEL, sanitizeLog(model));
+ MDC.put(AI_PROMPT_VERSION, sanitizeLog(promptVersion));
+ }
+
+ private void logAiCallStart(String operation, String provider, String model, String promptVersion, String input) {
+ if (detailedLogging && input != null) {
+ log.info("AI Call Started: operation={}, provider={}, model={}, promptVersion={}, input='{}'",
+ operation, provider, model, promptVersion, input);
+ } else {
+ log.info("AI Call Started: operation={}, provider={}, model={}, promptVersion={}",
+ operation, provider, model, promptVersion);
+ }
+ }
+
+ private void handleSuccessfulAiCall(Optional currentUser, String operation, String model, String input, String provider) {
+ currentUser.ifPresent(user -> {
+ if (!"quick-add-parse".equals(operation)) {
+ aiUsageService.incrementUsage(user, operation);
+ }
+ actionLogService.log(user.getLogin(), "AI_CALL", "Operation: " + operation + ", Model: " + model);
+ });
+
+ // Record cost
+ UsageCapture capture = usageCapture.get();
+ aiUsageCostService.recordUsage(currentUser.orElse(null), operation, provider, model, input, capture.getOutput(), capture.getUsage());
+ }
+
+ private void handleRejectedAiCall(String operation, AiCreditExhaustedException e) {
+ log.warn("AI Call Rejected: operation={}, reason={}", operation, e.getMessage());
+ meterRegistry.counter("ai.calls.rejected", TAG_OPERATION, operation, TAG_REASON, "credits_exhausted").increment();
+ }
+
+ private void handleFailedAiCall(String operation, String provider, Exception e) {
+ log.error("AI Call Failed: operation={}, error={}", operation, e.getMessage());
+ meterRegistry.counter("ai.calls.failures", TAG_OPERATION, operation, TAG_PROVIDER, provider, TAG_ERROR, e.getClass().getSimpleName()).increment();
+ }
+
+ private void finalizeAiCall(long startTime, String operation, String currentUserLogin, boolean success, String provider, String model) {
+ long durationNs = System.nanoTime() - startTime;
+ long durationMs = TimeUnit.NANOSECONDS.toMillis(durationNs);
+
+ log.info("AI Call Completed: operation={}, userId={}, success={}, latency={}ms", operation, currentUserLogin, success, durationMs);
+
+ meterRegistry.timer("ai.calls.latency", TAG_OPERATION, operation, TAG_PROVIDER, provider, TAG_MODEL, model, TAG_SUCCESS, String.valueOf(success))
+ .record(durationNs, TimeUnit.NANOSECONDS);
+
+ meterRegistry.counter("ai.calls.total", TAG_OPERATION, operation, TAG_PROVIDER, provider, TAG_MODEL, model, TAG_SUCCESS, String.valueOf(success))
+ .increment();
+ }
+
+ private void cleanupMdc() {
+ MDC.remove(REQUEST_ID);
+ MDC.remove(USER_ID);
+ MDC.remove(AI_PROVIDER);
+ MDC.remove(AI_MODEL);
+ MDC.remove(AI_PROMPT_VERSION);
+ }
+
+ /**
+ * Increments the retry counter for AI calls.
+ */
+ public void recordRetry(String operation, String provider) {
+ meterRegistry.counter("ai.calls.retries", TAG_OPERATION, operation, TAG_PROVIDER, provider).increment();
+ }
+
+ /**
+ * Records an embedding job.
+ */
+ public void recordEmbeddingJob(String provider, String model, int count) {
+ meterRegistry.counter("ai.embeddings.total", TAG_PROVIDER, provider, TAG_MODEL, model).increment(count);
+ }
+
+ private Optional getCurrentUser() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) {
+ return Optional.empty();
+ }
+ return userRepository.findByLogin(auth.getName());
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/OpenAiHealthIndicator.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/OpenAiHealthIndicator.java
new file mode 100644
index 000000000..626626d97
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/observability/OpenAiHealthIndicator.java
@@ -0,0 +1,44 @@
+package ch.goodone.angularai.backend.ai.observability;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.boot.health.contributor.Health;
+import org.springframework.boot.health.contributor.HealthIndicator;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * Health indicator for OpenAI API connectivity.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OpenAiHealthIndicator implements HealthIndicator {
+
+ private final ChatModel openAiChatModel;
+
+ @Override
+ public Health health() {
+ try {
+ // We don't want to make a real AI call as it costs money and is slow.
+ // We check if the bean is available and potentially if the API key is set.
+ if (openAiChatModel != null) {
+ return Health.up()
+ .withDetails(Map.of(
+ "provider", "OpenAI",
+ "status", "Initialized"
+ ))
+ .build();
+ } else {
+ return Health.down()
+ .withDetail("reason", "OpenAI ChatModel not initialized")
+ .build();
+ }
+ } catch (Exception e) {
+ log.error("OpenAI Health Check failed", e);
+ return Health.down(e).build();
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/prompt/StructuredOutputService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/prompt/StructuredOutputService.java
new file mode 100644
index 000000000..2109deb3d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/prompt/StructuredOutputService.java
@@ -0,0 +1,151 @@
+package ch.goodone.angularai.backend.ai.prompt;
+
+import ch.goodone.angularai.backend.ai.exception.AiParsingException;
+import ch.goodone.angularai.backend.ai.exception.AiProviderException;
+import ch.goodone.angularai.backend.ai.exception.AiRateLimitException;
+import ch.goodone.angularai.backend.ai.observability.AiObservabilityService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.chat.prompt.PromptTemplate;
+import org.springframework.ai.converter.BeanOutputConverter;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.Resource;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Service to handle AI calls with structured JSON output and automatic retry/repair.
+ */
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class StructuredOutputService {
+ private static final String FORMAT_INSTRUCTIONS = "formatInstructions";
+
+ private final AiObservabilityService observabilityService;
+
+ @Value("classpath:prompts/common/v1/repair-json.st")
+ private Resource repairJsonPromptResource;
+
+ /**
+ * Calls the AI model and expects a structured output of type T.
+ * Retries once with a repair prompt if parsing fails.
+ *
+ * @param chatModel The AI chat model to use.
+ * @param promptResource The resource containing the prompt template.
+ * @param templateModel Variables for the prompt template.
+ * @param responseType The class of the expected result DTO.
+ * @param The type of the expected result DTO.
+ * @return The parsed DTO.
+ * @throws RuntimeException if parsing fails even after a retry.
+ */
+ public T call(ChatModel chatModel, Resource promptResource, Map templateModel, Class responseType) {
+ BeanOutputConverter converter = new BeanOutputConverter<>(responseType);
+ String formatInstructions = converter.getFormat();
+
+ Map modelWithInstructions = new HashMap<>(templateModel);
+ modelWithInstructions.put(FORMAT_INSTRUCTIONS, formatInstructions);
+
+ PromptTemplate promptTemplate = new PromptTemplate(promptResource);
+ Prompt prompt = promptTemplate.create(modelWithInstructions);
+
+ String content = null;
+ try {
+ ChatResponse response = callModel(chatModel, prompt);
+ content = response.getResult().getOutput().getText();
+
+ // Report usage for observability/cost tracking
+ observabilityService.reportUsage(response.getMetadata().getUsage(), content);
+
+ return converter.convert(content);
+ } catch (AiProviderException | AiRateLimitException e) {
+ throw e;
+ } catch (Exception e) {
+ // 1. Try deterministic repair for common truncation issues
+ T truncatedRepair = tryRepairTruncation(converter, content);
+ if (truncatedRepair != null) {
+ return truncatedRepair;
+ }
+
+ log.warn("AI call or parsing failed for {} (Prompt: {}). Error: {}. Content: {}",
+ responseType.getSimpleName(), promptResource.getDescription(), e.getMessage(), content);
+ return attemptRepair(chatModel, converter, content, e.getMessage());
+ }
+ }
+
+ private T tryRepairTruncation(BeanOutputConverter converter, String content) {
+ if (content == null || content.isBlank()) {
+ return null;
+ }
+
+ String trimmed = content.trim();
+ if (trimmed.startsWith("{") && !trimmed.endsWith("}")) {
+ log.info("Detected potentially truncated JSON, attempting deterministic repair");
+
+ // Try common completion suffixes in order of complexity
+ String[] suffixes = {"}", "\n}", "]}", "\n]}", "\"]}", "\"\n]}"};
+ for (String suffix : suffixes) {
+ try {
+ return converter.convert(trimmed + suffix);
+ } catch (Exception e) {
+ log.trace("Deterministic repair failed with suffix '{}': {}", suffix, e.getMessage());
+ }
+ }
+ }
+ return null;
+ }
+
+ private ChatResponse callModel(ChatModel chatModel, Prompt prompt) {
+ try {
+ return chatModel.call(prompt);
+ } catch (Exception e) {
+ String msg = e.getMessage().toLowerCase();
+ log.error("AI model call failed: {}", e.getMessage());
+
+ if (msg.contains("429") || msg.contains("rate limit")) {
+ throw new AiRateLimitException("AI provider rate limit exceeded");
+ }
+ if (msg.contains("connection") || msg.contains("unavailable") || msg.contains("refused") || msg.contains("timeout")) {
+ throw new AiProviderException("AI provider is currently unavailable. Please ensure your local AI service (e.g. Ollama) is running.", e);
+ }
+ if (msg.contains("404") || msg.contains("not found")) {
+ throw new AiProviderException("AI model not found. Please ensure the requested model is pulled in your local AI service.", e);
+ }
+ throw new AiProviderException("Error calling AI provider: " + e.getMessage(), e);
+ }
+ }
+
+ private T attemptRepair(ChatModel chatModel, BeanOutputConverter converter, String failedContent, String errorMessage) {
+ log.info("Attempting to repair JSON output for response type");
+ observabilityService.recordRetry("json-repair", "unknown");
+
+ Map repairModel = Map.of(
+ "previousResponse", failedContent != null ? failedContent : "NULL",
+ "errorMessage", errorMessage,
+ FORMAT_INSTRUCTIONS, converter.getFormat()
+ );
+
+ PromptTemplate repairTemplate = new PromptTemplate(repairJsonPromptResource);
+ Prompt repairPrompt = repairTemplate.create(repairModel);
+
+ try {
+ ChatResponse repairResponse = callModel(chatModel, repairPrompt);
+ String repairedContent = repairResponse.getResult().getOutput().getText();
+
+ // Report usage for observability/cost tracking
+ observabilityService.reportUsage(repairResponse.getMetadata().getUsage(), repairedContent);
+
+ return converter.convert(repairedContent);
+ } catch (AiProviderException | AiRateLimitException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Repair attempt failed. Error: {}", e.getMessage());
+ throw new AiParsingException("Failed to get valid structured output from AI after retry. Original error: " + errorMessage, e);
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditExhaustedException.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditExhaustedException.java
new file mode 100644
index 000000000..b833e7ebd
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditExhaustedException.java
@@ -0,0 +1,11 @@
+package ch.goodone.angularai.backend.ai.usage;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
+public class AiCreditExhaustedException extends RuntimeException {
+ public AiCreditExhaustedException(String message) {
+ super(message);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditRequestService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditRequestService.java
new file mode 100644
index 000000000..bf941af2d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiCreditRequestService.java
@@ -0,0 +1,99 @@
+package ch.goodone.angularai.backend.ai.usage;
+
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.AiCreditRequestRepository;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import ch.goodone.angularai.backend.service.EmailService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AiCreditRequestService {
+
+ private final AiCreditRequestRepository repository;
+ private final EmailService emailService;
+ private final ActionLogService actionLogService;
+ private final UserRepository userRepository;
+ private final AiUsageService aiUsageService;
+
+ public AiCreditRequest createRequest(User user, Integer amount, AiCreditRequest.Type type, String reason) {
+ AiCreditRequest request = new AiCreditRequest();
+ request.setUserLogin(user.getLogin());
+ request.setUserEmail(user.getEmail());
+ request.setUserName(user.getFirstName() + " " + user.getLastName());
+ request.setRequestedAmount(amount);
+ request.setRequestType(type);
+ request.setReason(reason);
+ request.setStatus(AiCreditRequest.Status.OPEN);
+
+ AiCreditRequest saved = repository.save(request);
+
+ actionLogService.log(user.getLogin(), "AI_CREDIT_REQUEST_CREATED",
+ "Type: " + type + ", Amount: " + amount + ", Reason: " + reason);
+
+ // Send notification email to admin
+ emailService.sendAiCreditRequestNotification(saved);
+
+ return saved;
+ }
+
+ public List getAllRequests() {
+ return repository.findAllByOrderByCreatedAtDesc();
+ }
+
+ public List getRequestsForUser(String login) {
+ return repository.findAllByUserLoginOrderByCreatedAtDesc(login);
+ }
+
+ @Transactional
+ public AiCreditRequest updateStatus(Long id, AiCreditRequest.Status status, String note, String adminLogin) {
+ AiCreditRequest request = repository.findById(id)
+ .orElseThrow(() -> new IllegalArgumentException("Request not found"));
+
+ if (status == AiCreditRequest.Status.APPROVED && request.getStatus() != AiCreditRequest.Status.APPROVED) {
+ applyCredits(request);
+ }
+
+ request.setStatus(status);
+ request.setNote(note);
+ request.setUpdatedAt(LocalDateTime.now());
+
+ AiCreditRequest saved = repository.save(request);
+
+ actionLogService.log(adminLogin, "AI_CREDIT_REQUEST_UPDATED",
+ "ID: " + id + ", Status: " + status + ", Note: " + note);
+
+ // Notify user
+ emailService.sendAiCreditRequestUpdateNotification(saved);
+
+ return saved;
+ }
+
+ private void applyCredits(AiCreditRequest request) {
+ User user = userRepository.findByLogin(request.getUserLogin())
+ .orElseThrow(() -> new IllegalArgumentException("User for credit request not found: " + request.getUserLogin()));
+
+ if (request.getRequestType() == AiCreditRequest.Type.DAILY_LIMIT_INCREASE) {
+ // Permanently update the user's limit
+ int currentLimit = aiUsageService.getUserDailyLimit(user);
+ user.setAiDailyLimit(currentLimit + request.getRequestedAmount());
+ userRepository.save(user);
+ log.info("Permanently increased daily limit for user {} by {}. New limit: {}",
+ user.getLogin(), request.getRequestedAmount(), user.getAiDailyLimit());
+ } else if (request.getRequestType() == AiCreditRequest.Type.ONE_TIME_TOPUP) {
+ // Add extra credits for today only
+ aiUsageService.addExtraCredits(user, request.getRequestedAmount());
+ log.info("Applied one-time top-up of {} credits for user {} today",
+ request.getRequestedAmount(), user.getLogin());
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageCostService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageCostService.java
new file mode 100644
index 000000000..2fc318fc5
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageCostService.java
@@ -0,0 +1,199 @@
+package ch.goodone.angularai.backend.ai.usage;
+
+import ch.goodone.angularai.backend.ai.AiProperties;
+import ch.goodone.angularai.backend.model.AiUsageCost;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.AiUsageCostRepository;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+public class AiUsageCostService {
+ private final AiUsageCostRepository repository;
+ private final AiProperties aiProperties;
+ private final UserRepository userRepository;
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void recordUsage(User user, String endpoint, String provider, String model, String input, String output, Usage usage) {
+ long inTokens = calculateInputTokens(usage, input);
+ long outTokens = calculateOutputTokens(usage, output);
+
+ recordUsageInternal(user, endpoint, provider, model, inTokens, outTokens);
+ }
+
+ private long calculateInputTokens(Usage usage, String input) {
+ if (usage != null && usage.getPromptTokens() != null && usage.getPromptTokens() > 0) {
+ return usage.getPromptTokens();
+ }
+ long estimated = (input != null && !input.isBlank()) ? (long) Math.ceil(input.length() / 4.0) : 0L;
+ return (estimated == 0 && input != null && !input.isBlank()) ? 1L : estimated;
+ }
+
+ private long calculateOutputTokens(Usage usage, String output) {
+ if (usage != null && usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
+ return usage.getCompletionTokens();
+ }
+ long estimated = (output != null && !output.isBlank()) ? (long) Math.ceil(output.length() / 4.0) : 0L;
+ return (estimated == 0 && output != null && !output.isBlank()) ? 1L : estimated;
+ }
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void recordUsage(User user, String endpoint, String provider, String model, Long inputTokens, Long outputTokens) {
+ recordUsageInternal(user, endpoint, provider, model, inputTokens, outputTokens);
+ }
+
+ private void recordUsageInternal(User user, String endpoint, String provider, String model, Long inputTokens, Long outputTokens) {
+ long inTokens = inputTokens != null ? inputTokens : 0L;
+ long outTokens = outputTokens != null ? outputTokens : 0L;
+
+ BigDecimal cost = calculateCost(model, inTokens, outTokens);
+
+ // Re-load user to ensure it's attached to this new transaction
+ User attachedUser = null;
+ if (user != null && user.getId() != null) {
+ attachedUser = userRepository.findById(user.getId()).orElse(null);
+ }
+
+ AiUsageCost usageCost = AiUsageCost.builder()
+ .user(attachedUser)
+ .timestamp(LocalDateTime.now())
+ .endpoint(endpoint)
+ .provider(provider)
+ .model(model)
+ .inputTokens(inTokens)
+ .outputTokens(outTokens)
+ .estimatedCost(cost)
+ .build();
+
+ repository.save(usageCost);
+ log.debug("Recorded AI usage cost: user={}, endpoint={}, model={}, cost={}",
+ user != null ? user.getLogin() : "anonymous", endpoint, model, cost);
+ }
+
+ public BigDecimal calculateCost(String model, long inputTokens, long outputTokens) {
+ if (model == null) {
+ log.warn("Model name is null. Cost calculation skipped.");
+ return BigDecimal.ZERO;
+ }
+
+ AiProperties.ModelPrice pricing = findPricingForModel(model);
+ if (pricing == null) {
+ log.warn("No pricing configured for model: {}. (Keys available: {})", model,
+ aiProperties.getPricing() != null ? aiProperties.getPricing().keySet() : "none (pricing map is null)");
+ return BigDecimal.ZERO;
+ }
+
+ BigDecimal inputPrice = BigDecimal.valueOf(pricing.getInputPricePer1k() != null ? pricing.getInputPricePer1k() : 0.0);
+ BigDecimal outputPrice = BigDecimal.valueOf(pricing.getOutputPricePer1k() != null ? pricing.getOutputPricePer1k() : 0.0);
+
+ BigDecimal inputCost = inputPrice.multiply(BigDecimal.valueOf(inputTokens)).divide(BigDecimal.valueOf(1000L), 10, RoundingMode.HALF_UP);
+ BigDecimal outputCost = outputPrice.multiply(BigDecimal.valueOf(outputTokens)).divide(BigDecimal.valueOf(1000L), 10, RoundingMode.HALF_UP);
+
+ return inputCost.add(outputCost).setScale(6, RoundingMode.HALF_UP);
+ }
+
+ private AiProperties.ModelPrice findPricingForModel(String model) {
+ if (aiProperties.getPricing() == null || model == null) {
+ return null;
+ }
+
+ String trimmedModel = model.trim();
+ AiProperties.ModelPrice pricing = aiProperties.getPricing().get(trimmedModel);
+ if (pricing != null) return pricing;
+
+ // Try case-insensitive and normalized matches
+ pricing = findCaseInsensitiveMatch(trimmedModel);
+ if (pricing != null) return pricing;
+
+ pricing = findNormalizedMatch(trimmedModel);
+ if (pricing != null) return pricing;
+
+ return findStartsWithMatch(trimmedModel);
+ }
+
+ private AiProperties.ModelPrice findCaseInsensitiveMatch(String model) {
+ for (Map.Entry entry : aiProperties.getPricing().entrySet()) {
+ if (entry.getKey().trim().equalsIgnoreCase(model)) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ private AiProperties.ModelPrice findNormalizedMatch(String model) {
+ String normalizedModel = model.toLowerCase().replace("-", "").replace("_", "").replace(".", "");
+ for (Map.Entry entry : aiProperties.getPricing().entrySet()) {
+ String normalizedKey = entry.getKey().trim().toLowerCase().replace("-", "").replace("_", "").replace(".", "");
+ if (normalizedKey.equals(normalizedModel)) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ private AiProperties.ModelPrice findStartsWithMatch(String model) {
+ String lowerModel = model.toLowerCase();
+ for (Map.Entry entry : aiProperties.getPricing().entrySet()) {
+ String pricingKey = entry.getKey().trim().toLowerCase();
+ if (lowerModel.startsWith(pricingKey)) {
+ return entry.getValue();
+ }
+ }
+ return null;
+ }
+
+ public Map getAggregatedCosts(LocalDateTime from) {
+ Map result = new HashMap<>();
+
+ LocalDateTime startOfDay = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
+ LocalDateTime startOfMonth = LocalDateTime.now().withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
+
+ result.put("totalCostToday", orZero(repository.sumEstimatedCostSince(startOfDay)));
+ result.put("totalCostThisMonth", orZero(repository.sumEstimatedCostSince(startOfMonth)));
+
+ result.put("costPerUser", mapToEntryList(repository.sumEstimatedCostPerUserSince(from)));
+ result.put("costPerFeature", mapToEntryList(repository.sumEstimatedCostPerFeatureSince(from)));
+ result.put("costPerModel", mapToEntryList(repository.sumEstimatedCostPerModelSince(from)));
+ result.put("costPerDay", mapToEntryList(repository.sumEstimatedCostPerDaySince(from)));
+
+ return result;
+ }
+
+ private BigDecimal orZero(BigDecimal value) {
+ return value != null ? value : BigDecimal.ZERO;
+ }
+
+ private List> mapToEntryList(List rows) {
+ if (rows == null) {
+ return List.of();
+ }
+ return rows.stream().map(row -> {
+ Map entry = new HashMap<>();
+ entry.put("key", row[0] != null ? row[0] : "unknown");
+
+ BigDecimal value = BigDecimal.ZERO;
+ if (row[1] instanceof BigDecimal bd) {
+ value = bd;
+ } else if (row[1] instanceof Number n) {
+ value = new BigDecimal(n.toString());
+ }
+
+ entry.put("value", value);
+ return entry;
+ }).toList();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageService.java b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageService.java
new file mode 100644
index 000000000..8ad976c3f
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/ai/usage/AiUsageService.java
@@ -0,0 +1,193 @@
+package ch.goodone.angularai.backend.ai.usage;
+
+import ch.goodone.angularai.backend.dto.ai.UserAiUsageDto;
+import ch.goodone.angularai.backend.model.AiFeatureUsage;
+import ch.goodone.angularai.backend.model.AiSuffixRule;
+import ch.goodone.angularai.backend.model.Role;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.model.UserAiUsage;
+import ch.goodone.angularai.backend.repository.AiFeatureUsageRepository;
+import ch.goodone.angularai.backend.repository.AiSuffixRuleRepository;
+import ch.goodone.angularai.backend.repository.UserAiUsageRepository;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.service.SystemSettingService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+@Slf4j
+@Transactional(readOnly = true)
+public class AiUsageService {
+
+ private final UserAiUsageRepository usageRepository;
+ private final AiFeatureUsageRepository featureUsageRepository;
+ private final AiSuffixRuleRepository suffixRuleRepository;
+ private final UserRepository userRepository;
+ private final SystemSettingService systemSettingService;
+
+ @org.springframework.beans.factory.annotation.Value("${app.ai.enabled:false}")
+ private boolean aiEnabled;
+
+ /**
+ * Checks if a user has remaining AI credits for today.
+ * @param user The user to check.
+ * @return true if credits are available, false otherwise.
+ */
+ public boolean hasRemainingCredits(User user) {
+ if (!isAiGlobalEnabled()) {
+ return false;
+ }
+
+ if (user.getRole() == Role.ROLE_ADMIN) {
+ return true;
+ }
+
+ int limit = getUserDailyLimit(user);
+ if (limit == -1) {
+ return true;
+ }
+
+ int usage = getUsageToday(user);
+ int extra = getExtraCreditsToday(user);
+ return usage < (limit + extra);
+ }
+
+ /**
+ * Increments the AI call count for the user and feature for today.
+ * @param user The user.
+ * @param feature The feature name.
+ */
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void incrementUsage(User user, String feature) {
+ LocalDate today = LocalDate.now();
+
+ // Re-load user to ensure it's attached to this new transaction
+ User attachedUser = userRepository.findById(user.getId())
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + user.getId()));
+
+ // Update user usage
+ UserAiUsage usage = usageRepository.findByUserAndDate(attachedUser, today)
+ .orElse(new UserAiUsage(attachedUser, today, 0));
+ usage.setAiCalls(usage.getAiCalls() + 1);
+ usageRepository.save(usage);
+
+ // Update feature usage
+ AiFeatureUsage featureUsage = featureUsageRepository.findByDateAndFeatureName(today, feature)
+ .orElse(new AiFeatureUsage(today, feature, 0));
+ featureUsage.setAiCalls(featureUsage.getAiCalls() + 1);
+ featureUsageRepository.save(featureUsage);
+ }
+
+ /**
+ * Gets the daily limit for a user.
+ * @param user The user.
+ * @return The limit (-1 for unlimited).
+ */
+ public int getUserDailyLimit(User user) {
+ if (user.getRole() == Role.ROLE_ADMIN) {
+ return -1;
+ }
+
+ if (user.getAiDailyLimit() != null) {
+ return user.getAiDailyLimit();
+ }
+
+ String email = user.getEmail();
+ if (email != null && email.contains("@")) {
+ String suffix = email.substring(email.lastIndexOf("@") + 1);
+ Optional rule = suffixRuleRepository.findBySuffix(suffix);
+ if (rule.isPresent()) {
+ return rule.get().getDailyLimit();
+ }
+ }
+
+ return systemSettingService.getAiDefaultDailyLimit();
+ }
+
+ /**
+ * Gets the number of AI calls made by the user today.
+ * @param user The user.
+ * @return Number of calls.
+ */
+ public int getUsageToday(User user) {
+ return usageRepository.findByUserAndDate(user, LocalDate.now())
+ .map(UserAiUsage::getAiCalls)
+ .orElse(0);
+ }
+
+ /**
+ * Gets usage summary for dashboard.
+ */
+ public List getUsageSince(LocalDate date) {
+ return usageRepository.findByDateAfter(date);
+ }
+
+ public long getTotalCallsToday() {
+ return usageRepository.findByDateAfter(LocalDate.now().minusDays(1))
+ .stream()
+ .filter(u -> u.getDate().equals(LocalDate.now()))
+ .mapToLong(UserAiUsage::getAiCalls)
+ .sum();
+ }
+
+ public List getFeatureUsageToday() {
+ return featureUsageRepository.findByDate(LocalDate.now());
+ }
+
+ public List getDailyTrend(int days) {
+ return featureUsageRepository.findByDateAfterOrderByDateAsc(LocalDate.now().minusDays(days));
+ }
+
+ public List getUserUsageToday() {
+ return usageRepository.findByDateAfter(LocalDate.now().minusDays(1))
+ .stream()
+ .filter(u -> u.getDate().equals(LocalDate.now()))
+ .toList();
+ }
+
+ public UserAiUsageDto getUserUsageSummary(User user) {
+ int usageToday = getUsageToday(user);
+ int dailyLimit = getUserDailyLimit(user);
+ int extraCredits = getExtraCreditsToday(user);
+ List trend = usageRepository.findByUserAndDateAfterOrderByDateAsc(user, LocalDate.now().minusDays(30))
+ .stream()
+ .map(u -> new UserAiUsageDto.DailyTrendDto(u.getDate(), u.getAiCalls()))
+ .toList();
+
+ return new UserAiUsageDto(usageToday, dailyLimit + extraCredits, trend);
+ }
+
+ /**
+ * Adds extra credits for the current day.
+ */
+ @Transactional
+ public void addExtraCredits(User user, int amount) {
+ LocalDate today = LocalDate.now();
+ UserAiUsage usage = usageRepository.findByUserAndDate(user, today)
+ .orElse(new UserAiUsage(user, today, 0, 0));
+ usage.setExtraCredits(usage.getExtraCredits() + amount);
+ usageRepository.save(usage);
+ log.info("Added {} extra credits for user {} today", amount, user.getLogin());
+ }
+
+ /**
+ * Gets extra credits for the current day.
+ */
+ public int getExtraCreditsToday(User user) {
+ return usageRepository.findByUserAndDate(user, LocalDate.now())
+ .map(UserAiUsage::getExtraCredits)
+ .orElse(0);
+ }
+
+ public boolean isAiGlobalEnabled() {
+ return aiEnabled && systemSettingService.isAiGlobalEnabled();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/AsyncConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/AsyncConfig.java
new file mode 100644
index 000000000..c539c0dc8
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/AsyncConfig.java
@@ -0,0 +1,28 @@
+package ch.goodone.angularai.backend.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+
+@Configuration
+@EnableAsync
+public class AsyncConfig {
+
+ @Bean(name = "applicationTaskExecutor")
+ public Executor applicationTaskExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(10);
+ executor.setQueueCapacity(500);
+ executor.setThreadNamePrefix("ai-async-");
+ // Ensure threads are daemon so they don't block JVM exit in tests
+ executor.setThreadPriority(Thread.NORM_PRIORITY);
+ executor.setDaemon(true);
+ executor.setWaitForTasksToCompleteOnShutdown(false);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/ConfigValidator.java b/backend/src/main/java/ch/goodone/angularai/backend/config/ConfigValidator.java
new file mode 100644
index 000000000..344e2ecab
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/ConfigValidator.java
@@ -0,0 +1,141 @@
+package ch.goodone.angularai.backend.config;
+
+import jakarta.annotation.PostConstruct;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.env.Environment;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+@Configuration
+public class ConfigValidator {
+
+ private static final Logger logger = LoggerFactory.getLogger(ConfigValidator.class);
+
+ private final Environment env;
+
+ @Value("${jwt.secret:}")
+ private String jwtSecret;
+
+ @Value("${google.recaptcha.default.config:1}")
+ private String recaptchaDefaultConfig;
+
+ @Value("${spring.datasource.url:}")
+ private String dbUrl;
+
+ @Value("${app.config.validation.enabled:true}")
+ private boolean validationEnabled;
+
+ public ConfigValidator(Environment env) {
+ this.env = env;
+ }
+
+ @PostConstruct
+ public void validate() {
+ if (!validationEnabled) {
+ logger.info("Configuration validation disabled by app.config.validation.enabled property.");
+ return;
+ }
+
+ List activeProfiles = Arrays.asList(env.getActiveProfiles());
+ logger.info("Validating configuration for active profiles: {}", activeProfiles);
+
+ List errors = new ArrayList<>();
+
+ // 1. JWT Secret Validation
+ validateJwtSecret(errors);
+
+ // 2. Profile specific validation
+ if (activeProfiles.contains("prod") || activeProfiles.contains("demo")) {
+ validateCriticalCredentials(errors);
+ validateDatabase(errors);
+ }
+
+ // 3. reCAPTCHA Validation
+ if (!isTestOrDevProfile(activeProfiles)) {
+ validateRecaptcha(errors);
+ }
+
+ if (!errors.isEmpty()) {
+ String errorMessage = "Configuration Drift Detected! Missing or invalid required configuration:\n- " +
+ String.join("\n- ", errors);
+ logger.error(errorMessage);
+ throw new IllegalStateException(errorMessage);
+ }
+
+ logger.info("Configuration validation successful.");
+ }
+
+ private boolean isTestOrDevProfile(List activeProfiles) {
+ return activeProfiles.contains("test") ||
+ activeProfiles.contains("dev") ||
+ activeProfiles.contains("local") ||
+ activeProfiles.contains("h2-mem") ||
+ activeProfiles.contains("h2") ||
+ activeProfiles.contains("h2-file") ||
+ activeProfiles.contains("default") ||
+ activeProfiles.contains("postgres") ||
+ activeProfiles.contains("prometheus");
+ }
+
+ private void validateJwtSecret(List errors) {
+ if (jwtSecret == null || jwtSecret.isEmpty()) {
+ errors.add("jwt.secret (JWT_SECRET) is missing.");
+ } else if (jwtSecret.equals("defaultSecretKeyWithAtLeast32CharactersLongForSecurity")) {
+ List activeProfiles = Arrays.asList(env.getActiveProfiles());
+ if (activeProfiles.contains("prod") || activeProfiles.contains("demo")) {
+ errors.add("jwt.secret (JWT_SECRET) is using the default insecure value in " + activeProfiles + " profile.");
+ }
+ } else if (jwtSecret.length() < 32) {
+ // Treat h2-mem as a test environment where we allow shorter secrets if needed,
+ // but normally it should still be long enough.
+ // For now, let's keep it strict unless it's explicitly 'test' or 'dev'
+ List activeProfiles = Arrays.asList(env.getActiveProfiles());
+ if (!isTestOrDevProfile(activeProfiles)) {
+ errors.add("jwt.secret (JWT_SECRET) must be at least 32 characters long (provided: " + jwtSecret.length() + ").");
+ }
+ }
+ }
+
+ private void validateCriticalCredentials(List errors) {
+ checkProperty(errors, "admin.secret", "ADMIN_PASSWORD");
+ checkProperty(errors, "user.secret", "USER_PASSWORD");
+ checkProperty(errors, "admin.email", "ADMIN_EMAIL");
+
+ // In prod/demo, we should not have 'admin123'
+ validateNotDefault(errors, "admin.secret", "admin123");
+ validateNotDefault(errors, "user.secret", "user123");
+ validateNotDefault(errors, "admin.email", "admin@goodone.ch");
+ }
+
+ private void validateDatabase(List errors) {
+ if (dbUrl == null || dbUrl.isEmpty() || dbUrl.contains("h2:mem")) {
+ errors.add("spring.datasource.url (SPRING_DATASOURCE_URL) is missing or using H2 in a production-like profile.");
+ }
+ }
+
+ private void validateRecaptcha(List errors) {
+ String configIndex = (recaptchaDefaultConfig == null || recaptchaDefaultConfig.isEmpty()) ? "1" : recaptchaDefaultConfig;
+ String prefix = "google.recaptcha." + configIndex + ".";
+ checkProperty(errors, prefix + "site.key", "RECAPTCHA_" + configIndex + "_SITE_KEY");
+ checkProperty(errors, prefix + "secret.key", "RECAPTCHA_" + configIndex + "_SECRET_KEY");
+ }
+
+ private void checkProperty(List errors, String property, String envVar) {
+ String value = env.getProperty(property);
+ if (value == null || value.isEmpty() || value.equals("dummy") || value.equals("disabled")) {
+ errors.add(property + " (" + envVar + ") is missing or set to '" + value + "'.");
+ }
+ }
+
+ private void validateNotDefault(List errors, String property, String defaultValue) {
+ String value = env.getProperty(property);
+ if (defaultValue.equals(value)) {
+ errors.add(property + " is using the default value '" + defaultValue + "' in a restricted profile.");
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/DemoStartupTask.java b/backend/src/main/java/ch/goodone/angularai/backend/config/DemoStartupTask.java
new file mode 100644
index 000000000..55b63e51b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/DemoStartupTask.java
@@ -0,0 +1,57 @@
+package ch.goodone.angularai.backend.config;
+
+import ch.goodone.angularai.backend.service.SystemSettingService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+@Component
+@Profile("demo")
+public class DemoStartupTask implements CommandLineRunner {
+ private static final Logger logger = LoggerFactory.getLogger(DemoStartupTask.class);
+ private final SystemSettingService systemSettingService;
+
+ public DemoStartupTask(SystemSettingService systemSettingService) {
+ this.systemSettingService = systemSettingService;
+ }
+
+ @Override
+ public void run(String... args) {
+ try {
+ int currentIndex = systemSettingService.getRecaptchaConfigIndex();
+ logger.info("Demo environment startup. Current reCAPTCHA index is: {}", currentIndex);
+
+ // Force Config 4 (Enterprise Score) for Fargate demo environment
+ if (currentIndex != 4) {
+ logger.warn("Overriding reCAPTCHA config index to 4 (Enterprise Score) for Demo environment.");
+ systemSettingService.setRecaptchaConfigIndex(4);
+
+ // Double check
+ int newIndex = systemSettingService.getRecaptchaConfigIndex();
+ logger.info("New reCAPTCHA index after override: {}", newIndex);
+ }
+
+ // Log active credentials (obfuscated) for debugging
+ logObfuscatedSecret("RECAPTCHA_4_SITE_KEY", System.getenv("RECAPTCHA_4_SITE_KEY"));
+ logObfuscatedSecret("RECAPTCHA_4_API_KEY", System.getenv("RECAPTCHA_4_API_KEY"));
+ logObfuscatedSecret("RECAPTCHA_4_PROJECT_ID", System.getenv("RECAPTCHA_4_PROJECT_ID"));
+ } catch (Exception e) {
+ logger.error("Failed to override reCAPTCHA config index in Demo environment", e);
+ }
+ }
+
+ private void logObfuscatedSecret(String name, String value) {
+ if (!logger.isInfoEnabled()) {
+ return;
+ }
+ if (value == null || value.isBlank()) {
+ logger.info("{}: NOT SET", name);
+ } else if (value.length() <= 8) {
+ logger.info("{}: SET (too short to obfuscate safely)", name);
+ } else {
+ logger.info("{}: {}...{}", name, value.substring(0, 4), value.substring(value.length() - 4));
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/DocStartupTask.java b/backend/src/main/java/ch/goodone/angularai/backend/config/DocStartupTask.java
new file mode 100644
index 000000000..a1c699336
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/DocStartupTask.java
@@ -0,0 +1,36 @@
+package ch.goodone.angularai.backend.config;
+
+import ch.goodone.angularai.backend.docs.ingest.DocIngestionService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+@Component
+@Profile("local")
+@RequiredArgsConstructor
+@Slf4j
+public class DocStartupTask implements CommandLineRunner {
+
+ private final DocIngestionService docIngestionService;
+
+ @Value("${app.docs.reindex-on-startup:true}")
+ private boolean reindexOnStartup;
+
+ @Override
+ public void run(String... args) {
+ if (!reindexOnStartup) {
+ log.info("Documentation reindexing on startup is disabled.");
+ return;
+ }
+
+ log.info("Starting documentation reindexing at startup (profile: local)");
+ try {
+ docIngestionService.reindex();
+ } catch (Exception e) {
+ log.error("Failed to reindex documentation at startup", e);
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/DotEnvApplicationInitializer.java b/backend/src/main/java/ch/goodone/angularai/backend/config/DotEnvApplicationInitializer.java
new file mode 100644
index 000000000..ff0fa533a
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/DotEnvApplicationInitializer.java
@@ -0,0 +1,86 @@
+package ch.goodone.angularai.backend.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.env.ConfigurableEnvironment;
+import org.springframework.core.env.MapPropertySource;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class DotEnvApplicationInitializer implements ApplicationContextInitializer {
+
+ private static final Logger logger = LoggerFactory.getLogger(DotEnvApplicationInitializer.class);
+
+ @Override
+ public void initialize(ConfigurableApplicationContext applicationContext) {
+ ConfigurableEnvironment environment = applicationContext.getEnvironment();
+ Map dotEnvProperties = new HashMap<>();
+
+ Path path = findDotEnvPath();
+
+ if (Files.exists(path)) {
+ try {
+ loadDotEnv(path, dotEnvProperties);
+
+ if (!dotEnvProperties.isEmpty()) {
+ environment.getPropertySources().addFirst(new MapPropertySource("dotEnvProperties", dotEnvProperties));
+ }
+ } catch (IOException e) {
+ logger.error("Failed to load .env file: {}", e.getMessage());
+ }
+ }
+ }
+
+ protected Path findDotEnvPath() {
+ Path path = Paths.get(".env");
+ if (!Files.exists(path)) {
+ // Try to find it in the root directory if we are running from backend directory
+ path = Paths.get("..", ".env");
+ }
+ return path;
+ }
+
+ private void loadDotEnv(Path path, Map dotEnvProperties) throws IOException {
+ List lines = Files.readAllLines(path);
+ for (String line : lines) {
+ parseLine(line, dotEnvProperties);
+ }
+ }
+
+ private void parseLine(String line, Map dotEnvProperties) {
+ String trimmedLine = line.trim();
+ if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
+ return;
+ }
+ String[] parts = trimmedLine.split("=", 2);
+ if (parts.length == 2) {
+ String key = parts[0].trim();
+ String value = stripQuotes(parts[1].trim());
+
+ // Always add to properties for Spring environment
+ dotEnvProperties.put(key, value);
+
+ // Set as system property if not already set, for non-Spring compatibility
+ if (System.getProperty(key) == null && System.getenv(key) == null) {
+ System.setProperty(key, value);
+ }
+ }
+ }
+
+ String stripQuotes(String value) {
+ if (value.length() >= 2 &&
+ ((value.startsWith("\"") && value.endsWith("\"")) ||
+ (value.startsWith("'") && value.endsWith("'")))) {
+ return value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/FlywayConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/FlywayConfig.java
new file mode 100644
index 000000000..2fcfc55b3
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/FlywayConfig.java
@@ -0,0 +1,159 @@
+package ch.goodone.angularai.backend.config;
+
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.configuration.FluentConfiguration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import javax.sql.DataSource;
+import jakarta.persistence.EntityManagerFactory;
+import java.util.Arrays;
+
+@Configuration
+@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = "spring.flyway.enabled", matchIfMissing = true)
+public class FlywayConfig {
+
+ private static final Logger logger = LoggerFactory.getLogger(FlywayConfig.class);
+
+ private static final String VENDOR_POSTGRESQL = "postgresql";
+ private static final String VENDOR_H2 = "h2";
+ private static final String VENDOR_MYSQL = "mysql";
+ private static final String VENDOR_MARIADB = "mariadb";
+ private static final String VENDOR_ORACLE = "oracle";
+ private static final String VENDOR_SQLSERVER = "sqlserver";
+ private static final String VENDOR_GENERIC = "generic";
+
+
+ @Value("${spring.flyway.baseline-on-migrate:false}")
+ private boolean baselineOnMigrate;
+
+ @Value("${spring.flyway.repair-on-migrate:false}")
+ private boolean repairOnMigrate;
+
+ @Value("${spring.flyway.out-of-order:false}")
+ private boolean outOfOrder;
+
+ @Value("${spring.flyway.clean-disabled:true}")
+ private boolean cleanDisabled;
+
+ @Value("${spring.flyway.locations:classpath:db/migration/{vendor}}")
+ private String locations;
+
+ @Value("${spring.flyway.schemas:}")
+ private String schemas;
+
+ @Value("${spring.flyway.create-schemas:false}")
+ private boolean createSchemas;
+
+ @Value("${spring.datasource.url:}")
+ private String dataSourceUrl;
+
+ @Bean
+ public static BeanFactoryPostProcessor flywayEntityManagerFactoryDependencyPostProcessor() {
+ return beanFactory -> {
+ String[] entityManagerFactoryBeanNames = beanFactory.getBeanNamesForType(EntityManagerFactory.class);
+ for (String beanName : entityManagerFactoryBeanNames) {
+ beanFactory.getBeanDefinition(beanName).setDependsOn("flyway");
+ }
+ };
+ }
+
+ @Bean
+ @org.springframework.context.annotation.Primary
+ public Flyway flyway(DataSource dataSource) {
+ // Resolve {vendor} if present in locations
+ String resolvedLocations = locations;
+ if (locations.contains("{vendor}")) {
+ String vendor = getDatabaseVendor(dataSource);
+ resolvedLocations = locations.replace("{vendor}", vendor);
+ }
+
+ // Build Flyway configuration
+ FluentConfiguration cfg = Flyway.configure()
+ .dataSource(dataSource)
+ .baselineOnMigrate(baselineOnMigrate)
+ .outOfOrder(outOfOrder)
+ .locations(resolvedLocations.split(","))
+ .cleanDisabled(cleanDisabled);
+
+ if (schemas != null && !schemas.isBlank()) {
+ String[] schemaArr = Arrays.stream(schemas.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .toArray(String[]::new);
+ if (schemaArr.length > 0) {
+ cfg.schemas(schemaArr)
+ .defaultSchema(schemaArr[0])
+ .createSchemas(createSchemas);
+ }
+ }
+
+ Flyway flyway = cfg.load();
+
+ if (repairOnMigrate) {
+ flyway.repair();
+ }
+
+ flyway.migrate();
+
+ return flyway;
+ }
+
+ String getDatabaseVendor(DataSource dataSource) {
+ // 1. Try to determine from injected URL (property or env var)
+ String vendorFromUrl = tryDetermineVendorFromUrl(dataSource);
+ if (vendorFromUrl != null) {
+ return vendorFromUrl;
+ }
+
+ // 2. Fallback to connection
+ return determineVendorFromConnection(dataSource);
+ }
+
+ private String tryDetermineVendorFromUrl(DataSource dataSource) {
+ if (dataSourceUrl != null && !dataSourceUrl.isBlank()) {
+ String vendor = parseVendorFromUrl(dataSourceUrl);
+ if (vendor != null) {
+ return vendor;
+ }
+ }
+
+ if (dataSource instanceof com.zaxxer.hikari.HikariDataSource hikari) {
+ String url = hikari.getJdbcUrl();
+ if (url != null) {
+ return parseVendorFromUrl(url);
+ }
+ }
+ return null;
+ }
+
+ private String determineVendorFromConnection(DataSource dataSource) {
+ try (java.sql.Connection connection = dataSource.getConnection()) {
+ String productName = connection.getMetaData().getDatabaseProductName().toLowerCase();
+ if (productName.contains(VENDOR_POSTGRESQL)) return VENDOR_POSTGRESQL;
+ if (productName.contains(VENDOR_H2)) return VENDOR_H2;
+ if (productName.contains(VENDOR_MYSQL)) return VENDOR_MYSQL;
+ if (productName.contains(VENDOR_ORACLE)) return VENDOR_ORACLE;
+ if (productName.contains("sql server")) return VENDOR_SQLSERVER;
+ return VENDOR_GENERIC;
+ } catch (java.sql.SQLException e) {
+ logger.warn("Could not determine database vendor from connection. Falling back to 'generic'. Error: {}", e.getMessage());
+ return VENDOR_GENERIC;
+ }
+ }
+
+ private String parseVendorFromUrl(String url) {
+ String lowerUrl = url.toLowerCase();
+ if (lowerUrl.contains(":" + VENDOR_POSTGRESQL)) return VENDOR_POSTGRESQL;
+ if (lowerUrl.contains(":" + VENDOR_H2)) return VENDOR_H2;
+ if (lowerUrl.contains(":" + VENDOR_MYSQL)) return VENDOR_MYSQL;
+ if (lowerUrl.contains(":" + VENDOR_MARIADB)) return VENDOR_MARIADB;
+ if (lowerUrl.contains(":" + VENDOR_ORACLE)) return VENDOR_ORACLE;
+ if (lowerUrl.contains(":" + VENDOR_SQLSERVER)) return VENDOR_SQLSERVER;
+ return null;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/JwtAuthenticationFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/config/JwtAuthenticationFilter.java
new file mode 100644
index 000000000..b841f1da1
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/JwtAuthenticationFilter.java
@@ -0,0 +1,69 @@
+package ch.goodone.angularai.backend.config;
+
+import ch.goodone.angularai.backend.service.JwtService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import ch.goodone.angularai.backend.security.CustomUserDetails;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.Map;
+import tools.jackson.databind.ObjectMapper;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtService jwtService;
+ private final UserDetailsService userDetailsService;
+
+ public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
+ this.jwtService = jwtService;
+ this.userDetailsService = userDetailsService;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+ final String authHeader = request.getHeader("Authorization");
+ final String jwt;
+ final String userLogin;
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+ jwt = authHeader.substring(7);
+ userLogin = jwtService.extractUsername(jwt);
+ if (userLogin != null && SecurityContextHolder.getContext().getAuthentication() == null) {
+ UserDetails userDetails = this.userDetailsService.loadUserByUsername(userLogin);
+ if (jwtService.isTokenValid(jwt, userDetails)) {
+ if (userDetails instanceof CustomUserDetails customUserDetails && !customUserDetails.isActive()) {
+ response.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ response.setContentType("application/json");
+ new ObjectMapper().writeValue(response.getWriter(), Map.of("error", "account_not_active"));
+ return;
+ }
+ UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
+ userDetails,
+ null,
+ userDetails.getAuthorities()
+ );
+ authToken.setDetails(
+ new WebAuthenticationDetailsSource().buildDetails(request)
+ );
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+ }
+ }
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/MailConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/MailConfig.java
new file mode 100644
index 000000000..5c32a7646
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/MailConfig.java
@@ -0,0 +1,52 @@
+package ch.goodone.angularai.backend.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.mail.javamail.JavaMailSenderImpl;
+
+import java.util.Properties;
+
+@Configuration
+public class MailConfig {
+
+ @Value("${spring.mail.host:localhost}")
+ private String host;
+
+ @Value("${spring.mail.port:25}")
+ private int port;
+
+ @Value("${spring.mail.username:}")
+ private String username;
+
+ @Value("${spring.mail.password:}")
+ private String password;
+
+ @Value("${spring.mail.properties.mail.smtp.auth:false}")
+ private String auth;
+
+ @Value("${spring.mail.properties.mail.smtp.starttls.enable:false}")
+ private String starttls;
+
+ @Value("${spring.mail.properties.mail.smtp.ssl.trust:*}")
+ private String sslTrust;
+
+ @Bean
+ public JavaMailSender javaMailSender() {
+ JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+ mailSender.setHost(host);
+ mailSender.setPort(port);
+ mailSender.setUsername(username);
+ mailSender.setPassword(password);
+
+ Properties props = mailSender.getJavaMailProperties();
+ props.put("mail.transport.protocol", "smtp");
+ props.put("mail.smtp.auth", auth);
+ props.put("mail.smtp.starttls.enable", starttls);
+ props.put("mail.debug", "true");
+ props.put("mail.smtp.ssl.trust", sslTrust);
+
+ return mailSender;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/MdcFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/config/MdcFilter.java
new file mode 100644
index 000000000..d77ed578a
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/MdcFilter.java
@@ -0,0 +1,99 @@
+package ch.goodone.angularai.backend.config;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import java.io.IOException;
+import java.util.UUID;
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class MdcFilter implements Filter {
+
+ private static final Logger logger = LoggerFactory.getLogger(MdcFilter.class);
+ private static final String TRACE_ID = "traceId";
+ private static final String TRACE_ID_HEADER = "X-Trace-Id";
+ private static final String SESSION_ID = "sessionId";
+ private static final String USER_LOGIN = "userLogin";
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ if (!(request instanceof HttpServletRequest httpRequest)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ String traceIdHeader = httpRequest.getHeader(TRACE_ID_HEADER);
+ String traceId;
+
+ if (traceIdHeader != null && !traceIdHeader.isBlank()) {
+ traceId = traceIdHeader;
+ } else {
+ traceId = UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ MDC.put(TRACE_ID, sanitizeLog(traceId));
+ httpResponse.addHeader(TRACE_ID_HEADER, sanitizeLog(traceId));
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("MdcFilter: Processing request {} {}", sanitizeLog(httpRequest.getMethod()), sanitizeLog(httpRequest.getRequestURI()));
+ }
+
+ try {
+ populateMdc(httpRequest);
+ chain.doFilter(request, response);
+ if (logger.isDebugEnabled()) {
+ logger.debug("MdcFilter: Completed request {} {}", sanitizeLog(httpRequest.getMethod()), sanitizeLog(httpRequest.getRequestURI()));
+ }
+ } finally {
+ MDC.clear();
+ }
+ }
+
+ private void populateMdc(HttpServletRequest request) {
+ // Safety check for session access
+ try {
+ HttpSession session = request.getSession(false);
+ if (session != null) {
+ MDC.put(SESSION_ID, sanitizeLog(session.getId()));
+ // Peek into session for Spring Security context to capture user early
+ Object scObj = session.getAttribute("SPRING_SECURITY_CONTEXT");
+ if (scObj instanceof SecurityContext sc) {
+ Authentication auth = sc.getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
+ MDC.put(USER_LOGIN, sanitizeLog(auth.getName()));
+ }
+ }
+ }
+ } catch (Exception e) {
+ logger.warn("MdcFilter: Unexpected error while accessing session: {}", sanitizeLog(e.getMessage()));
+ }
+
+ try {
+ if (request.getUserPrincipal() != null) {
+ String name = request.getUserPrincipal().getName();
+ MDC.put(USER_LOGIN, sanitizeLog(name));
+ }
+ } catch (Exception e) {
+ logger.warn("MdcFilter: Unexpected error while accessing user principal: {}", sanitizeLog(e.getMessage()));
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/MonitoringConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/MonitoringConfig.java
new file mode 100644
index 000000000..7e9a9212e
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/MonitoringConfig.java
@@ -0,0 +1,15 @@
+package ch.goodone.angularai.backend.config;
+
+import org.springframework.boot.actuate.web.exchanges.HttpExchangeRepository;
+import org.springframework.boot.actuate.web.exchanges.InMemoryHttpExchangeRepository;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MonitoringConfig {
+
+ @Bean
+ public HttpExchangeRepository httpExchangeRepository() {
+ return new InMemoryHttpExchangeRepository();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/OpenApiConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/OpenApiConfig.java
new file mode 100644
index 000000000..d269bb58a
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/OpenApiConfig.java
@@ -0,0 +1,36 @@
+package ch.goodone.angularai.backend.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+ private static final String SCHEME_BASIC = "basicAuth";
+ private static final String SCHEME_BEARER = "bearerAuth";
+
+ @Bean
+ public OpenAPI customOpenAPI() {
+ return new OpenAPI()
+ .info(new Info()
+ .title("AngularAI API")
+ .version("1.0")
+ .description("API documentation for the AngularAI project"))
+ .addSecurityItem(new SecurityRequirement().addList(SCHEME_BASIC))
+ .addSecurityItem(new SecurityRequirement().addList(SCHEME_BEARER))
+ .components(new Components()
+ .addSecuritySchemes(SCHEME_BASIC, new SecurityScheme()
+ .name(SCHEME_BASIC)
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("basic"))
+ .addSecuritySchemes(SCHEME_BEARER, new SecurityScheme()
+ .name(SCHEME_BEARER)
+ .type(SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")));
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/RateLimitingFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/config/RateLimitingFilter.java
new file mode 100644
index 000000000..d720d42f8
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/RateLimitingFilter.java
@@ -0,0 +1,137 @@
+package ch.goodone.angularai.backend.config;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import io.github.bucket4j.Bandwidth;
+import io.github.bucket4j.Bucket;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = "app.rate-limiting.enabled", havingValue = "true")
+public class RateLimitingFilter implements Filter {
+
+ private static final Logger logger = LoggerFactory.getLogger(RateLimitingFilter.class);
+ private final Map buckets = new ConcurrentHashMap<>();
+
+ @Value("${app.rate-limiting.capacity:10}")
+ private int capacity;
+
+ @Value("${app.rate-limiting.refill-tokens:10}")
+ private int refillTokens;
+
+ @Value("${app.rate-limiting.refill-duration-minutes:1}")
+ private int refillDurationMinutes;
+
+ @Value("${spring.servlet.multipart.max-request-size:1MB}")
+ private String maxRequestSize;
+
+ private long parseSize(String size) {
+ if (size == null || size.isBlank()) {
+ return 1024L * 1024L; // Default 1MB
+ }
+ String normalizedSize = size.toUpperCase().trim();
+ if (normalizedSize.endsWith("MB")) {
+ return Long.parseLong(normalizedSize.substring(0, normalizedSize.length() - 2)) * 1024L * 1024L;
+ }
+ if (normalizedSize.endsWith("KB")) {
+ return Long.parseLong(normalizedSize.substring(0, normalizedSize.length() - 2)) * 1024L;
+ }
+ if (normalizedSize.endsWith("B")) {
+ return Long.parseLong(normalizedSize.substring(0, normalizedSize.length() - 1));
+ }
+ try {
+ return Long.parseLong(normalizedSize);
+ } catch (NumberFormatException e) {
+ return 1024L * 1024L;
+ }
+ }
+
+ private Bucket createNewBucket() {
+ Bandwidth limit = Bandwidth.builder()
+ .capacity(capacity)
+ .refillGreedy(refillTokens, Duration.ofMinutes(refillDurationMinutes))
+ .build();
+ return Bucket.builder()
+ .addLimit(limit)
+ .build();
+ }
+
+ @Override
+ @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ String path = httpRequest.getRequestURI();
+
+ // 0. Payload size check
+ long contentLength = httpRequest.getContentLengthLong();
+ long maxSize = parseSize(maxRequestSize);
+ if (contentLength > maxSize) {
+ logger.warn("Payload too large: {} bytes (max: {} bytes)", contentLength, maxSize);
+ httpResponse.setStatus(HttpStatus.CONTENT_TOO_LARGE.value());
+ httpResponse.getWriter().write("Payload too large");
+ return;
+ }
+
+ // 1. Get Client IP (handling X-Forwarded-For)
+ String clientIp = httpRequest.getRemoteAddr();
+ String xforwardedFor = httpRequest.getHeader("X-Forwarded-For");
+ boolean isForwarded = false;
+ if (xforwardedFor != null && !xforwardedFor.isEmpty()) {
+ clientIp = xforwardedFor.split(",")[0].trim();
+ isForwarded = true;
+ }
+
+ String sanitizedPath = sanitizeLog(path);
+ String sanitizedIp = sanitizeLog(clientIp);
+
+ // 2. Bypass for local testing/E2E ONLY IF NOT FORWARDED
+ // If it's forwarded, it's likely a test or a real proxy request that we WANT to limit
+ if (!isForwarded && ("127.0.0.1".equals(clientIp) || "0:0:0:0:0:0:0:1".equals(clientIp) || "localhost".equals(httpRequest.getServerName()))) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ // Rate limit sensitive endpoints
+ if (path.startsWith("/api/auth/login") ||
+ path.startsWith("/api/auth/register") ||
+ path.startsWith("/api/tasks/analyze") ||
+ path.startsWith("/api/system/csp-report") ||
+ path.startsWith("/api/ai/") ||
+ (path.startsWith("/api/admin/docs/") && !path.equals("/api/admin/docs/status")) ||
+ path.startsWith("/api/tasks/rate-limit-test")) {
+
+ logger.debug("RateLimitingFilter: Checking rate limit for path: {} from IP: {}", sanitizedPath, sanitizedIp);
+
+ Bucket bucket = buckets.computeIfAbsent(clientIp, k -> createNewBucket());
+
+ if (bucket.tryConsume(1)) {
+ logger.debug("RateLimitingFilter: Limit OK for path: {}", sanitizedPath);
+ chain.doFilter(request, response);
+ } else {
+ logger.warn("RateLimitingFilter: Rate limit exceeded for path: {} from IP: {}", sanitizedPath, sanitizedIp);
+ httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
+ httpResponse.getWriter().write("Too many requests");
+ }
+ } else {
+ chain.doFilter(request, response);
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/RequestLoggingFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/config/RequestLoggingFilter.java
new file mode 100644
index 000000000..38026e36f
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/RequestLoggingFilter.java
@@ -0,0 +1,123 @@
+package ch.goodone.angularai.backend.config;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.util.UUID;
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 1) // Run after MdcFilter
+public class RequestLoggingFilter implements Filter {
+
+ private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);
+
+ private static final String REQUEST_ID = "requestId";
+ private static final String METHOD = "method";
+ private static final String PATH = "path";
+ private static final String STATUS = "status";
+ private static final String LATENCY = "latency";
+
+ /**
+ * Threshold in milliseconds for logging successful requests.
+ * Requests faster than this will only be logged if they fail (status >= 400).
+ */
+ private static final long LOGGING_THRESHOLD_MS = 100;
+
+ /**
+ * List of endpoints that should not be logged for successful GET requests to reduce log noise.
+ */
+ private static final java.util.Set NOISY_ENDPOINTS = java.util.Set.of(
+ "/api/dashboard",
+ "/api/tasks",
+ "/api/auth/info",
+ "/api/admin/logs",
+ "/api/admin/settings/landing-message",
+ "/api/admin/settings/geolocation"
+ );
+
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ if (!(request instanceof HttpServletRequest httpRequest)) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ // Skip logging for frequent health-check/system-info calls to reduce log noise
+ if ("/api/system/info".equals(httpRequest.getRequestURI())) {
+ chain.doFilter(request, response);
+ return;
+ }
+
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ long startTime = System.nanoTime();
+
+ String requestId = UUID.randomUUID().toString();
+ MDC.put(REQUEST_ID, sanitizeLog(requestId));
+ MDC.put(METHOD, sanitizeLog(httpRequest.getMethod()));
+ MDC.put(PATH, sanitizeLog(httpRequest.getRequestURI()));
+
+ try {
+ chain.doFilter(request, response);
+ } finally {
+ long durationNs = System.nanoTime() - startTime;
+ long durationMs = durationNs / 1_000_000;
+ int status = httpResponse.getStatus();
+ MDC.put(STATUS, String.valueOf(status));
+ MDC.put(LATENCY, String.valueOf(durationMs));
+
+ boolean isNoisy = "GET".equalsIgnoreCase(httpRequest.getMethod()) && NOISY_ENDPOINTS.contains(httpRequest.getRequestURI());
+
+ if (logger.isInfoEnabled() && status < 400 && isNoisy) {
+ // Skip logging for noisy successful GET requests
+ } else if (logger.isInfoEnabled() && (durationMs >= LOGGING_THRESHOLD_MS || status >= 400)) {
+ String sanitizedMethod = sanitizeLog(httpRequest.getMethod());
+ String sanitizedPath = sanitizeLog(httpRequest.getRequestURI());
+
+ // Re-attempt to get user from MDC (populated by MdcFilter or MdcUserFilter)
+ String userLogin = MDC.get("userLogin");
+
+ // Final fallback if MDC is empty (only works if context not yet cleared)
+ if (userLogin == null || userLogin.isBlank() || "null".equals(userLogin)) {
+ if (httpRequest.getUserPrincipal() != null) {
+ userLogin = httpRequest.getUserPrincipal().getName();
+ } else {
+ userLogin = "anonymous";
+ }
+ }
+
+ String sanitizedUser = sanitizeLog(userLogin);
+ String sanitizedTraceId = sanitizeLog(MDC.get("traceId"));
+ String sanitizedRequestId = sanitizeLog(MDC.get(REQUEST_ID));
+ String sanitizedSessionId = sanitizeLog(MDC.get("sessionId"));
+
+ logger.info("[{}] [{}] [{}] [{}] Request processed: {} {} - Status: {} - Duration: {}ms",
+ sanitizedTraceId, sanitizedRequestId, sanitizedSessionId, sanitizedUser,
+ sanitizedMethod, sanitizedPath, status, durationMs);
+ }
+
+ // Note: We don't clear MDC here because MdcFilter will clear it.
+ // But we should remove the fields we added if we want to be clean.
+ MDC.remove(REQUEST_ID);
+ MDC.remove(METHOD);
+ MDC.remove(PATH);
+ MDC.remove(STATUS);
+ MDC.remove(LATENCY);
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/SecurityConfig.java b/backend/src/main/java/ch/goodone/angularai/backend/config/SecurityConfig.java
new file mode 100644
index 000000000..e49ed82ab
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/SecurityConfig.java
@@ -0,0 +1,311 @@
+package ch.goodone.angularai.backend.config;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import ch.goodone.angularai.backend.security.CustomUserDetails;
+import ch.goodone.angularai.backend.ai.config.AiEnabledFilter;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import java.util.Collections;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.web.SecurityFilterChain;
+import org.slf4j.MDC;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.web.filter.OncePerRequestFilter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Configuration
+@EnableWebSecurity
+@org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
+public class SecurityConfig {
+
+ private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(SecurityConfig.class);
+
+ private static class CsrfCookieFilter extends OncePerRequestFilter {
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
+ if (csrfToken != null) {
+ csrfToken.getToken();
+ }
+ filterChain.doFilter(request, response);
+ }
+ }
+
+ private static class MdcUserFilter extends OncePerRequestFilter {
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getName())) {
+ MDC.put("userLogin", sanitizeLog(auth.getName()));
+ }
+ filterChain.doFilter(request, response);
+ }
+ }
+
+ @Value("${app.security.jwt.enabled:false}")
+ private boolean jwtEnabled;
+
+ @jakarta.annotation.PostConstruct
+ public void logStatus() {
+ logger.info("Initializing SecurityConfig. JWT enabled: {}", jwtEnabled);
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthFilter, WafSimulatedFilter wafFilter, AiEnabledFilter aiEnabledFilter) {
+ // Simulated WAF should be the first line of defense
+ http.addFilterBefore(wafFilter, org.springframework.security.web.context.SecurityContextHolderFilter.class);
+
+ // AI Enabled Filter should check before most other filters for /api/ai endpoints
+ http.addFilterBefore(aiEnabledFilter, UsernamePasswordAuthenticationFilter.class);
+
+ if (jwtEnabled) {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .httpBasic(basic -> basic.authenticationEntryPoint(noPopupAuthenticationEntryPoint()));
+ } else {
+ CookieCsrfTokenRepository repository = CookieCsrfTokenRepository.withHttpOnlyFalse();
+ repository.setCookieName("XSRF-TOKEN");
+ repository.setHeaderName("X-XSRF-TOKEN");
+
+ http
+ .csrf(csrf -> csrf
+ .csrfTokenRepository(repository)
+ .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
+ .ignoringRequestMatchers("/api/system/csp-report", "/h2-console/**", "/api/auth/login", "/api/admin/docs/reindex", "/api/admin/docs/upload", "/api/admin/users/cleanup", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
+ )
+ .addFilterAfter(new CsrfCookieFilter(), UsernamePasswordAuthenticationFilter.class)
+ .addFilterAfter(new MdcUserFilter(), AnonymousAuthenticationFilter.class)
+ .securityContext(context -> context
+ .securityContextRepository(new org.springframework.security.web.context.HttpSessionSecurityContextRepository())
+ )
+ .sessionManagement(session -> session
+ .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
+ )
+ .httpBasic(basic -> basic.authenticationEntryPoint(noPopupAuthenticationEntryPoint()));
+ }
+
+ // Global filter to capture authentication failures
+ http.addFilterBefore(new OncePerRequestFilter() {
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ try {
+ filterChain.doFilter(request, response);
+ if (response.getStatus() == 401) {
+ String method = request.getMethod();
+ String uri = request.getRequestURI();
+ if (logger.isWarnEnabled()) {
+ logger.warn("[DEBUG_LOG] 401 Unauthorized for " + sanitizeLog(method) + " " + sanitizeLog(uri));
+ }
+ }
+ } catch (Exception e) {
+ String method = request.getMethod();
+ String uri = request.getRequestURI();
+ String msg = e.getMessage();
+ if (logger.isErrorEnabled()) {
+ logger.error("[DEBUG_LOG] Filter chain exception for " + sanitizeLog(method) + " " + sanitizeLog(uri) + ": " + sanitizeLog(msg));
+ }
+ throw e;
+ }
+ }
+ }, UsernamePasswordAuthenticationFilter.class);
+
+ http
+ .cors(Customizer.withDefaults())
+ .formLogin(AbstractHttpConfigurer::disable);
+
+ http.authorizeHttpRequests(auth -> auth
+ .requestMatchers("/h2-console/**").permitAll()
+ .requestMatchers("/api/auth/**", "/api/system/info", "/api/system/recaptcha-site-key", "/api/system/test/**", "/api/system/csp-report", "/api/contact", "/api/ai/architecture/explain").permitAll()
+ .requestMatchers("/actuator/**").permitAll()
+ .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/admin/settings/recaptcha", "/api/admin/settings/landing-message", "/api/admin/settings/geolocation").hasAnyAuthority(ROLE_ADMIN, ROLE_ADMIN_READ)
+ .requestMatchers(HttpMethod.GET, "/api/admin/ai/**", "/api/admin/ai-usage").hasAuthority(ROLE_ADMIN)
+ .requestMatchers("/api/admin/**").hasAuthority(ROLE_ADMIN)
+ .requestMatchers("/api/**").authenticated()
+ .anyRequest().permitAll()
+ )
+ .logout(logout -> logout
+ .logoutUrl("/api/auth/logout")
+ .logoutSuccessHandler((request, response, authentication) ->
+ response.setStatus(HttpServletResponse.SC_OK)
+ )
+ .invalidateHttpSession(true)
+ .deleteCookies("JSESSIONID")
+ )
+ .headers(headers -> headers
+ .frameOptions(org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig::sameOrigin)
+ .contentSecurityPolicy(csp -> csp
+ .policyDirectives("default-src 'self'; " +
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: " +
+ "https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ " +
+ "https://www.google.com https://www.recaptcha.net https://www.gstatic.com " +
+ "https://*.google.com https://*.gstatic.com https://*.recaptcha.net https://www.googletagmanager.com https://*.clarity.ms; " +
+ "script-src-elem 'self' 'unsafe-inline' " +
+ "https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ " +
+ "https://www.google.com https://www.recaptcha.net https://www.gstatic.com " +
+ "https://*.google.com https://*.gstatic.com https://*.recaptcha.net https://www.googletagmanager.com https://*.clarity.ms; " +
+ "style-src 'self' 'unsafe-inline' " +
+ "https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://www.recaptcha.net/recaptcha/ " +
+ "https://fonts.googleapis.com https://*.google.com https://*.gstatic.com https://*.recaptcha.net; " +
+ "font-src 'self' data: https://fonts.gstatic.com; " +
+ "img-src 'self' data: https: https://*.google.com https://*.gstatic.com https://*.recaptcha.net; " +
+ "connect-src 'self' https://www.google.com/recaptcha/ https://www.recaptcha.net/recaptcha/ https://*.google.com https://*.recaptcha.net https:; " +
+ "frame-src 'self' https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/ https://www.recaptcha.net/recaptcha/ " +
+ "https://*.google.com https://*.recaptcha.net; " +
+ "worker-src 'self' blob:; " +
+ "child-src 'self' blob:; " +
+ "report-uri /api/system/csp-report;")
+ )
+ .httpStrictTransportSecurity(hsts -> hsts
+ .includeSubDomains(true)
+ .maxAgeInSeconds(31536000)
+ )
+ );
+
+ if (jwtEnabled) {
+ http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+ http.addFilterAfter(new MdcUserFilter(), JwtAuthenticationFilter.class);
+ }
+
+ try {
+ return http.build();
+ } catch (Exception ex) {
+ throw new IllegalStateException("Failed to build SecurityFilterChain", ex);
+ }
+ }
+
+ @Bean
+ public AuthenticationEntryPoint noPopupAuthenticationEntryPoint() {
+ return (request, response, authException) -> {
+ if (response.isCommitted()) {
+ return;
+ }
+
+ String message = authException.getMessage();
+
+ // Return 401/403 based on the exception type
+ // DisabledException (thrown by Spring if user.isEnabled() is false) -> 403
+ boolean isStatusError = authException instanceof org.springframework.security.authentication.DisabledException ||
+ authException instanceof org.springframework.security.authentication.LockedException ||
+ authException instanceof org.springframework.security.authentication.AccountExpiredException ||
+ (message != null && (message.toLowerCase().contains("disabled") || message.toLowerCase().contains("active")));
+
+ response.setStatus(isStatusError ? HttpServletResponse.SC_FORBIDDEN : HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json");
+ response.setCharacterEncoding("UTF-8");
+
+ String jsonResponse = String.format("{\"error\": \"%s\", \"message\": \"%s\"}",
+ isStatusError ? "account_not_active" : "Unauthorized",
+ message != null ? message.replace("\"", "\\\"") : "");
+
+ response.getWriter().write(jsonResponse);
+ response.getWriter().flush();
+ };
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(AuthenticationConfiguration config) {
+ try {
+ return config.getAuthenticationManager();
+ } catch (Exception ex) {
+ throw new IllegalStateException("Failed to obtain AuthenticationManager", ex);
+ }
+ }
+
+ @Bean
+ public UserDetailsService userDetailsService(UserRepository userRepository) {
+ return username -> {
+ User user = userRepository.findByLogin(username)
+ .orElseThrow(() -> {
+ logger.warn("[DEBUG_LOG] User not found: {}", username);
+ return new UsernameNotFoundException("User not found");
+ });
+
+ String passHash = user.getPassword();
+ String passPrefix = (passHash != null && passHash.length() >= 8) ? passHash.substring(0, 8) : "too-short";
+ logger.info("[DEBUG_LOG] Loading user: {}, status: {}, role: {}, passPrefix: {}", user.getLogin(), user.getStatus(), user.getRole(), passPrefix);
+
+ // Map our UserStatus to Spring Security account status flags
+ boolean enabled = user.getStatus() == ch.goodone.angularai.backend.model.UserStatus.ACTIVE;
+
+ return new CustomUserDetails(
+ user.getLogin(),
+ user.getPassword(),
+ enabled, true, true, true,
+ Collections.singletonList(new SimpleGrantedAuthority(user.getRole() != null ? user.getRole().name() : ROLE_USER)),
+ user.getStatus()
+ );
+ };
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public RestTemplate restTemplate() {
+ return new RestTemplate();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ // Whitelist allowed origins instead of using broad patterns
+ configuration.setAllowedOrigins(List.of(
+ "http://localhost:4200",
+ "http://localhost:80",
+ "http://localhost",
+ "https://goodone.ch"
+ ));
+ // Strict patterns for development environments
+ configuration.setAllowedOriginPatterns(List.of(
+ "http://10.0.2.2:*", // Android Emulator
+ "http://127.0.0.1:*" // Localhost IP
+ ));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-XSRF-TOKEN"));
+ configuration.setAllowCredentials(true);
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/WafSimulatedFilter.java b/backend/src/main/java/ch/goodone/angularai/backend/config/WafSimulatedFilter.java
new file mode 100644
index 000000000..05731650d
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/WafSimulatedFilter.java
@@ -0,0 +1,158 @@
+package ch.goodone.angularai.backend.config;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * A simulated Web Application Firewall (WAF) filter that performs basic
+ * application-level request filtering to protect against common attacks.
+ * This is a low-cost alternative to a dedicated AWS WAF.
+ */
+@Component
+public class WafSimulatedFilter extends OncePerRequestFilter {
+
+ private static final Logger wafLogger = LoggerFactory.getLogger(WafSimulatedFilter.class);
+
+ // Common SQL Injection patterns
+ private static final List SQL_INJECTION_PATTERNS = List.of(
+ Pattern.compile("\\bUNION\\s++ALL\\s++SELECT\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bSELECT\\b.{1,100}?\\bFROM\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bINSERT\\s++INTO\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bUPDATE\\b.{1,100}?\\bSET\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bDELETE\\s++FROM\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bDROP\\s++TABLE\\b", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bOR\\s++['\"]?+\\d++['\"]?+\\s*+=\\s*+['\"]?+\\d++['\"]?+", Pattern.CASE_INSENSITIVE)
+ );
+
+ // Common XSS patterns
+ private static final List XSS_PATTERNS = List.of(
+ Pattern.compile("", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("\\bon\\w++\\s*+=", Pattern.CASE_INSENSITIVE),
+ Pattern.compile("alert\\(", Pattern.CASE_INSENSITIVE),
+ Pattern.compile(" ]*+>", Pattern.CASE_INSENSITIVE)
+ );
+
+ // Path traversal patterns
+ private static final Pattern PATH_TRAVERSAL_PATTERN = Pattern.compile(
+ "(?i)(\\.\\./|\\.\\.\\\\|/etc/passwd|/windows/system32|C:\\\\|/var/log)",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ // Suspicious User-Agents
+ private static final Pattern SUSPICIOUS_USER_AGENT_PATTERN = Pattern.compile(
+ "(?i)(sqlmap|nikto|dirbuster|gobuster|w3af|metasploit|burp\\s*suite|nmap|acunetix|zaproxy)",
+ Pattern.CASE_INSENSITIVE
+ );
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+
+ String path = request.getRequestURI();
+ String clientIp = getClientIp(request);
+
+ // 0. Bypass for local testing/E2E
+ if (isLocalRequest(request)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ // 1. Check User-Agent
+ String userAgent = request.getHeader("User-Agent");
+ if (userAgent != null && SUSPICIOUS_USER_AGENT_PATTERN.matcher(userAgent).find()) {
+ logBlockedRequest(path, clientIp, "Suspicious User-Agent", userAgent);
+ blockRequest(response);
+ return;
+ }
+
+ // 2. Check Query Parameters
+ String queryString = request.getQueryString();
+ if (isMalicious(queryString)) {
+ logBlockedRequest(path, clientIp, "Malicious Query String", queryString);
+ blockRequest(response);
+ return;
+ }
+
+ // 3. Check Headers
+ Enumeration headerNames = request.getHeaderNames();
+ while (headerNames.hasMoreElements()) {
+ String headerName = headerNames.nextElement();
+ String headerValue = request.getHeader(headerName);
+ if (isMalicious(headerValue)) {
+ logBlockedRequest(path, clientIp, "Malicious Header: " + headerName, headerValue);
+ blockRequest(response);
+ return;
+ }
+ }
+
+ // 4. Check Path
+ if (PATH_TRAVERSAL_PATTERN.matcher(path).find()) {
+ logBlockedRequest(path, clientIp, "Path Traversal Attempt", path);
+ blockRequest(response);
+ return;
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ private boolean isMalicious(String input) {
+ if (input == null || input.isEmpty()) {
+ return false;
+ }
+
+ for (Pattern pattern : SQL_INJECTION_PATTERNS) {
+ if (pattern.matcher(input).find()) {
+ return true;
+ }
+ }
+
+ for (Pattern pattern : XSS_PATTERNS) {
+ if (pattern.matcher(input).find()) {
+ return true;
+ }
+ }
+
+ return PATH_TRAVERSAL_PATTERN.matcher(input).find();
+ }
+
+ @SuppressWarnings("PMD.AvoidUsingHardCodedIP")
+ private boolean isLocalRequest(HttpServletRequest request) {
+ String remoteAddr = request.getRemoteAddr();
+ return "127.0.0.1".equals(remoteAddr) || "0:0:0:0:0:0:0:1".equals(remoteAddr) || "localhost".equals(request.getServerName());
+ }
+
+ private void logBlockedRequest(String path, String ip, String reason, String details) {
+ if (wafLogger.isWarnEnabled()) {
+ wafLogger.warn("[WAF_BLOCKED] Path: {}, IP: {}, Reason: {}, Details: {}",
+ sanitizeLog(path), sanitizeLog(ip), sanitizeLog(reason), sanitizeLog(details));
+ }
+ }
+
+ private void blockRequest(HttpServletResponse response) throws IOException {
+ response.setStatus(HttpStatus.FORBIDDEN.value());
+ response.setContentType("application/json");
+ response.getWriter().write("{\"error\": \"Request blocked by security policy\"}");
+ }
+
+ private String getClientIp(HttpServletRequest request) {
+ String xforwardedFor = request.getHeader("X-Forwarded-For");
+ if (xforwardedFor != null && !xforwardedFor.isEmpty()) {
+ return xforwardedFor.split(",")[0].trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/config/WebConfiguration.java b/backend/src/main/java/ch/goodone/angularai/backend/config/WebConfiguration.java
new file mode 100644
index 000000000..de804aae8
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/config/WebConfiguration.java
@@ -0,0 +1,55 @@
+package ch.goodone.angularai.backend.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+import org.springframework.data.web.config.EnableSpringDataWebSupport;
+import org.springframework.http.CacheControl;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.resource.PathResourceResolver;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;
+
+@Configuration
+@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
+public class WebConfiguration implements WebMvcConfigurer {
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ // Hashed assets and static resources - cache for 1 year, immutable
+ registry.addResourceHandler("/**/*.js", "/**/*.css", "/**/*.png", "/**/*.jpg", "/**/*.jpeg", "/**/*.gif", "/**/*.svg", "/**/*.ico", "/**/*.woff", "/**/*.woff2", "/**/*.ttf", "/**/*.otf", "/**/*.eot")
+ .addResourceLocations("classpath:/static/")
+ .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable());
+
+ // Default handler for everything else (including index.html) - no-cache to ensure SPA updates
+ registry.addResourceHandler("/**")
+ .addResourceLocations("classpath:/static/")
+ .setCacheControl(CacheControl.noCache())
+ .resourceChain(true)
+ .addResolver(new PathResourceResolver() {
+ @Override
+ protected Resource getResource(String resourcePath, Resource location) throws IOException {
+ Resource requestedResource = location.createRelative(resourcePath);
+
+ if (requestedResource.exists() && requestedResource.isReadable()) {
+ return requestedResource;
+ }
+
+ // Forward to index.html if the resource doesn't exist (SPA routing)
+ // but only if it's not an API request or Actuator request
+ String path = resourcePath.startsWith("/") ? resourcePath.substring(1) : resourcePath;
+ if (!path.startsWith("api") && !path.startsWith("actuator")) {
+ Resource index = location.createRelative("index.html");
+ if (index.exists() && index.isReadable()) {
+ return index;
+ }
+ }
+
+ return null;
+ }
+ });
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/ActionLogController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/ActionLogController.java
new file mode 100644
index 000000000..fb488f761
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/ActionLogController.java
@@ -0,0 +1,62 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.dto.ActionLogDTO;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+
+@RestController
+@RequestMapping("/api/admin/logs")
+@Tag(name = "Log Management", description = "Endpoints for administrators to view and manage system logs")
+@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+public class ActionLogController {
+
+ private final ActionLogService actionLogService;
+
+ public ActionLogController(ActionLogService actionLogService) {
+ this.actionLogService = actionLogService;
+ }
+
+ @GetMapping
+ public Page getLogs(
+ org.springframework.security.core.Authentication authentication,
+ Pageable pageable,
+ @RequestParam(required = false) String type,
+ @RequestParam(required = false) String search,
+ @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
+ @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate) {
+ if (authentication != null) {
+ actionLogService.log(authentication.getName(), "LOGS_VIEWED", "User viewed action logs with filter: " + search);
+ }
+ boolean isAdmin = authentication != null && authentication.getAuthorities().stream()
+ .anyMatch(a -> a.getAuthority().equals(ROLE_ADMIN));
+ return actionLogService.getLogs(pageable, type, startDate, endDate, search, isAdmin);
+ }
+
+ @DeleteMapping
+ public ResponseEntity clearLogs(org.springframework.security.core.Authentication authentication) {
+ if (authentication != null) {
+ actionLogService.log(authentication.getName(), "LOGS_CLEARED", "User cleared all action logs");
+ }
+ actionLogService.clearLogs();
+ return ResponseEntity.noContent().build();
+ }
+
+ @PostMapping
+ public ActionLogDTO createLog(@RequestBody ActionLogDTO logDTO) {
+ return actionLogService.createLog(logDTO);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDemoController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDemoController.java
new file mode 100644
index 000000000..aec486daa
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDemoController.java
@@ -0,0 +1,32 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.service.DemoResetService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/admin/demo")
+@Tag(name = "Admin Demo", description = "Endpoints for demo environment management")
+@PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+public class AdminDemoController {
+
+ private final DemoResetService demoResetService;
+
+ public AdminDemoController(DemoResetService demoResetService) {
+ this.demoResetService = demoResetService;
+ }
+
+ @PostMapping("/reset")
+ @Operation(summary = "Reset the demo database to a known baseline", description = "Cleans the database and re-seeds data. Admin only.")
+ public ResponseEntity resetDemo(Authentication authentication) {
+ demoResetService.resetDemoData(authentication.getName());
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDocsController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDocsController.java
new file mode 100644
index 000000000..58ab6b34b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminDocsController.java
@@ -0,0 +1,87 @@
+package ch.goodone.angularai.backend.controller;
+
+import ch.goodone.angularai.backend.docs.ingest.DocIngestionService;
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.docs.ingest.DocIndexingStatusService;
+import ch.goodone.angularai.backend.dto.DocIndexingStatusDTO;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/docs")
+@RequiredArgsConstructor
+@Tag(name = "Admin Documentation", description = "Endpoints for administrators to manage knowledge base documentation")
+@PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+public class AdminDocsController {
+ private static final String STATUS_SUCCESS = "success";
+ private static final String STATUS_ERROR = "error";
+ private static final String KEY_STATUS = "status";
+ private static final String KEY_MESSAGE = "message";
+
+ private final DocIngestionService docIngestionService;
+ private final DocIndexingStatusService statusService;
+ private final ActionLogService actionLogService;
+
+ @GetMapping("/status")
+ @Operation(summary = "Get the current status of documentation reindexing")
+ @PreAuthorize("hasRole('" + ROLE_ADMIN + "')")
+ public ResponseEntity getStatus() {
+ return ResponseEntity.ok(statusService.getStatus());
+ }
+
+ @PostMapping("/reindex")
+ @Operation(summary = "Rescan and reindex documentation from the local file system (Full Refresh)")
+ public ResponseEntity> reindex(
+ @RequestParam(required = false, defaultValue = "true") boolean force,
+ Authentication authentication) {
+ try {
+ docIngestionService.reindexAsync(force);
+ actionLogService.log(authentication.getName(), "DOCS_REINDEXED", "Triggered documentation reindexing (force: " + force + ")");
+ return ResponseEntity.ok(Map.of(KEY_STATUS, STATUS_SUCCESS, KEY_MESSAGE, "Reindexing started (force: " + force + ")"));
+ } catch (Exception e) {
+ return ResponseEntity.status(500).body(Map.of(KEY_STATUS, STATUS_ERROR, KEY_MESSAGE, "Reindexing failed: " + e.getMessage()));
+ }
+ }
+
+ @PostMapping("/upload")
+ @Operation(summary = "Upload a zip file containing documentation")
+ public ResponseEntity> uploadDocs(
+ @RequestParam("file") MultipartFile file,
+ @RequestParam(required = false, defaultValue = "true") boolean reindex,
+ @RequestParam(required = false, defaultValue = "true") boolean force,
+ Authentication authentication) {
+
+ if (file.isEmpty()) {
+ return ResponseEntity.badRequest().body(Map.of(KEY_STATUS, STATUS_ERROR, KEY_MESSAGE, "File is empty"));
+ }
+
+ try {
+ docIngestionService.extractZip(file.getInputStream());
+ actionLogService.log(authentication.getName(), "DOCS_UPLOADED", "Uploaded documentation zip: " + file.getOriginalFilename());
+
+ if (reindex) {
+ docIngestionService.reindexAsync(force);
+ actionLogService.log(authentication.getName(), "DOCS_REINDEXED", "Triggered documentation reindexing after upload (force: " + force + ")");
+ return ResponseEntity.ok(Map.of(KEY_STATUS, STATUS_SUCCESS, KEY_MESSAGE, "Upload complete. Reindexing started."));
+ }
+
+ return ResponseEntity.ok(Map.of(KEY_STATUS, STATUS_SUCCESS, KEY_MESSAGE, "Upload completed successfully"));
+ } catch (IOException e) {
+ return ResponseEntity.internalServerError().body(Map.of(KEY_STATUS, STATUS_ERROR, KEY_MESSAGE, "Failed to extract zip: " + e.getMessage()));
+ }
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminSystemController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminSystemController.java
new file mode 100644
index 000000000..af5e9f94c
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminSystemController.java
@@ -0,0 +1,115 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import ch.goodone.angularai.backend.service.IpLocationService;
+import ch.goodone.angularai.backend.service.SystemSettingService;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/settings")
+@Tag(name = "Admin System Settings", description = "Endpoints for administrators to manage system settings")
+@org.springframework.security.access.prepost.PreAuthorize("hasAnyAuthority('" + ROLE_ADMIN + "', '" + ROLE_ADMIN_READ + "')")
+public class AdminSystemController {
+
+ private static final String INDEX = "index";
+ private static final String ENABLED = "enabled";
+ private static final String MODE = "mode";
+ private static final String SETTING_CHANGED = "SETTING_CHANGED";
+
+ private final SystemSettingService systemSettingService;
+ private final ActionLogService actionLogService;
+ private final IpLocationService ipLocationService;
+
+ public AdminSystemController(SystemSettingService systemSettingService, ActionLogService actionLogService, IpLocationService ipLocationService) {
+ this.systemSettingService = systemSettingService;
+ this.actionLogService = actionLogService;
+ this.ipLocationService = ipLocationService;
+ }
+
+ @GetMapping("/geolocation")
+ public ResponseEntity> getGeolocationEnabled() {
+ return ResponseEntity.ok(Map.of(ENABLED, systemSettingService.isGeolocationEnabled()));
+ }
+
+ @PostMapping("/geolocation")
+ @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity setGeolocationEnabled(@RequestBody Map body, Authentication authentication) {
+ if (body == null || !body.containsKey(ENABLED)) {
+ return ResponseEntity.badRequest().build();
+ }
+ boolean enabled = Boolean.TRUE.equals(body.get(ENABLED));
+ systemSettingService.setGeolocationEnabled(enabled);
+ actionLogService.log(authentication.getName(), SETTING_CHANGED, "Geolocation enabled set to: " + enabled);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/recaptcha")
+ public ResponseEntity> getRecaptchaConfigIndex() {
+ return ResponseEntity.ok(Map.of(INDEX, systemSettingService.getRecaptchaConfigIndex()));
+ }
+
+ @PostMapping("/recaptcha")
+ @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity setRecaptchaConfigIndex(@RequestBody Map body, Authentication authentication) {
+ if (body == null || !body.containsKey(INDEX)) {
+ return ResponseEntity.badRequest().build();
+ }
+ int index = body.get(INDEX);
+ systemSettingService.setRecaptchaConfigIndex(index);
+ actionLogService.log(authentication.getName(), SETTING_CHANGED, "reCAPTCHA config index set to: " + index);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/landing-message")
+ public ResponseEntity> getLandingMessageEnabled() {
+ return ResponseEntity.ok(Map.of(ENABLED, systemSettingService.isLandingMessageEnabled()));
+ }
+
+ @PostMapping("/landing-message")
+ @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity setLandingMessageEnabled(@RequestBody Map body, Authentication authentication) {
+ if (body == null || !body.containsKey(ENABLED)) {
+ return ResponseEntity.badRequest().build();
+ }
+ boolean enabled = Boolean.TRUE.equals(body.get(ENABLED));
+ systemSettingService.setLandingMessageEnabled(enabled);
+ actionLogService.log(authentication.getName(), SETTING_CHANGED, "Landing message enabled set to: " + enabled);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/landing-message/mode")
+ public ResponseEntity> getLandingMessageMode() {
+ return ResponseEntity.ok(Map.of(MODE, systemSettingService.getLandingMessageMode()));
+ }
+
+ @PostMapping("/landing-message/mode")
+ @org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity setLandingMessageMode(@RequestBody Map body, Authentication authentication) {
+ if (body == null || !body.containsKey(MODE)) {
+ return ResponseEntity.badRequest().build();
+ }
+ String mode = body.get(MODE);
+ systemSettingService.setLandingMessageMode(mode);
+ actionLogService.log(authentication.getName(), SETTING_CHANGED, "Landing message mode set to: " + mode);
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/geolocation/test")
+ public ResponseEntity testGeolocation(@RequestParam String ip) {
+ if (ip == null || !ip.matches("^(\\d{1,3}\\.){3}\\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$")) {
+ return ResponseEntity.badRequest().build();
+ }
+ return ResponseEntity.ok(ipLocationService.lookup(ip));
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminUserController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminUserController.java
new file mode 100644
index 000000000..41e3a888b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdminUserController.java
@@ -0,0 +1,148 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.dto.UserDTO;
+import ch.goodone.angularai.backend.model.Role;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.model.UserConstants;
+import ch.goodone.angularai.backend.model.UserStatus;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import ch.goodone.angularai.backend.service.ValidationService;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/admin/users")
+@Tag(name = "Admin User Management", description = "Endpoints for administrators to manage users")
+@org.springframework.security.access.prepost.PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+public class AdminUserController {
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final ActionLogService actionLogService;
+ private final ValidationService validationService;
+
+ public AdminUserController(UserRepository userRepository, PasswordEncoder passwordEncoder, ActionLogService actionLogService, ValidationService validationService) {
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.actionLogService = actionLogService;
+ this.validationService = validationService;
+ }
+
+ @GetMapping
+ public List getAllUsers(Authentication authentication) {
+ return userRepository.findAll().stream()
+ .map(u -> UserDTO.fromEntity(u, authentication))
+ .toList();
+ }
+
+ @PostMapping
+ public ResponseEntity createUser(@RequestBody UserDTO userDTO, Authentication authentication) {
+ validationService.validateUserRegistrationThrowing(userDTO);
+
+ User user = new User();
+ user.setFirstName(userDTO.getFirstName());
+ user.setLastName(userDTO.getLastName());
+ user.setLogin(userDTO.getLogin());
+ user.setPassword(passwordEncoder.encode(userDTO.getPassword() != null ? userDTO.getPassword() : "password123"));
+ user.setEmail(userDTO.getEmail());
+ user.setBirthDate(userDTO.getBirthDate());
+ user.setAddress(userDTO.getAddress());
+ user.setStatus(UserStatus.ACTIVE);
+ if (userDTO.getRole() != null) {
+ user.setRole(Role.valueOf(userDTO.getRole()));
+ } else {
+ user.setRole(Role.ROLE_USER);
+ }
+
+ userRepository.save(user);
+ actionLogService.log(authentication.getName(), "USER_CREATED", "Admin created user: " + user.getLogin());
+ return ResponseEntity.ok(UserDTO.fromEntity(user, authentication));
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateUser(@PathVariable Long id, @RequestBody UserDTO userDTO, Authentication authentication) {
+ if (!validationService.isValidEmail(userDTO.getEmail())) {
+ throw new IllegalArgumentException("Invalid email format");
+ }
+ return userRepository.findById(id)
+ .map(user -> {
+ // Unique email check
+ if (userDTO.getEmail() != null && !userDTO.getEmail().equals(user.getEmail()) &&
+ userRepository.findByEmail(userDTO.getEmail()).isPresent()) {
+ throw new IllegalArgumentException("Email already exists");
+ }
+
+ // Self-protection: Prevent admin from removing their own admin privileges
+ if (user.getLogin().equals(authentication.getName()) &&
+ userDTO.getRole() != null && !Role.ROLE_ADMIN.name().equals(userDTO.getRole())) {
+ throw new IllegalArgumentException("Cannot remove your own admin role");
+ }
+
+ user.setFirstName(userDTO.getFirstName());
+ user.setLastName(userDTO.getLastName());
+ user.setEmail(userDTO.getEmail());
+ user.setBirthDate(userDTO.getBirthDate());
+ user.setAddress(userDTO.getAddress());
+ if (userDTO.getRole() != null) {
+ user.setRole(Role.valueOf(userDTO.getRole()));
+ }
+
+ userRepository.save(user);
+ actionLogService.log(authentication.getName(), "USER_MODIFIED", "Admin modified user: " + user.getLogin());
+ return ResponseEntity.ok(UserDTO.fromEntity(user, authentication));
+ })
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteUser(@PathVariable Long id, Authentication authentication) {
+ return userRepository.findById(id)
+ .map(user -> {
+ // Self-protection: Prevent admin from deleting their own account
+ if (user.getLogin().equals(authentication.getName())) {
+ throw new IllegalArgumentException("Cannot delete your own account");
+ }
+ // Protection of standard users
+ if (List.of(UserConstants.LOGIN_ADMIN, UserConstants.LOGIN_USER, UserConstants.LOGIN_ADMIN_READ).contains(user.getLogin())) {
+ throw new IllegalArgumentException("Cannot delete standard system users");
+ }
+ userRepository.delete(user);
+ actionLogService.log(authentication.getName(), "USER_DELETED", "Admin deleted user: " + user.getLogin());
+ return ResponseEntity.noContent().build();
+ })
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @DeleteMapping("/cleanup")
+ @org.springframework.security.access.prepost.PreAuthorize("hasRole('" + ROLE_ADMIN + "')")
+ public ResponseEntity cleanupNonStandardUsers(Authentication authentication) {
+ if (authentication == null) {
+ return ResponseEntity.status(401).body("Not authenticated");
+ }
+
+ List protectedUsers = List.of(UserConstants.LOGIN_ADMIN, UserConstants.LOGIN_USER, UserConstants.LOGIN_ADMIN_READ, UserConstants.LOGIN_USER2, UserConstants.LOGIN_PENDING);
+ List usersToDelete = userRepository.findAll().stream()
+ .filter(user -> !protectedUsers.contains(user.getLogin()))
+ .toList();
+
+ int count = usersToDelete.size();
+ userRepository.deleteAll(usersToDelete);
+
+ actionLogService.log(authentication.getName(), "USERS_CLEANED_UP", "Admin cleaned up " + count + " non-standard users.");
+ return ResponseEntity.ok("Successfully deleted " + count + " non-standard users.");
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AdrController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdrController.java
new file mode 100644
index 000000000..d62f95883
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AdrController.java
@@ -0,0 +1,163 @@
+package ch.goodone.angularai.backend.controller;
+
+import ch.goodone.angularai.backend.dto.AdrContentDTO;
+import ch.goodone.angularai.backend.model.DocChunk;
+import ch.goodone.angularai.backend.model.DocSource;
+import ch.goodone.angularai.backend.repository.DocChunkRepository;
+import ch.goodone.angularai.backend.repository.DocSourceRepository;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Controller for managing and serving Architecture Decision Records (ADRs).
+ */
+@RestController
+@RequestMapping("/api/adr")
+@RequiredArgsConstructor
+@Tag(name = "ADR", description = "Endpoints for Architecture Decision Records")
+@Slf4j
+public class AdrController {
+
+ private final DocSourceRepository sourceRepository;
+ private final DocChunkRepository chunkRepository;
+
+ private static final String UNKNOWN = "Unknown";
+ private static final String ADR_FULL_SET_PATH = "doc/knowledge/adrs/adr-full-set.md";
+ private static final String ADR_PREFIX = "ADR-";
+
+ private static final Pattern ADR_HEADER_PATTERN = Pattern.compile("(?mi)^\\s*#+\\s*(ADR-\\d+)[\\s:\\-]*\\s*(.*)");
+ private static final Pattern STATUS_PATTERN = Pattern.compile("(?i)\\*\\*Status:\\*\\*\\s*(.*)");
+ private static final Pattern DATE_PATTERN = Pattern.compile("(?i)\\*\\*Date:\\*\\*\\s*(.*)");
+ private static final Pattern CONTEXT_PATTERN = Pattern.compile("(?i)\\*\\*Context:\\*\\*\\s*(.*)");
+
+ /**
+ * Retrieves the full set of ADRs as markdown content with parsed metadata.
+ *
+ * @return AdrContentDTO containing the full markdown and a list of ADR items.
+ */
+ @GetMapping("/full-set")
+ @Operation(summary = "Get the full set of Architecture Decision Records")
+ public ResponseEntity getFullSet() {
+ log.info("Request received for full ADR set");
+ Optional sourceOpt = sourceRepository.findByPath(ADR_FULL_SET_PATH);
+ if (sourceOpt.isEmpty()) {
+ log.warn("ADR source file not found in database: {}", ADR_FULL_SET_PATH);
+ // Fallback: try to find by containing path if exact match fails
+ List sources = sourceRepository.findByPathContaining("adr-full-set.md");
+ if (sources.isEmpty()) {
+ return ResponseEntity.notFound().build();
+ }
+ sourceOpt = Optional.of(sources.getFirst());
+ }
+
+ DocSource source = sourceOpt.get();
+ List chunks = chunkRepository.findBySource(source);
+
+ if (chunks.isEmpty()) {
+ log.warn("No chunks found for ADR source: {}", source.getPath());
+ return ResponseEntity.noContent().build();
+ }
+
+ String fullContent = chunks.stream()
+ .sorted(Comparator.comparing(DocChunk::getChunkOrder))
+ .map(DocChunk::getContent)
+ .collect(Collectors.joining("\n"));
+
+ // Clean up line endings to ensure consistent parsing
+ fullContent = fullContent.replace("\r\n", "\n").replace("\r", "\n");
+
+ if (!fullContent.isEmpty()) {
+ log.debug("ADR content preview: {}", fullContent.substring(0, Math.min(200, fullContent.length())));
+ }
+
+ List items = parseAdrItems(fullContent);
+ log.info("Full ADR set ({} chars) parsed into {} items. Source: {}",
+ fullContent.length(), items.size(), source.getPath());
+
+ if (items.isEmpty() && fullContent.contains(ADR_PREFIX)) {
+ log.warn("{} string found in content but no items parsed. Parsing failed. First 500 chars of content: {}",
+ ADR_PREFIX, fullContent.substring(0, Math.min(500, fullContent.length())));
+ }
+
+ return ResponseEntity.ok(AdrContentDTO.builder()
+ .content(fullContent)
+ .items(items)
+ .build());
+ }
+
+ private List parseAdrItems(String content) {
+ List items = new ArrayList<>();
+
+ // Split content into lines for more robust processing
+ String[] lines = content.split("\n");
+
+ AdrContentDTO.AdrItemDTO currentItem = null;
+ StringBuilder currentBody = new StringBuilder();
+
+ for (String line : lines) {
+ Matcher matcher = ADR_HEADER_PATTERN.matcher(line);
+ if (matcher.find()) {
+ // Save previous item if exists
+ if (currentItem != null) {
+ fillMetadata(currentItem, currentBody.toString());
+ items.add(currentItem);
+ }
+
+ // Start new item
+ currentItem = AdrContentDTO.AdrItemDTO.builder()
+ .id(matcher.group(1))
+ .title(matcher.group(2) != null ? matcher.group(2).trim() : UNKNOWN)
+ .build();
+ currentBody = new StringBuilder();
+ } else if (currentItem != null) {
+ currentBody.append(line).append("\n");
+ }
+ }
+
+ // Save last item
+ if (currentItem != null) {
+ fillMetadata(currentItem, currentBody.toString());
+ items.add(currentItem);
+ }
+
+ return items;
+ }
+
+ private void fillMetadata(AdrContentDTO.AdrItemDTO item, String body) {
+ String status = extractValue(body, STATUS_PATTERN);
+ String date = extractValue(body, DATE_PATTERN);
+ String context = extractValue(body, CONTEXT_PATTERN);
+
+ item.setStatus(status != null && !status.trim().isEmpty() ? status.trim() : UNKNOWN);
+ item.setDate(date != null && !date.trim().isEmpty() ? date.trim() : UNKNOWN);
+
+ if (context != null && !context.trim().isEmpty()) {
+ String firstLine = context.split("\n")[0].trim();
+ item.setSummary(firstLine);
+ } else {
+ item.setSummary("");
+ }
+ }
+
+ private String extractValue(String section, Pattern pattern) {
+ Matcher matcher = pattern.matcher(section);
+ if (matcher.find()) {
+ return matcher.group(1);
+ }
+ return null;
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AiAdminController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiAdminController.java
new file mode 100644
index 000000000..0da28de6f
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiAdminController.java
@@ -0,0 +1,157 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.ai.dto.AiCreditRequestActionRequest;
+import ch.goodone.angularai.backend.ai.dto.AiCreditRequestDTO;
+import ch.goodone.angularai.backend.ai.usage.AiCreditRequestService;
+import ch.goodone.angularai.backend.ai.usage.AiUsageService;
+import ch.goodone.angularai.backend.dto.ai.AiSettingsDto;
+import ch.goodone.angularai.backend.dto.ai.AiSuffixRuleDto;
+import ch.goodone.angularai.backend.dto.ai.AiUsageDashboardDto;
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import ch.goodone.angularai.backend.model.AiSuffixRule;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.AiSuffixRuleRepository;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.service.SystemSettingService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/admin")
+@RequiredArgsConstructor
+public class AiAdminController {
+ private static final String KEY_LOGIN = "login";
+ private static final String KEY_LIMIT = "limit";
+
+ private final AiUsageService aiUsageService;
+ private final AiSuffixRuleRepository suffixRuleRepository;
+ private final SystemSettingService systemSettingService;
+ private final UserRepository userRepository;
+ private final AiCreditRequestService aiCreditRequestService;
+
+ @GetMapping("/ai-usage")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity getAiUsageDashboard(org.springframework.security.core.Authentication authentication) {
+ long totalCallsToday = aiUsageService.getTotalCallsToday();
+
+ boolean isAdmin = authentication != null && authentication.getAuthorities().stream()
+ .anyMatch(a -> a.getAuthority().equals(ROLE_ADMIN));
+
+ List callsPerUser = aiUsageService.getUserUsageToday().stream()
+ .map(u -> {
+ String login = u.getUser().getLogin();
+ if (!isAdmin && u.getUser().getAnonymizedLogin() != null) {
+ login = u.getUser().getAnonymizedLogin();
+ } else if (!isAdmin) {
+ login = "user_" + login.hashCode();
+ }
+ return new AiUsageDashboardDto.UserUsageDto(
+ login,
+ u.getAiCalls(),
+ aiUsageService.getUserDailyLimit(u.getUser()) + u.getExtraCredits());
+ })
+ .toList();
+
+ List callsPerFeature = aiUsageService.getFeatureUsageToday().stream()
+ .map(f -> new AiUsageDashboardDto.FeatureUsageDto(f.getFeatureName(), f.getAiCalls()))
+ .toList();
+
+ List dailyTrend = aiUsageService.getDailyTrend(30).stream()
+ .collect(Collectors.groupingBy(f -> f.getDate(),
+ java.util.TreeMap::new,
+ Collectors.summingLong(f -> (long) f.getAiCalls())))
+ .entrySet().stream()
+ .map(e -> new AiUsageDashboardDto.DailyTrendDto(e.getKey(), e.getValue()))
+ .toList();
+
+ return ResponseEntity.ok(new AiUsageDashboardDto(totalCallsToday, callsPerUser, callsPerFeature, dailyTrend));
+ }
+
+ @GetMapping("/ai/settings")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity getAiSettings() {
+ return ResponseEntity.ok(new AiSettingsDto(
+ systemSettingService.isAiGlobalEnabled(),
+ systemSettingService.getAiDefaultDailyLimit()
+ ));
+ }
+
+ @PostMapping("/ai/settings")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity updateAiSettings(@RequestBody AiSettingsDto settings) {
+ systemSettingService.setAiGlobalEnabled(settings.isGlobalEnabled());
+ systemSettingService.setAiDefaultDailyLimit(settings.getDefaultDailyLimit());
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/ai/suffix-rules")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity> getSuffixRules() {
+ return ResponseEntity.ok(suffixRuleRepository.findAll().stream()
+ .map(AiSuffixRuleDto::fromEntity)
+ .toList());
+ }
+
+ @PostMapping("/ai/suffix-rules")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity updateSuffixRule(@RequestBody AiSuffixRuleDto dto) {
+ AiSuffixRule rule = suffixRuleRepository.findBySuffix(dto.getSuffix())
+ .orElse(new AiSuffixRule());
+ rule.setSuffix(dto.getSuffix());
+ rule.setDailyLimit(dto.getDailyLimit());
+ return ResponseEntity.ok(AiSuffixRuleDto.fromEntity(suffixRuleRepository.save(rule)));
+ }
+
+ @DeleteMapping("/ai/suffix-rules/{id}")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity deleteSuffixRule(@PathVariable Long id) {
+ suffixRuleRepository.deleteById(id);
+ return ResponseEntity.ok().build();
+ }
+
+ @PostMapping("/ai/user-limit")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity setUserLimit(@RequestBody Map payload) {
+ String login = (String) payload.get(KEY_LOGIN);
+ Integer limit = (Integer) payload.get(KEY_LIMIT);
+
+ User user = userRepository.findByLogin(login)
+ .orElseThrow(() -> new IllegalArgumentException("User not found: " + login));
+
+ user.setAiDailyLimit(limit);
+ userRepository.save(user);
+
+ return ResponseEntity.ok().build();
+ }
+
+ @GetMapping("/ai/credit-requests")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity> getCreditRequests() {
+ return ResponseEntity.ok(aiCreditRequestService.getAllRequests().stream()
+ .map(AiCreditRequestDTO::fromEntity)
+ .toList());
+ }
+
+ @PostMapping("/ai/credit-requests/{id}/action")
+ @PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+ public ResponseEntity actionCreditRequest(
+ @PathVariable Long id,
+ @RequestBody AiCreditRequestActionRequest actionRequest,
+ org.springframework.security.core.Authentication authentication) {
+
+ AiCreditRequest entity = aiCreditRequestService.updateStatus(
+ id,
+ actionRequest.getStatus(),
+ actionRequest.getNote(),
+ authentication.getName());
+
+ return ResponseEntity.ok(AiCreditRequestDTO.fromEntity(entity));
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AiController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiController.java
new file mode 100644
index 000000000..c0a9d509e
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiController.java
@@ -0,0 +1,132 @@
+package ch.goodone.angularai.backend.controller;
+
+import ch.goodone.angularai.backend.ai.application.AiApplicationService;
+import ch.goodone.angularai.backend.ai.dto.*;
+import ch.goodone.angularai.backend.ai.usage.AiCreditRequestService;
+import ch.goodone.angularai.backend.ai.usage.AiUsageService;
+import ch.goodone.angularai.backend.dto.ai.UserAiUsageDto;
+import ch.goodone.angularai.backend.model.AiCreditRequest;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Controller for AI-related endpoints.
+ */
+@RestController
+@RequestMapping("/api/ai")
+@RequiredArgsConstructor
+@Slf4j
+public class AiController {
+ private final AiApplicationService aiApplicationService;
+ private final ch.goodone.angularai.backend.service.CaptchaService captchaService;
+ private final AiCreditRequestService aiCreditRequestService;
+ private final AiUsageService aiUsageService;
+ private final UserRepository userRepository;
+
+ /**
+ * Parses a quick-add task string into structured data.
+ *
+ * @param request The parse request.
+ * @return The parsed result.
+ */
+ @PostMapping("/task-quick-add/parse")
+ public QuickAddParseResult parseQuickAdd(@RequestBody QuickAddParseRequest request, Authentication authentication) {
+ log.info("Request to parse quick-add task");
+ if (!captchaService.verify(request.getRecaptchaToken(), "task_quick_add")) {
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+ return aiApplicationService.parseQuickAdd(request, authentication != null ? authentication.getName() : "anonymous");
+ }
+
+ /**
+ * Explains the project architecture based on a question.
+ *
+ * @param request The explanation request.
+ * @return The explanation result.
+ */
+ @PostMapping("/architecture/explain")
+ public ArchitectureExplainResult explainArchitecture(@RequestBody ArchitectureExplainRequest request) {
+ log.info("Request to explain architecture");
+ if (!captchaService.verify(request.getRecaptchaToken(), "architecture_explain")) {
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+ return aiApplicationService.explainArchitecture(request);
+ }
+
+ /**
+ * Generates a Risk Radar report.
+ *
+ * @param request The Risk Radar request.
+ * @return The Risk Radar response.
+ */
+ @PostMapping("/risk-radar")
+ public RiskRadarResponse generateRiskRadar(@RequestBody RiskRadarRequest request) {
+ log.info("Request to generate Risk Radar");
+ if (!captchaService.verify(request.getRecaptchaToken(), "risk_radar")) {
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+ return aiApplicationService.generateRiskRadar(request);
+ }
+
+ /**
+ * Detects ADR drift.
+ *
+ * @param request The ADR drift request.
+ * @return The ADR drift response.
+ */
+ @PostMapping("/adr-drift")
+ public AdrDriftResponse detectAdrDrift(@RequestBody AdrDriftRequest request) {
+ log.info("Request to detect ADR drift");
+ if (!captchaService.verify(request.getRecaptchaToken(), "adr_drift")) {
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+ return aiApplicationService.detectAdrDrift(request);
+ }
+
+ @PostMapping("/credits/request")
+ public AiCreditRequestDTO createCreditRequest(@RequestBody @Valid AiCreditRequestCreateRequest request, Authentication authentication) {
+ log.info("Request to create AI credit top-up");
+ if (!captchaService.verify(request.getCaptchaToken(), "ai_credit_request")) {
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+
+ if (authentication == null) {
+ throw new IllegalStateException("User must be logged in");
+ }
+
+ User user = userRepository.findByLogin(authentication.getName())
+ .orElseThrow(() -> new UsernameNotFoundException("User not found"));
+
+ AiCreditRequest entity = aiCreditRequestService.createRequest(user, request.getAmount(), request.getType(), request.getReason());
+ return AiCreditRequestDTO.fromEntity(entity);
+ }
+
+ @GetMapping("/credits/requests")
+ public List getMyCreditRequests(Authentication authentication) {
+ if (authentication == null) {
+ throw new IllegalStateException("User must be logged in");
+ }
+ return aiCreditRequestService.getRequestsForUser(authentication.getName())
+ .stream()
+ .map(AiCreditRequestDTO::fromEntity)
+ .toList();
+ }
+
+ @GetMapping("/usage")
+ public UserAiUsageDto getMyAiUsage(Authentication authentication) {
+ if (authentication == null) {
+ throw new IllegalStateException("User must be logged in");
+ }
+ User user = userRepository.findByLogin(authentication.getName())
+ .orElseThrow(() -> new UsernameNotFoundException("User not found"));
+ return aiUsageService.getUserUsageSummary(user);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AiCostController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiCostController.java
new file mode 100644
index 000000000..d7c1e5e46
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiCostController.java
@@ -0,0 +1,35 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.*;
+import ch.goodone.angularai.backend.ai.usage.AiUsageCostService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/ai/cost")
+@RequiredArgsConstructor
+@PreAuthorize("hasAuthority('" + ROLE_ADMIN + "')")
+public class AiCostController {
+
+ private final AiUsageCostService aiUsageCostService;
+
+ @GetMapping("/dashboard")
+ public ResponseEntity> getDashboardData(
+ @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from) {
+
+ if (from == null) {
+ from = LocalDateTime.now().minusDays(30);
+ }
+
+ return ResponseEntity.ok(aiUsageCostService.getAggregatedCosts(from));
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AiExceptionHandler.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiExceptionHandler.java
new file mode 100644
index 000000000..a1df27220
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AiExceptionHandler.java
@@ -0,0 +1,79 @@
+package ch.goodone.angularai.backend.controller;
+
+import ch.goodone.angularai.backend.ai.exception.AiDisabledException;
+import ch.goodone.angularai.backend.ai.exception.AiException;
+import ch.goodone.angularai.backend.ai.exception.AiParsingException;
+import ch.goodone.angularai.backend.ai.exception.AiProviderException;
+import ch.goodone.angularai.backend.ai.exception.AiRateLimitException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+
+/**
+ * Centralized error handling for AI-related exceptions.
+ */
+@RestControllerAdvice(assignableTypes = {AiController.class, RetrospectiveController.class, AiAdminController.class})
+@Slf4j
+public class AiExceptionHandler {
+
+ @ExceptionHandler(AiProviderException.class)
+ public ResponseEntity> handleAiProviderException(AiProviderException e) {
+ log.error("AI Provider error: {}. Cause: {}", e.getMessage(), e.getCause() != null ? e.getCause().getMessage() : "none");
+ return createErrorResponse(HttpStatus.SERVICE_UNAVAILABLE, e.getMessage());
+ }
+
+ @ExceptionHandler(AiDisabledException.class)
+ public ResponseEntity> handleAiDisabledException(AiDisabledException e) {
+ log.info("AI features are disabled: {}", e.getMessage());
+ return createErrorResponse(HttpStatus.FORBIDDEN, e.getMessage());
+ }
+
+ @ExceptionHandler(AiParsingException.class)
+ public ResponseEntity> handleAiParsingException(AiParsingException e) {
+ log.error("AI Parsing error: {}. Cause: {}", e.getMessage(), e.getCause() != null ? e.getCause().getMessage() : "none");
+ return createErrorResponse(HttpStatus.BAD_GATEWAY, "The AI provider returned an invalid response. Please try again.");
+ }
+
+ @ExceptionHandler(AiRateLimitException.class)
+ public ResponseEntity> handleAiRateLimitException(AiRateLimitException e) {
+ log.warn("AI Rate limit exceeded: {}", e.getMessage());
+ return createErrorResponse(HttpStatus.TOO_MANY_REQUESTS, e.getMessage());
+ }
+
+ @ExceptionHandler(AccessDeniedException.class)
+ public ResponseEntity> handleAccessDeniedException(AccessDeniedException e) {
+ log.warn("Access denied in AI controller: {}", e.getMessage());
+ return createErrorResponse(HttpStatus.FORBIDDEN, "You do not have permission to access this AI feature.");
+ }
+
+ @ExceptionHandler(AiException.class)
+ public ResponseEntity> handleGeneralAiException(AiException e) {
+ log.error("General AI error: {}. Cause: {}", e.getMessage(), e.getCause() != null ? e.getCause().getMessage() : "none");
+ return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An error occurred while processing your AI request.");
+ }
+
+ @ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class)
+ public ResponseEntity> handleHttpMessageNotReadableException(org.springframework.http.converter.HttpMessageNotReadableException e) {
+ log.warn("Malformed JSON in AI request: {}", e.getMessage());
+ return createErrorResponse(HttpStatus.BAD_REQUEST, "Invalid JSON payload. Please ensure the request body is valid JSON with double-quoted property names.");
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleGeneralException(Exception e) {
+ log.error("Unhandled exception in AI controller", e);
+ return createErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
+ }
+
+ private ResponseEntity> createErrorResponse(HttpStatus status, String message) {
+ java.util.Map body = new java.util.HashMap<>();
+ body.put("error", message);
+ body.put("reason", message);
+ body.put("message", message);
+ return ResponseEntity.status(status).body(body);
+ }
+}
diff --git a/backend/src/main/java/ch/goodone/angularai/backend/controller/AuthController.java b/backend/src/main/java/ch/goodone/angularai/backend/controller/AuthController.java
new file mode 100644
index 000000000..91fd1783b
--- /dev/null
+++ b/backend/src/main/java/ch/goodone/angularai/backend/controller/AuthController.java
@@ -0,0 +1,429 @@
+package ch.goodone.angularai.backend.controller;
+
+import static ch.goodone.angularai.backend.util.SecurityConstants.sanitizeLog;
+import ch.goodone.angularai.backend.ai.usage.AiUsageService;
+import ch.goodone.angularai.backend.dto.UserDTO;
+import ch.goodone.angularai.backend.model.PasswordRecoveryToken;
+import ch.goodone.angularai.backend.model.User;
+import ch.goodone.angularai.backend.model.VerificationToken;
+import ch.goodone.angularai.backend.repository.PasswordRecoveryTokenRepository;
+import ch.goodone.angularai.backend.repository.UserRepository;
+import ch.goodone.angularai.backend.repository.VerificationTokenRepository;
+import ch.goodone.angularai.backend.service.ActionLogService;
+import ch.goodone.angularai.backend.service.CaptchaService;
+import ch.goodone.angularai.backend.service.EmailService;
+import ch.goodone.angularai.backend.service.JwtService;
+import ch.goodone.angularai.backend.service.UserAliasService;
+import ch.goodone.angularai.backend.service.ValidationService;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Locale;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/auth")
+@Tag(name = "Authentication", description = "Endpoints for user login, logout, and registration")
+public class AuthController {
+
+ private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
+
+ private final UserRepository userRepository;
+ private final PasswordEncoder passwordEncoder;
+ private final ActionLogService actionLogService;
+ private final CaptchaService captchaService;
+ private final VerificationTokenRepository verificationTokenRepository;
+ private final PasswordRecoveryTokenRepository passwordRecoveryTokenRepository;
+ private final ValidationService validationService;
+ private final EmailService emailService;
+ private final JwtService jwtService;
+ private final AiUsageService aiUsageService;
+ private final UserAliasService userAliasService;
+
+ @org.springframework.beans.factory.annotation.Value("${app.base-url}")
+ private String baseUrl;
+
+ @org.springframework.beans.factory.annotation.Value("${app.security.jwt.enabled:false}")
+ private boolean jwtEnabled;
+
+ @org.springframework.beans.factory.annotation.Value("${e2e.bypass.secret:}")
+ private String bypassSecret;
+
+ @SuppressWarnings("java:S107")
+ public AuthController(UserRepository userRepository,
+ PasswordEncoder passwordEncoder,
+ ActionLogService actionLogService,
+ CaptchaService captchaService,
+ VerificationTokenRepository verificationTokenRepository,
+ PasswordRecoveryTokenRepository passwordRecoveryTokenRepository,
+ ValidationService validationService,
+ EmailService emailService,
+ JwtService jwtService,
+ AiUsageService aiUsageService,
+ UserAliasService userAliasService) {
+ this.userRepository = userRepository;
+ this.passwordEncoder = passwordEncoder;
+ this.actionLogService = actionLogService;
+ this.captchaService = captchaService;
+ this.verificationTokenRepository = verificationTokenRepository;
+ this.passwordRecoveryTokenRepository = passwordRecoveryTokenRepository;
+ this.validationService = validationService;
+ this.emailService = emailService;
+ this.jwtService = jwtService;
+ this.aiUsageService = aiUsageService;
+ this.userAliasService = userAliasService;
+ }
+
+ @PostMapping("/login")
+ public ResponseEntity login(Authentication authentication, HttpServletRequest request, @RequestBody(required = false) Map body) {
+ if (authentication == null) {
+ logger.error("Login attempt failed: Authentication object is null");
+ return ResponseEntity.status(401).build();
+ }
+
+ String recaptchaToken = body != null ? body.get("recaptchaToken") : null;
+ if (!captchaService.verify(recaptchaToken, "login")) {
+ logger.warn("Login attempt: reCAPTCHA verification failed for user: {}", authentication.getName());
+ // Invalidate session to ensure no persistent login state if it was already created by httpBasic
+ jakarta.servlet.http.HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.invalidate();
+ }
+ return ResponseEntity.status(401).build();
+ }
+
+ User user = userRepository.findByLogin(authentication.getName())
+ .orElseGet(() -> {
+ logger.error("Login attempt failed: User not found for login: {}", authentication.getName());
+ return null;
+ });
+ if (user == null) {
+ return ResponseEntity.status(401).build();
+ }
+
+ if (user.getStatus() != ch.goodone.angularai.backend.model.UserStatus.ACTIVE) {
+ logger.warn("Login attempt for non-active user: {}", user.getLogin());
+ // Invalidate session to ensure no persistent login state
+ jakarta.servlet.http.HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.invalidate();
+ }
+ return ResponseEntity.status(403).build();
+ }
+
+ String ip = request.getHeader("X-Forwarded-For");
+ logger.info("Login request from IP (X-Forwarded-For): {}", ip);
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getRemoteAddr();
+ logger.info("Login request from IP (RemoteAddr): {}", ip);
+ } else if (ip.contains(",")) {
+ // If there are multiple IPs, the first one is the client IP
+ ip = ip.split(",")[0].trim();
+ logger.info("Parsed client IP from X-Forwarded-For: {}", ip);
+ }
+ String ua = request.getHeader("User-Agent");
+ logger.info("Login request User-Agent: {}", ua);
+
+ actionLogService.logLogin(user.getLogin(), ip, ua);
+
+ UserDTO userDTO = UserDTO.fromEntity(user, authentication, true);
+ userDTO.setAiDailyLimit(aiUsageService.getUserDailyLimit(user));
+ userDTO.setAiUsageToday(aiUsageService.getUsageToday(user));
+ userDTO.setAiGlobalEnabled(aiUsageService.isAiGlobalEnabled());
+
+ if (jwtEnabled && authentication.getPrincipal() instanceof org.springframework.security.core.userdetails.UserDetails userDetails) {
+ String token = jwtService.generateToken(userDetails);
+ userDTO.setToken(token);
+ }
+
+ return ResponseEntity.ok(userDTO);
+ }
+
+ @GetMapping("/info")
+ public ResponseEntity getAuthInfo(Authentication authentication, HttpServletRequest request) {
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return ResponseEntity.ok(null);
+ }
+ return userRepository.findByLogin(authentication.getName())
+ .map(user -> {
+ if (user.getStatus() != ch.goodone.angularai.backend.model.UserStatus.ACTIVE) {
+ logger.warn("Auth info request for non-active user: {}", user.getLogin());
+ // Invalidate session to ensure no persistent login state
+ jakarta.servlet.http.HttpSession session = request.getSession(false);
+ if (session != null) {
+ session.invalidate();
+ }
+ return ResponseEntity.status(403).build();
+ }
+ UserDTO dto = UserDTO.fromEntity(user);
+ dto.setAiDailyLimit(aiUsageService.getUserDailyLimit(user));
+ dto.setAiUsageToday(aiUsageService.getUsageToday(user));
+ dto.setAiGlobalEnabled(aiUsageService.isAiGlobalEnabled());
+ return ResponseEntity.ok(dto);
+ })
+ .orElse(ResponseEntity.ok(null));
+ }
+
+ @PostMapping("/logout")
+ public ResponseEntity logout(Authentication authentication) {
+ if (authentication != null) {
+ actionLogService.log(authentication.getName(), "USER_LOGOUT", "User logged out");
+ }
+ return ResponseEntity.ok().build();
+ }
+
+ @PostMapping("/register")
+ @Transactional
+ public ResponseEntity register(@jakarta.validation.Valid @RequestBody UserDTO userDTO) {
+ if (userDTO == null) {
+ throw new IllegalArgumentException("Invalid request data");
+ }
+
+ String sanitizedLogin = sanitizeLog(userDTO.getLogin());
+ logger.info("Registering user: {}", sanitizedLogin);
+
+ validateRegistration(userDTO);
+
+ cleanupPendingUser(userDTO.getLogin(), userDTO.getEmail());
+
+ User user = createUserFromDTO(userDTO);
+
+ userRepository.save(user);
+
+ // Generate and save alias after ID is assigned
+ userAliasService.generateAlias(user);
+ userRepository.save(user);
+ logger.debug("User saved, generating verification token");
+
+ VerificationToken verificationToken = new VerificationToken(user);
+ verificationTokenRepository.save(verificationToken);
+
+ logger.debug("Verification token generated, sending email");
+ emailService.sendVerificationEmail(user.getEmail(), verificationToken.getToken());
+
+ String token = verificationToken.getToken();
+ String maskedToken = token != null && token.length() > 4 ? token.substring(0, 4) + "..." : "****";
+ actionLogService.log(user.getLogin(), "USER_REGISTERED", "User registered, pending verification. Token: " + sanitizeLog(maskedToken));
+ return ResponseEntity.ok(UserDTO.fromEntity(user, null, true));
+ }
+
+ private User createUserFromDTO(UserDTO userDTO) {
+ User user = new User();
+ user.setFirstName(userDTO.getFirstName());
+ user.setLastName(userDTO.getLastName());
+ user.setLogin(userDTO.getLogin());
+ user.setPassword(passwordEncoder.encode(userDTO.getPassword()));
+ user.setEmail(userDTO.getEmail());
+ user.setPhone(userDTO.getPhone() != null && !userDTO.getPhone().isBlank() ? userDTO.getPhone() : null);
+ user.setBirthDate(userDTO.getBirthDate());
+ user.setAddress(userDTO.getAddress());
+ user.setRole(ch.goodone.angularai.backend.model.Role.ROLE_ADMIN_READ);
+ user.setStatus(ch.goodone.angularai.backend.model.UserStatus.PENDING);
+ return user;
+ }
+
+ private void validateRegistration(UserDTO userDTO) {
+ String token = userDTO.getRecaptchaToken();
+ String login = userDTO.getLogin();
+
+ String sanitizedLogin = sanitizeLog(login);
+ logger.debug("VALIDATE REGISTRATION: user='{}', token='{}'",
+ sanitizedLogin, token != null && token.length() > 5 ? token.substring(0, 5) + "..." : token);
+
+ // FAIL-SAFE BYPASS FOR E2E TESTS
+ if (bypassSecret != null && !bypassSecret.isBlank() && bypassSecret.equals(token)) {
+ logger.info("VALIDATE REGISTRATION: FAIL-SAFE BYPASS TRIGGERED for user {}", sanitizedLogin);
+ } else if (!captchaService.verify(token, "register")) {
+ logger.warn("VALIDATE REGISTRATION: reCAPTCHA verification failed for user: {}", sanitizedLogin);
+ throw new IllegalArgumentException("reCAPTCHA verification failed");
+ }
+
+ validationService.validateUserRegistrationThrowing(userDTO);
+ }
+
+ private void cleanupPendingUser(String login, String email) {
+ // Handle re-registration: clean up pending user with same login or email
+ userRepository.findByLogin(login).ifPresent(u -> {
+ if (u.getStatus() == ch.goodone.angularai.backend.model.UserStatus.PENDING) {
+ actionLogService.log(u.getLogin(), "USER_REREGISTER_CLEANUP", "Cleaning up pending user for re-registration by login: " + u.getLogin());
+ verificationTokenRepository.deleteByUser(u);
+ userRepository.delete(u);
+ userRepository.flush();
+ }
+ });
+ userRepository.findByEmail(email).ifPresent(u -> {
+ if (u.getStatus() == ch.goodone.angularai.backend.model.UserStatus.PENDING) {
+ actionLogService.log(u.getLogin(), "USER_REREGISTER_CLEANUP", "Cleaning up pending user for re-registration by email: " + u.getEmail());
+ verificationTokenRepository.deleteByUser(u);
+ userRepository.delete(u);
+ userRepository.flush();
+ }
+ });
+ }
+
+ @GetMapping("/verify")
+ @Transactional
+ public ResponseEntity verify(@RequestParam String token) {
+ logger.info("Received verification request");
+ return verificationTokenRepository.findByToken(token)
+ .>map(t -> {
+ logger.info("Token found for user");
+ if (t.isExpired()) {
+ logger.warn("Token expired for user");
+ throw new IllegalArgumentException("Verification token has expired");
+ }
+ User user = t.getUser();
+ if (user.getPendingEmail() != null) {
+ logger.info("Confirming email change for user: {}", sanitizeLog(user.getLogin()));
+ user.setEmail(user.getPendingEmail());
+ user.setPendingEmail(null);
+ actionLogService.log(user.getLogin(), "USER_EMAIL_CHANGED", "User successfully changed email to: " + sanitizeLog(user.getEmail()));
+ }
+ user.setStatus(ch.goodone.angularai.backend.model.UserStatus.ACTIVE);
+ user.setVerificationToken(null);
+ userRepository.save(user);
+ verificationTokenRepository.delete(t);
+ logger.info("User successfully verified and token deleted");
+ return ResponseEntity.ok().build();
+ })
+ .orElseGet(() -> {
+ logger.error("Token NOT found in database");
+ throw new IllegalArgumentException("Invalid verification token");
+ });
+ }
+
+ /**
+ * Backdoor for E2E tests to retrieve verification token.
+ * ONLY intended for use in Fargate deployment verification or CI environments.
+ */
+ @GetMapping("/test/verification-token")
+ public ResponseEntity getVerificationTokenForTest(@RequestParam String email) {
+ logger.info("Test-only token retrieval requested for email: {}", email);
+ return userRepository.findByEmail(email)
+ .flatMap(user -> verificationTokenRepository.findAll().stream()
+ .filter(t -> t.getUser().getId().equals(user.getId()))
+ .findFirst())
+ .map(token -> ResponseEntity.ok(token.getToken()))
+ .orElse(ResponseEntity.notFound().build());
+ }
+
+ @PostMapping("/resend-verification")
+ @Transactional
+ public ResponseEntity resendVerification(@RequestParam String email) {
+ return userRepository.findByEmail(email)
+ .map(user -> {
+ if (user.getStatus() != ch.goodone.angularai.backend.model.UserStatus.PENDING) {
+ throw new IllegalArgumentException("User is already active or invalid status");
+ }
+ // Clean up old tokens
+ verificationTokenRepository.deleteByUser(user);
+ verificationTokenRepository.flush();
+
+ VerificationToken newToken = new VerificationToken(user);
+ verificationTokenRepository.save(newToken);
+
+ emailService.sendVerificationEmail(user.getEmail(), newToken.getToken());
+ actionLogService.log(user.getLogin(), "USER_VERIFICATION_RESENT", "Verification email resent to " + email);
+
+ return ResponseEntity.ok("Verification email sent");
+ })
+ .orElseThrow(() -> new IllegalArgumentException("Email not found"));
+ }
+
+ @PostMapping("/forgot-password")
+ @Transactional
+ public ResponseEntity forgotPassword(@RequestBody Map payload, Locale locale) {
+ String email = payload.get("email");
+ logger.info("Password recovery requested");
+
+ if (email != null) {
+ userRepository.findByEmail(email).ifPresent(user -> {
+ // Clean up old tokens
+ passwordRecoveryTokenRepository.deleteByUser(user);
+ passwordRecoveryTokenRepository.flush();
+
+ PasswordRecoveryToken token = new PasswordRecoveryToken(user);
+ passwordRecoveryTokenRepository.save(token);
+
+ emailService.sendPasswordRecoveryEmail(user.getEmail(), token.getToken(), locale);
+ actionLogService.log(user.getLogin(), "USER_PASSWORD_RECOVERY_REQUESTED", "Password recovery token generated");
+ });
+ }
+
+ // Always return OK to prevent user enumeration
+ return ResponseEntity.ok().build();
+ }
+
+ @PostMapping("/reset-password")
+ @Transactional
+ public ResponseEntity resetPassword(@RequestBody Map