From 935525067e98159c0523889a0c5b49b08133d4ed Mon Sep 17 00:00:00 2001 From: Denis Lugowski Date: Sun, 8 Feb 2026 08:36:29 +0100 Subject: [PATCH] feat: Update CLAUDE.md files, add docs --- CLAUDE.md | 20 +- docs/real-time-notifications-architecture.md | 321 +++++++++++++++++++ legalconsenthub-backend/CLAUDE.md | 5 + 3 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 docs/real-time-notifications-architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index b032410..a1dc0b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,11 +89,18 @@ cd legalconsenthub && pnpm install && pnpm run dev # Backend (port 8080) cd legalconsenthub-backend && ./gradlew bootRun -# Generate API clients (after modifying api/legalconsenthub.yml) -pnpm run api:generate # Frontend client -./gradlew generate_legalconsenthub_server # Backend server stubs +# Generate API clients (REQUIRED after modifying api/legalconsenthub.yml) +cd legalconsenthub && pnpm run api:generate # Frontend TypeScript client +cd legalconsenthub-backend && ./gradlew generate_legalconsenthub_server # Backend Kotlin server stubs ``` +**⚠️ CRITICAL: API Client Regeneration** +After ANY change to `api/legalconsenthub.yml`, you MUST regenerate BOTH clients: +1. Frontend: `pnpm run api:generate` (TypeScript client) +2. Backend: `./gradlew generate_legalconsenthub_server` (Kotlin server stubs) + +Compilation/runtime will fail if clients are out of sync with the OpenAPI spec. + --- ## Key Files @@ -109,7 +116,12 @@ See subproject CLAUDE.md files for component-specific key files. ## Rules for AI -1. **API-first workflow** - Always modify OpenAPI spec first, then regenerate clients +1. **API-first workflow** - ALWAYS follow this sequence when modifying APIs: + a. Modify `api/legalconsenthub.yml` OpenAPI spec first + b. Regenerate frontend client: `cd legalconsenthub && pnpm run api:generate` + c. Regenerate backend stubs: `cd legalconsenthub-backend && ./gradlew generate_legalconsenthub_server` + d. Implement backend controller methods (they implement generated interfaces) + e. Use generated client in frontend (never write manual API calls) 2. **Organization context** - Always consider `organizationId` for multi-tenancy. Forms with empty/null organizationId are "global" forms visible to all organizations 3. **Form structure is 3-level** - Section → SubSection → Element 4. **Roles managed in Keycloak** - Not in application database diff --git a/docs/real-time-notifications-architecture.md b/docs/real-time-notifications-architecture.md new file mode 100644 index 0000000..6645323 --- /dev/null +++ b/docs/real-time-notifications-architecture.md @@ -0,0 +1,321 @@ +# Real-Time Notifications Architecture Decision + +**Status:** POSTPONED +**Created:** 2026-02-08 +**Last Updated:** 2026-02-08 + +--- + +## Context + +The Legal Consent Hub requires real-time notification delivery to users. When events occur (e.g., form submissions, approvals, comments), affected users should receive notifications immediately without refreshing the page. + +### Constraint: Stateless Backend + +The backend **must be stateless** for horizontal scaling. Multiple backend instances run behind a load balancer, and any instance must be able to handle any request. + +--- + +## Current Implementation + +**Location:** `src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/` + +### Files + +| File | Purpose | +|------|---------| +| `SseNotificationEmitterRegistry.kt` | In-memory registry of SSE connections | +| `NotificationEventListener.kt` | Listens for `NotificationCreatedEvent` and triggers SSE delivery | +| `NotificationCreatedEvent.kt` | Spring application event for new notifications | +| `NotificationService.kt` | Creates notifications and publishes events | +| `NotificationController.kt` | REST endpoints including SSE stream endpoint | + +### How It Works + +1. Frontend opens SSE connection to `/api/notifications/stream` +2. Backend stores `SseEmitter` in `SseNotificationEmitterRegistry` (in-memory `CopyOnWriteArrayList`) +3. When notification created, `NotificationService` publishes `NotificationCreatedEvent` +4. `NotificationEventListener` receives event, uses registry to send SSE to matching users +5. Registry filters by `userId`, `organizationId`, and `roles` to route notifications + +### Current Code (SseNotificationEmitterRegistry.kt) + +```kotlin +@Service +class SseNotificationEmitterRegistry { + // IN-MEMORY STORAGE - THIS IS THE PROBLEM + private val connections = CopyOnWriteArrayList() + + fun registerEmitter(userId: String, organizationId: String, userRoles: List): SseEmitter { + val emitter = SseEmitter(TIMEOUT_MS) + val context = SseConnectionContext(emitter, userId, organizationId, userRoles) + connections.add(context) + // ... cleanup handlers + return emitter + } + + fun sendToUser(userId: String, organizationId: String, event: SseEmitter.SseEventBuilder) { + connections.filter { it.userId == userId && it.organizationId == organizationId } + .forEach { it.emitter.send(event) } + } + + // Also: sendToRoles(), sendToOrganization() +} +``` + +--- + +## The Problem + +**In-memory storage breaks with multiple instances.** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Load Balancer │ +└─────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Instance A │ │ Instance B │ +│ │ │ │ +│ connections = [ │ │ connections = [ │ +│ User1-Emitter, │ │ User2-Emitter, │ +│ User3-Emitter │ │ User4-Emitter │ +│ ] │ │ ] │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +**Scenario:** +1. User1 connects via SSE → routed to Instance A → emitter stored in A's memory +2. User2 connects via SSE → routed to Instance B → emitter stored in B's memory +3. User1 creates notification for User2 +4. Instance A handles the request, tries to send SSE to User2 +5. **FAILURE:** User2's emitter is in Instance B, not Instance A +6. User2 never receives the notification + +--- + +## Options + +### Option 1: Redis Pub/Sub + +**How it works:** +- When notification created, publish event to Redis channel +- All backend instances subscribe to this channel +- Each instance forwards matching events to its local SSE connections + +``` +┌──────────────┐ publish ┌─────────────┐ +│ Instance A │ ───────────────► │ Redis │ +│ (creates │ │ Pub/Sub │ +│ notification) └─────────────┘ +└──────────────┘ │ + │ subscribe (all instances) + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │Instance A│ │Instance B│ │Instance C│ + │(send to │ │(send to │ │(send to │ + │local SSE)│ │local SSE)│ │local SSE)│ + └──────────┘ └──────────┘ └──────────┘ +``` + +| Pros | Cons | +|------|------| +| Low latency (~1-5ms) | Requires Redis infrastructure | +| Battle-tested pattern | Additional operational complexity | +| Spring has good support (`spring-boot-starter-data-redis`) | Another service to monitor/maintain | +| Can reuse Redis for caching later | | + +**Implementation effort:** ~2-4 hours + +**Dependencies to add:** +```kotlin +implementation("org.springframework.boot:spring-boot-starter-data-redis") +``` + +--- + +### Option 2: PostgreSQL LISTEN/NOTIFY + +**How it works:** +- Use Postgres native pub/sub mechanism +- On notification insert, trigger sends NOTIFY +- All backend instances LISTEN on the channel +- Each instance forwards to its local SSE connections + +```sql +-- Trigger function +CREATE FUNCTION notify_new_notification() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify('new_notification', row_to_json(NEW)::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger on insert +CREATE TRIGGER notification_insert_trigger +AFTER INSERT ON notifications +FOR EACH ROW EXECUTE FUNCTION notify_new_notification(); +``` + +| Pros | Cons | +|------|------| +| No new infrastructure (already have Postgres) | Slightly higher latency (~5-20ms) | +| Simple conceptually | Requires manual JDBC listener setup | +| Database is source of truth | Less common pattern, fewer examples | +| Works even if notification created by SQL directly | Connection pool considerations | + +**Implementation effort:** ~3-5 hours + +**No additional dependencies** (uses existing JDBC driver) + +--- + +### Option 3: Message Queue (RabbitMQ / Kafka) + +**How it works:** +- Publish notification events to queue/topic +- All instances consume from the queue +- Each instance forwards to local SSE connections + +| Pros | Cons | +|------|------| +| Very reliable, durable messages | Significant new infrastructure | +| Can handle high throughput | Overkill for notification use case | +| Good for future event-driven architecture | Higher operational complexity | +| Supports complex routing | Longer setup time | + +**Implementation effort:** ~4-8 hours + +**Dependencies:** +```kotlin +// RabbitMQ +implementation("org.springframework.boot:spring-boot-starter-amqp") + +// OR Kafka +implementation("org.springframework.kafka:spring-kafka") +``` + +--- + +### Option 4: Client-Side Polling (Remove SSE) + +**How it works:** +- Remove SSE entirely +- Frontend polls `/api/notifications?since={timestamp}` every N seconds +- Completely stateless, no server-side connection tracking + +```typescript +// Frontend polling +setInterval(async () => { + const notifications = await fetch(`/api/notifications?since=${lastCheck}`) + lastCheck = Date.now() + // Update UI with new notifications +}, 10000) // Poll every 10 seconds +``` + +| Pros | Cons | +|------|------| +| Simplest solution | Not real-time (10-30s delay) | +| Truly stateless | Higher database load | +| No new infrastructure | More API requests | +| Easy to implement | Worse UX for time-sensitive notifications | +| Works through all proxies/firewalls | | + +**Implementation effort:** ~1-2 hours (mostly removing SSE code) + +--- + +### Option 5: WebSocket with External STOMP Broker + +**How it works:** +- Use Spring WebSocket with STOMP protocol +- External message broker (RabbitMQ/ActiveMQ) handles message routing +- Clients subscribe to user-specific topics + +| Pros | Cons | +|------|------| +| Full-duplex communication | Requires message broker | +| Rich protocol (STOMP) | More complex than SSE | +| Spring has excellent support | WebSocket connection management | +| Can do request-response patterns | Some proxies don't support WebSocket well | + +**Implementation effort:** ~4-6 hours + +--- + +## Comparison Matrix + +| Criteria | Redis Pub/Sub | Postgres NOTIFY | Message Queue | Polling | WebSocket+STOMP | +|----------|---------------|-----------------|---------------|---------|-----------------| +| Latency | ~1-5ms | ~5-20ms | ~5-50ms | 10-30s | ~1-5ms | +| New Infrastructure | Redis | None | RabbitMQ/Kafka | None | RabbitMQ/ActiveMQ | +| Implementation Effort | Low | Medium | High | Very Low | Medium | +| Operational Complexity | Low | Low | High | None | Medium | +| Scalability | Excellent | Good | Excellent | Limited | Excellent | +| Spring Support | Excellent | Manual | Excellent | N/A | Excellent | + +--- + +## Recommendation + +**For this project, the top candidates are:** + +1. **PostgreSQL LISTEN/NOTIFY** - Already have Postgres, no new infra, acceptable latency +2. **Redis Pub/Sub** - If Redis is added for caching anyway, use it for this too +3. **Polling** - If real-time isn't critical, simplest solution + +**Avoid:** Message Queue (overkill), WebSocket+STOMP (unnecessary complexity) + +--- + +## Why Postponed + +1. **Current state works for single instance** - Development and testing can proceed +2. **No immediate production multi-instance deployment** - Decision can wait +3. **Infrastructure decisions pending** - Unknown if Redis will be used for other purposes +4. **Need to evaluate real-time requirements** - How critical is sub-second notification delivery? + +--- + +## Questions to Answer Before Deciding + +1. Will Redis be used for caching or session storage? +2. How critical is real-time delivery? (Is 10-30s polling acceptable?) +3. What is the expected notification volume? +4. What is the deployment target? (Kubernetes, Docker Swarm, VMs?) + +--- + +## How to Resume This Work + +When ready to implement multi-instance support: + +1. Answer the questions above +2. Choose an option based on infrastructure and requirements +3. Implementation steps: + - Keep `SseNotificationEmitterRegistry` for local emitter management + - Add cross-instance event distribution layer + - Modify `NotificationEventListener` to publish to chosen channel + - Add subscriber that receives events and calls registry's send methods + +**Key insight:** The local SSE connection management stays the same. Only the event distribution mechanism changes. + +--- + +## Related Files + +- `SseNotificationEmitterRegistry.kt` - Current in-memory implementation +- `NotificationEventListener.kt` - Event handling logic +- `NotificationCreatedEvent.kt` - Event payload +- `NotificationService.kt` - Notification creation +- `NotificationController.kt` - SSE endpoint + +--- + +## References + +- [Spring SSE Documentation](https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html#mvc-ann-async-sse) +- [Redis Pub/Sub with Spring](https://docs.spring.io/spring-data/redis/reference/redis/pubsub.html) +- [PostgreSQL LISTEN/NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) diff --git a/legalconsenthub-backend/CLAUDE.md b/legalconsenthub-backend/CLAUDE.md index 4865712..89ad05c 100644 --- a/legalconsenthub-backend/CLAUDE.md +++ b/legalconsenthub-backend/CLAUDE.md @@ -80,6 +80,7 @@ The application automatically seeds initial data on first startup: - Verify all element types render correctly - Ensure template sections excluded, spawned sections included - Hotspots: `ApplicationFormFormatService.kt`, `application_form_latex_template.tex` +4. **Stateless** - Any implementation must support statelessness for horizontal scaling (multiple instances behind load balancer) ### Architecture Guidelines - **Service layer** - Business logic lives here @@ -88,6 +89,10 @@ The application automatically seeds initial data on first startup: - **Mapper layer** - Separate mapper classes for DTO/Entity conversions - **Entity layer** - JPA entities with relationships +### Code Quality Rules +- **Never use @Suppress annotations** - Fix the underlying issue instead of suppressing warnings +- **Controller methods must override generated interfaces** - All public endpoints must be in OpenAPI spec + ### PDF Generation - LaTeX templates in `src/main/resources/templates/` - Thymeleaf for dynamic content