diff --git a/.gitignore b/.gitignore index 63fd2d6..7009b49 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,21 @@ build/ ### VS Code ### .vscode/ -/eegportal.lock.db -/eeg_backend/eegportal.mv.db -/eegportal.mv.db -/eegportal.trace.db + +### Frontend ### +eeg_frontend/node/ +node_modules/ +eeg_frontend/.angular/ + +### H2 Datenbank ### +*.db +*.lock.db + +### Test-Dateien ### +test_*.json + +### Projekt-spezifisch ### /projekt_kontext.txt +.mimocode/ +run_test.ps1 +server_output.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..52fca7f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md - EEG Portal + +## Projektübersicht + +**EEG Portal** - Portal zur Verwaltung von Energiegemeinschaften in Österreich. +Behandelt: Benutzerregistrierung, Messpunkt-Consent-Workflows (MaKo), EDA-Topologieprüfungen, +Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife. + +## Architektur + +**Modularer Monolith** - Designed für spätere Domänen-Extraktion als Maven-Module. + +### Backend (Spring Boot) +- **Java 21**, Spring Boot 4.0.6 +- **Pakete:** + - `iam/` - Identitäts- und Zugriffsverwaltung (Auth, Users, Security) + - `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte + - `mako/` - EDA-Kommunikation (Consent, Topologie) + - `tariff/` - Tarifverwaltung + - `dashboard/` - Dashboard-Statistiken + - `common/` - Events, Exceptions, gemeinsame Types + +### Frontend (Angular) +- Angular 21, TypeScript 5.9, Tailwind CSS 4 +- API-Client wird aus OpenAPI-Spec generiert (`npm run generate-api`) + +## Wichtige Patterns + +### Event-basierte Kommunikation +Domänen kommunizieren über Spring Application Events: +``` +UserRegisteredEvent → UserApprovedEvent → InitiateMakoConsentEvent +EdaTopologyCheckEvent → EdaTopologyCompletedEvent / EdaTopologyFailedEvent +MakoConsensApprovedEvent / MakoConsensRejectedEvent / MakoConsensTechnicalFailedEvent +``` + +### MaKo-Zustandsautomat (MeteringPoint.fireTrigger()) +``` +NEW → WAITING_FOR_CONSENT → CONSENT_GRANTED → ACTIVE + ↓ + REJECTED / ERROR +``` + +### Sicherheit +- JWT-basiert (HS256), 2h Ablauf +- Rollen: `ADMIN`, `MEMBER` +- Admin-Endpunkte: `/api/admin/**`, `/api/community/admin/**` + +## Entwicklung starten + +```bash +# Backend +.\mvnw spring-boot:run -pl eeg_backend -Dspring-boot.run.arguments=--spring.profiles.active=dev + +# Frontend +cd eeg_frontend && ng serve +``` + +## Wichtige Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `eeg_backend/.../community/domain/MeteringPoint.java:fireTrigger()` | MaKo-Zustandsautomat | +| `eeg_backend/.../community/event/listener/MakoConsentListener.java` | Consent-Event-Handler | +| `eeg_backend/.../mako/event/listener/EdaCommuncationEventListener.java` | EDA-Kommunikation | +| `eeg_backend/.../iam/config/AdminDataInitializer.java` | Initialer Admin-User (dev) | +| `eeg_frontend/openapi.yaml` | API-Vertrag für Frontend-Generierung | + +## Regeln + +- Fehlernachrichten sind auf **Deutsch** +- `@Profile("dev")` für simulierte EDA-Services +- EDA-Simulation: AT-Nummer endet mit "REJECT" → Ablehnung, "ERROR" → technischer Fehler +- Explizite `isEnabled()`-Überschreibung in `User.java` nötig (Lombok/Spring Security Konflikt) diff --git a/eeg_backend/pom.xml b/eeg_backend/pom.xml index fd1106a..1d84c8e 100644 --- a/eeg_backend/pom.xml +++ b/eeg_backend/pom.xml @@ -120,6 +120,7 @@ org.projectlombok lombok + 1.18.38 org.projectlombok @@ -150,10 +151,12 @@ org.springframework.boot spring-boot-configuration-processor + 4.0.6 org.projectlombok lombok + 1.18.38 @@ -169,6 +172,7 @@ org.projectlombok lombok + 1.18.38 diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MakoConsensListener.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MakoConsensListener.java new file mode 100644 index 0000000..9aa1981 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MakoConsensListener.java @@ -0,0 +1,82 @@ +package at.mueller.eeg.backend.community.event.listener; + +import at.mueller.eeg.backend.common.event.EdaTopologyCheckEvent; +import at.mueller.eeg.backend.common.event.MakoConsensApprovedEvent; +import at.mueller.eeg.backend.common.event.MakoConsensRejectedEvent; +import at.mueller.eeg.backend.common.event.MakoConsensTechnicalFailedEvent; +import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.domain.state.MakoTrigger; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MakoConsensListener { + + private final MeteringPointRepository meteringPointRepository; + private final ApplicationEventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMakoConsensApproved(MakoConsensApprovedEvent event) { + log.info("MaKo-Consent erteilt für AT-Nummer: {}", event.atNumber()); + + meteringPointRepository.findByAtNumber(event.atNumber()).ifPresentOrElse(point -> { + try { + point.fireTrigger(MakoTrigger.CONSENT_APPROVED); + meteringPointRepository.save(point); + + log.info("Zählpunkt {} ist nun im Status: CONSENT_GRANTED. Starte Topologie-Check...", + event.atNumber()); + + eventPublisher.publishEvent(new EdaTopologyCheckEvent( + point.getUserId(), + point.getId(), + point.getAtNumber() + )); + + } catch (IllegalStateException e) { + log.error("Zustandsübergang fehlgeschlagen für AT-Nummer {}: {}", event.atNumber(), e.getMessage()); + } + }, () -> log.error("Zählpunkt mit AT-Nummer {} nicht gefunden.", event.atNumber())); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMakoConsensRejected(MakoConsensRejectedEvent event) { + log.info("MaKo-Consent abgelehnt für AT-Nummer: {}. Grund: {}", event.atNumber(), event.reason()); + + meteringPointRepository.findByAtNumber(event.atNumber()).ifPresentOrElse(point -> { + try { + point.fireTrigger(MakoTrigger.CONSENT_DENIED); + meteringPointRepository.save(point); + + log.info("Zählpunkt {} ist nun im Status: REJECTED", event.atNumber()); + + } catch (IllegalStateException e) { + log.error("Zustandsübergang fehlgeschlagen für AT-Nummer {}: {}", event.atNumber(), e.getMessage()); + } + }, () -> log.error("Zählpunkt mit AT-Nummer {} nicht gefunden.", event.atNumber())); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleMakoConsensTechnicalFailed(MakoConsensTechnicalFailedEvent event) { + log.warn("MaKo-Consent technischer Fehler für AT-Nummer: {}. Grund: {}", event.atNumber(), event.reason()); + + meteringPointRepository.findByAtNumber(event.atNumber()).ifPresentOrElse(point -> { + try { + point.fireTrigger(MakoTrigger.TECHNICAL_FAILURE); + meteringPointRepository.save(point); + + log.info("Zählpunkt {} ist nun im Status: ERROR", event.atNumber()); + + } catch (IllegalStateException e) { + log.error("Zustandsübergang fehlgeschlagen für AT-Nummer {}: {}", event.atNumber(), e.getMessage()); + } + }, () -> log.error("Zählpunkt mit AT-Nummer {} nicht gefunden.", event.atNumber())); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java index 237d63f..496e754 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java @@ -4,6 +4,7 @@ import at.mueller.eeg.backend.community.domain.MeteringPoint; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface MeteringPointRepository extends JpaRepository { @@ -12,5 +13,7 @@ public interface MeteringPointRepository extends JpaRepository findByAtNumber(String atNumber); + void deleteByUserId(UUID userId); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/AdminDataInitializer.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/AdminDataInitializer.java new file mode 100644 index 0000000..bd63b95 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/AdminDataInitializer.java @@ -0,0 +1,115 @@ +package at.mueller.eeg.backend.iam.config; + +import at.mueller.eeg.backend.iam.domain.ParticipantType; +import at.mueller.eeg.backend.iam.domain.RegistrationStatus; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.domain.UserRole; +import at.mueller.eeg.backend.iam.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +@Profile("dev") +@RequiredArgsConstructor +@Slf4j +public class AdminDataInitializer implements CommandLineRunner { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + private static final String ADMIN_EMAIL = "admin@eeg-portal.at"; + private static final String ADMIN_FIRST_NAME = "Admin"; + private static final String ADMIN_LAST_NAME = "Administrator"; + + @Override + public void run(String... args) { + userRepository.findByEmail(ADMIN_EMAIL).ifPresentOrElse( + user -> log.info("Admin-User {} existiert bereits.", ADMIN_EMAIL), + () -> { + String generatedPassword = generateRandomPassword(); + createAdminUser(generatedPassword); + createTestUser(); + System.out.println(""" + + ======================================================================== + INITIALER ADMIN-BENUTZER WURDE ERSTELLT + ======================================================================== + E-Mail: admin@eeg-portal.at + Passwort: %s + + TEST-USER: + E-Mail: test@example.com + Passwort: Test1234! + ======================================================================== + """.formatted(generatedPassword)); + } + ); + } + + private void createAdminUser(String password) { + User admin = new User(); + admin.setEmail(ADMIN_EMAIL); + admin.setPasswordHash(passwordEncoder.encode(password)); + admin.setFirstName(ADMIN_FIRST_NAME); + admin.setLastName(ADMIN_LAST_NAME); + admin.setParticipantType(ParticipantType.PRIVATE); + admin.setRole(UserRole.ADMIN); + admin.setStatus(RegistrationStatus.APPROVED); + admin.setEnabled(true); + admin.setEmailVerified(true); + userRepository.save(admin); + } + + private void createTestUser() { + User testUser = new User(); + testUser.setEmail("test@example.com"); + testUser.setPasswordHash(passwordEncoder.encode("Test1234!")); + testUser.setFirstName("Test"); + testUser.setLastName("Benutzer"); + testUser.setParticipantType(ParticipantType.PRIVATE); + testUser.setRole(UserRole.MEMBER); + testUser.setStatus(RegistrationStatus.PENDING); + testUser.setEnabled(false); + testUser.setEmailVerified(true); + userRepository.save(testUser); + } + + private String generateRandomPassword() { + String upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + String lower = "abcdefghijklmnopqrstuvwxyz"; + String digits = "0123456789"; + String special = "!@#$%&*"; + String all = upper + lower + digits + special; + + SecureRandom random = new SecureRandom(); + StringBuilder password = new StringBuilder(16); + + // Mindestens ein Zeichen aus jeder Klasse + password.append(upper.charAt(random.nextInt(upper.length()))); + password.append(lower.charAt(random.nextInt(lower.length()))); + password.append(digits.charAt(random.nextInt(digits.length()))); + password.append(special.charAt(random.nextInt(special.length()))); + + // Rest zufällig füllen + for (int i = 4; i < 16; i++) { + password.append(all.charAt(random.nextInt(all.length()))); + } + + // Mischen + char[] chars = password.toString().toCharArray(); + for (int i = chars.length - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + char tmp = chars[i]; + chars[i] = chars[j]; + chars[j] = tmp; + } + + return new String(chars); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java index 9a35db8..95570c4 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java @@ -61,6 +61,11 @@ public class User implements UserDetails { return List.of(new SimpleGrantedAuthority(role.name())); } + @Override + public boolean isEnabled() { + return this.enabled; + } + @Override public @Nullable String getPassword() { return this.passwordHash;