feat: Update CLAUDE.md files, add docs
This commit is contained in:
20
CLAUDE.md
20
CLAUDE.md
@@ -89,11 +89,18 @@ cd legalconsenthub && pnpm install && pnpm run dev
|
|||||||
# Backend (port 8080)
|
# Backend (port 8080)
|
||||||
cd legalconsenthub-backend && ./gradlew bootRun
|
cd legalconsenthub-backend && ./gradlew bootRun
|
||||||
|
|
||||||
# Generate API clients (after modifying api/legalconsenthub.yml)
|
# Generate API clients (REQUIRED after modifying api/legalconsenthub.yml)
|
||||||
pnpm run api:generate # Frontend client
|
cd legalconsenthub && pnpm run api:generate # Frontend TypeScript client
|
||||||
./gradlew generate_legalconsenthub_server # Backend server stubs
|
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
|
## Key Files
|
||||||
@@ -109,7 +116,12 @@ See subproject CLAUDE.md files for component-specific key files.
|
|||||||
|
|
||||||
## Rules for AI
|
## 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
|
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
|
3. **Form structure is 3-level** - Section → SubSection → Element
|
||||||
4. **Roles managed in Keycloak** - Not in application database
|
4. **Roles managed in Keycloak** - Not in application database
|
||||||
|
|||||||
321
docs/real-time-notifications-architecture.md
Normal file
321
docs/real-time-notifications-architecture.md
Normal file
@@ -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<SseConnectionContext>()
|
||||||
|
|
||||||
|
fun registerEmitter(userId: String, organizationId: String, userRoles: List<String>): 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)
|
||||||
@@ -80,6 +80,7 @@ The application automatically seeds initial data on first startup:
|
|||||||
- Verify all element types render correctly
|
- Verify all element types render correctly
|
||||||
- Ensure template sections excluded, spawned sections included
|
- Ensure template sections excluded, spawned sections included
|
||||||
- Hotspots: `ApplicationFormFormatService.kt`, `application_form_latex_template.tex`
|
- 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
|
### Architecture Guidelines
|
||||||
- **Service layer** - Business logic lives here
|
- **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
|
- **Mapper layer** - Separate mapper classes for DTO/Entity conversions
|
||||||
- **Entity layer** - JPA entities with relationships
|
- **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
|
### PDF Generation
|
||||||
- LaTeX templates in `src/main/resources/templates/`
|
- LaTeX templates in `src/main/resources/templates/`
|
||||||
- Thymeleaf for dynamic content
|
- Thymeleaf for dynamic content
|
||||||
|
|||||||
Reference in New Issue
Block a user