Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Root developer entrypoint. Wraps the per-service tooling (Gradle, pnpm, pytest, docker
# compose) that already exists in each subdirectory - see docs/cicd.md for what CI itself runs.
#
# Unlike CI, these targets don't pass --no-daemon to Gradle: locally you want the daemon kept
# warm between runs, you just don't want a stale one in an ephemeral CI container.
.DEFAULT_GOAL := help

SPRING_SERVICES := organization member event finance feedback letter

.PHONY: help
help: ## Show this help message
@echo "Usage: make <target>"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'

## --- Docker Compose lifecycle -----------------------------------------------------------
## Always cd into infra/ first so docker-compose.override.yml auto-merges (matching README's
## "Running Locally" section) - never pass -f infra/docker-compose.yml directly, that skips it.

.PHONY: up
up: ## Start the local stack (docker compose up -d --build)
cd infra && docker compose up -d --build

.PHONY: down
down: ## Stop the local stack, keep the database volume
cd infra && docker compose down

.PHONY: down-v
down-v: ## Stop the local stack and delete all volumes (wipes the database)
cd infra && docker compose down -v

.PHONY: logs
logs: ## Follow logs for all running containers
cd infra && docker compose logs -f

.PHONY: ps
ps: ## List running containers and their status
cd infra && docker compose ps

## --- Test / Lint / Build ----------------------------------------------------------------
## No per-service targets on purpose - these always run against everything.

.PHONY: test
test: ## Run every test suite (Spring x6, GenAI, web-client)
@for s in $(SPRING_SERVICES); do \
echo "==> test: spring-$$s"; \
(cd services/spring-$$s && ./gradlew test) || exit 1; \
done
@echo "==> test: py-genai-helper"
cd services/py-genai-helper && pytest -q
@echo "==> test: web-client"
cd web-client && pnpm test:coverage

.PHONY: lint
lint: ## Run every linter (Checkstyle, ruff, ESLint + typecheck)
@for s in $(SPRING_SERVICES); do \
echo "==> lint: spring-$$s"; \
(cd services/spring-$$s && ./gradlew checkstyleMain) || exit 1; \
done
@echo "==> lint: py-genai-helper"
cd services/py-genai-helper && ruff check .
@echo "==> lint: web-client"
cd web-client && pnpm typecheck && pnpm lint

.PHONY: build
build: ## Build every service (Spring x6 + web-client)
@for s in $(SPRING_SERVICES); do \
echo "==> build: spring-$$s"; \
(cd services/spring-$$s && ./gradlew build) || exit 1; \
done
@echo "==> build: web-client"
cd web-client && pnpm build

.PHONY: verify
verify: lint test build ## Run lint, test, and build for everything - mirrors what CI checks per PR
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ All five schema-owning Spring services share a single **PostgreSQL 15** instance
| Feedback | `feedback` | `feedback_user` |
| Finance | `finance` | `finance_user` |

Schemas and users are created at DB init time by [`infra/postgres/init-db.sh`](infra/postgres/init-db.sh). Each service runs its own **Flyway** migrations on startup (`V1__create_tables.sql`, `V2__add_foreign_keys.sql` for cross-schema references, granted via `ALTER DEFAULT PRIVILEGES`). The letter service has no database; the GenAI service persists RAG documents in a Chroma vector store instead of PostgreSQL.
Schemas and users are created at DB init time by [`infra/postgres/init-db.sh`](infra/postgres/init-db.sh). Each service runs its own **Flyway** migrations on startup (`V1__create_tables.sql`, `V2__add_foreign_keys.sql` for cross-schema references, granted via `ALTER DEFAULT PRIVILEGES`). The letter service has no database of its own.

The GenAI service also uses this same Postgres instance: it owns a sixth schema, `reports` (`reports_user`), where generated member/team report text is persisted (created idempotently at startup — Python has no Flyway). Separately, it persists RAG documents in a Chroma vector store, not PostgreSQL.

## Authentication (Keycloak)

Expand All @@ -114,6 +116,18 @@ pre-commit install --hook-type pre-push

Auto-fixing hooks modify files and abort the commit so you can re-stage. Bypass only in emergencies (`git commit --no-verify` / `git push --no-verify`) — CI still gates. Full hook config: [`.pre-commit-config.yaml`](.pre-commit-config.yaml).

The root [`Makefile`](Makefile) wraps the per-service tooling (Gradle, pnpm, pytest, docker compose) so you don't need to remember each one's exact invocation or working directory. Run `make help` for the full list:

```bash
make up # start the local stack (see Running Locally below)
make test # run every test suite (Spring x6, GenAI, web-client)
make lint # run every linter (Checkstyle, ruff, ESLint + typecheck)
make build # build every service (Spring x6 + web-client)
make verify # lint + test + build for everything — mirrors what CI checks per PR
```

`test`, `lint`, and `build` always run against everything — there are no per-service targets, so a full check is always exactly one command.

## Running Locally

```bash
Expand All @@ -122,6 +136,8 @@ cp .env.example .env # first time only — local-dev secrets, gitignored
docker compose up -d --build
```

Equivalent shortcut from the repo root: `make up` (also `make down`, `make down-v`, `make logs`, `make ps`) — see [Developer Setup](#developer-setup) above. Both do exactly the same thing: the Makefile just `cd`s into `infra/` first, same as the manual steps.

This auto-merges [`infra/docker-compose.override.yml`](infra/docker-compose.override.yml), which strips TLS/Let's-Encrypt/Host-routing so everything is reachable on plain HTTP:

| URL | Service |
Expand Down
25 changes: 22 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Architecture

This is the detailed companion to the [README's Architecture section](../README.md#architecture): per-service responsibilities, who calls whom, and how a request is authenticated end to end. There is deliberately no UML diagram here — see [`docs/outdated/`](outdated/) for why the previous ones were retired.
This is the detailed companion to the [README's Architecture section](../README.md#architecture): per-service responsibilities, who calls whom, and how a request is authenticated end to end. The diagrams below reflect the current six-service system; the PNGs under [`docs/outdated/`](outdated/) model an earlier three-service topology and are kept only as historical record. Diagram sources live in [`docs/diagrams/`](diagrams/) as Mermaid files — see [`docs/diagrams/README.md`](diagrams/README.md) to regenerate them after an architecture change.

## Subsystem Decomposition

Depicts the Kubernetes (Rancher/RKE2) deployment specifically, since it's the one continuously-live, gradeable cluster target — see [Environment differences](#environment-differences) below for how Compose/VM differ. `oauth2-proxy` only gates the web-client's cookie session; every backend service, Keycloak, api-docs, and Grafana are reached directly through the ingress and each does its own auth (Bearer-JWT check, or in Grafana's case its own separate Keycloak OAuth client) — there is no single shared gatekeeper. Note also the two independent PostgreSQL instances: `app-db` (schema-per-service, described below) and a separate `keycloak-db` that only Keycloak uses.

![Subsystem decomposition diagram](diagrams/subsystem-decomposition.png)

## Subsystems

Expand Down Expand Up @@ -31,18 +37,31 @@ A Python 3.12 / Flask service using LangChain. Unlike the Spring services, it is

- It calls **feedback-service** (`GET /feedback`, Bearer-forwarded) to pull the data it summarizes when generating a member/team report.
- It calls **either OpenAI or a local Ollama instance** to run inference, selected per-request via the `uselocal` field on the report-generation endpoints (not a global config flag) — the web client exposes this as the "Use local LLM" toggle on the helper page.
- RAG question-answering persists uploaded documents in a **Chroma** vector store (a PVC-backed directory in Kubernetes, a bind-mounted volume elsewhere) rather than PostgreSQL.
- RAG question-answering persists uploaded documents in a **Chroma** vector store (a PVC-backed directory in Kubernetes, a bind-mounted volume elsewhere), not PostgreSQL.
- Generated report text *is* persisted in PostgreSQL, though: the service owns a sixth schema, `reports` (`reports_user`), in the same instance as the Spring services, with its tables created idempotently at startup since Python has no Flyway.

### Database — PostgreSQL

Single instance, schema-per-service, documented in the [README's Database section](../README.md#database). No service reads another service's schema directly; cross-schema foreign keys exist (e.g. `event.events.creator_id → member.members.id`) but are enforced by the database, not queried across services.
Single instance, schema-per-service, documented in the [README's Database section](../README.md#database) — five schemas for the five Spring services that own one, plus the `reports` schema owned by the GenAI service (see above). Every service user is also granted a shared, read-only `reader` role (`infra/postgres/init-db.sh`) that can `SELECT` across all schemas but never write outside its own; this backs a handful of small, explicitly-documented read-only lookups — e.g. `event-service`'s `MemberEntity`, `letter-service`'s `TransactionEntity`, and the GenAI service's own member/team display-name lookups — used only to resolve a name or balance for display, never to write, and never as a substitute for calling the owning service's API. Cross-schema foreign keys (e.g. `event.events.creator_id → member.members.id`) are enforced by the database independently of this.

### Proxy & Auth — Traefik / nginx ingress + Keycloak

- **Docker Compose / Azure VM**: Traefik terminates TLS (Let's Encrypt on the VM), applies a `forward-auth` middleware backed by its own confidential Keycloak client (session-cookie based, separate from the app-level Bearer tokens below), and strips path prefixes before forwarding to each service.
- **Kubernetes**: the cluster's own nginx ingress does the prefix-stripping and routing instead of Traefik; TLS is handled at the cluster edge.
- **Keycloak** (realm `devops`) is the single OIDC provider in every environment. Four confidential/public clients exist: `devops-client` (public, PKCE — the React app), `traefik-forward-auth` (confidential — gates the browser session at the proxy), `grafana` (confidential — Grafana's own admin-only OAuth login), and `org-role-sync` (confidential, service account only, no browser flow — organization-service and member-service's Admin REST API client, see above). None of the three confidential clients' secrets is hardcoded: each is templated as a `__PLACEHOLDER__` in `infra/keycloak/realm-config.json`, substituted at container start (Compose/VM) or chart render (Helm) from the matching GitHub secret — see [docs/cicd.md](cicd.md).

## Use Cases

Route access is role-gated client-side (`web-client/src/app/navPolicy.ts`) and re-checked server-side by each service. Four roles exist: `member` (trainee), `trainer` (coach), `director`, `admin`. All four can additionally view the dashboard, browse events & sessions, view sports & teams, and manage their own profile — those baseline pages are omitted from the diagram below since every role shares them; only the role-differentiating capabilities are shown. Admin's capabilities are exactly the union of Coach's and Director's, with nothing unique of its own, so it's shown as generalizing both (`«is-a»`) rather than duplicating every edge.

![Use case diagram](diagrams/use-case.png)

## Analysis Object Model

Modeled from the client's perspective — its `Role` type (`web-client/src/types.ts`) and each feature's role-gated actions — rather than the server's join-table implementation (`DirectorEntity`/`TrainerEntity`/`TraineeEntity`, each scoped to one sport or team). A person's role is a single flat attribute from the client's standpoint, not a per-scope assignment, so `Trainee`/`Coach`/`Director`/`Admin` are modeled as specializations of `Member` to group each role's distinct capabilities clearly. The two relationships that genuinely are per-scope — a coach coaches specific teams, a director directs specific sports — are kept as separate associations rather than folded into the role itself. Feedback, balances, letters, event attendance, and generated reports are all about a `Trainee` specifically (coaches and directors act on them, but are never themselves the subject), so those associations sit on `Trainee` rather than the abstract `Member` — matching the client's own `ReportKind`-discriminated `Report` type, member and team reports are modeled as one `Report` class rather than two near-identical ones.

![Analysis object model diagram](diagrams/analysis-object-model.png)

## Request lifecycle (example: loading the members page)

1. Browser requests `/` → proxy's forward-auth middleware checks for a session cookie; none yet → redirected through Keycloak's login page → cookie set on success.
Expand Down
39 changes: 39 additions & 0 deletions docs/diagrams/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Diagrams

Mermaid sources for the diagrams embedded in [`docs/architecture.md`](../architecture.md). Each `.mmd` file has a matching `.png` rendered from it — edit the `.mmd`, then regenerate the PNG, never the other way around.

| Source | Rendered | Diagram |
|---|---|---|
| `subsystem-decomposition.mmd` | `subsystem-decomposition.png` | Subsystem Decomposition |
| `use-case.mmd` | `use-case.png` | Use Case |
| `analysis-object-model.mmd` | `analysis-object-model.png` | Analysis Object Model |

## Regenerating

```bash
cd docs/diagrams
npx -y @mermaid-js/mermaid-cli -i <name>.mmd -o <name>.png -b white -w 1400 -s 2
```

Requires Node/npx (no global install needed — `npx -y` fetches `@mermaid-js/mermaid-cli` on demand and runs it headlessly via Puppeteer).

## Manual label fix in `analysis-object-model.png`

Mermaid's `classDiagram` layout has no `linkStyle`/spacing controls (unlike flowchart), so the auto-placed `belongs to` label originally overlapped the `Coach` box. This was fixed by hand-shifting that one label in the rendered SVG rather than in the `.mmd` — running the command above regenerates a technically-correct but visually-regressed PNG (label back on top of `Coach`). To reproduce the fix after a content change:

```bash
cd docs/diagrams
npx -y @mermaid-js/mermaid-cli -i analysis-object-model.mmd -o /tmp/aom.svg -b white
python3 -c "
svg = open('/tmp/aom.svg').read()
old = '<g class=\"edgeLabel\" transform=\"translate(861.98522, 520.13373)\"><g class=\"label\" data-id=\"id_Trainee_Team_8\"'
new = '<g class=\"edgeLabel\" transform=\"translate(861.98522, 600.13373)\"><g class=\"label\" data-id=\"id_Trainee_Team_8\"'
assert old in svg
open('/tmp/aom.svg', 'w').write(svg.replace(old, new))
"
chromium-browser --headless --disable-gpu --no-sandbox --screenshot=analysis-object-model.png \
--window-size=3000,2600 --force-device-scale-factor=2 --default-background-color=FFFFFFFF /tmp/aom.svg
magick analysis-object-model.png -trim +repage analysis-object-model.png
```

The exact `translate(...)` coordinates are only valid for the current diagram content — if classes/associations change, re-render the plain SVG first and find the new `belongs to` label's transform before adjusting. ImageMagick's own SVG rasterizer (Inkscape delegate) doesn't render Mermaid's HTML `foreignObject` labels — text disappears — so the screenshot step must go through an actual browser (`chromium-browser --headless`), not `magick`/`convert` directly.
102 changes: 102 additions & 0 deletions docs/diagrams/analysis-object-model.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
classDiagram
direction LR
class Member {
<<abstract>>
+UUID id
+String firstName
+String lastName
+String email
+String phoneNumber
+String address
+Date birthday
+Date joiningDate
+String information
+Role role
+updateProfile()
}
class Trainee {
+requestAIReport()
}
class Coach {
+createEvent()
+submitFeedback()
+manageMembers()
+generateLetter()
+requestAIReport()
}
class Director {
+manageTeamRoster()
+manageBalances()
+manageMembers()
+generateLetter()
+createEvent()
}
class Admin {
+manageSportsAndTeams()
+manageRoles()
}

Member <|-- Trainee
Member <|-- Coach
Member <|-- Director
Member <|-- Admin

class Sport {
+UUID id
+String name
+String description
}
class Team {
+UUID id
+String name
+String description
+String address
}
class Event {
+UUID id
+String name
+String description
+Instant startTime
+Instant endTime
+createEvent()
}
class Feedback {
+UUID id
+Integer rating
+String feedback
+Instant createdAt
+submitFeedback()
}
class Transaction {
+UUID id
+int amountCents
+String title
+String description
+Instant createdAt
+recordTransaction()
}
class Letter {
+String subject
+String content
+generatePdf()
+sendMail()
}
class Report {
+UUID id
+String kind
+String text
+Instant createdAt
}

Director "*" --> "*" Sport : directs
Sport "1" --> "*" Team : offers
Coach "*" --> "*" Team : coaches
Trainee "*" --> "*" Team : belongs to
Team "1" --> "*" Report : subject of
Coach "1" --> "*" Feedback : writes
Event "1" --> "*" Feedback : about
Trainee "1" --> "*" Feedback : receives
Event "*" --> "*" Trainee : attendance
Trainee "1" --> "*" Transaction : owns
Trainee "1" --> "*" Letter : recipient of
Trainee "1" --> "*" Report : subject of
Binary file added docs/diagrams/analysis-object-model.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions docs/diagrams/subsystem-decomposition.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
flowchart TB
ING["nginx Ingress · TLS via cert-manager<br/>ge83mom-devops26.stud.k8s.aet.cit.tum.de"]
OAP["oauth2-proxy<br/>cookie session gate"]
WC["web-client"]
DOCS["api-docs (swagger-ui)<br/>open, no auth"]

subgraph Backend["Backend services — own Bearer-JWT check, HPA 1-2 replicas"]
ORG["organization-service"]
MEM["member-service"]
EVT["event-service"]
FIN["finance-service"]
FBK["feedback-service"]
LET["letter-service"]
GAI["py-genai-helper<br/>single replica — RWO PVC"]
end

subgraph Auth["Auth"]
KC["Keycloak<br/>realm: devops"]
KCDB[("keycloak-db")]
end

APPDB[("app-db<br/>schema per service + reports")]

subgraph Inference["Inference"]
OLL["Ollama"]
OAI["OpenAI API"]
VEC[("Chroma PVC")]
end

subgraph Observability["Observability — same namespace"]
PROM["Prometheus"]
GRAF["Grafana<br/>own Keycloak OAuth"]
LOKI["Loki"]
ALLOY["Alloy"]
end

ING -->|"/"| OAP --> WC
ING -->|"/api/v1/…"| Backend
ING -->|"/auth"| KC
ING -->|"/docs"| DOCS
ING -->|"/dashboard"| GRAF

Backend --> APPDB
GAI --> OLL
GAI --> OAI
GAI --> VEC
KC --> KCDB

ALLOY --> LOKI
PROM -.->|scrapes| Backend
PROM -.->|scrapes| KC
GRAF --> PROM
GRAF --> LOKI
Binary file added docs/diagrams/subsystem-decomposition.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading