diff --git a/eeg_backend/eegportal.trace.db b/eeg_backend/eegportal.trace.db new file mode 100644 index 0000000..a7239e6 --- /dev/null +++ b/eeg_backend/eegportal.trace.db @@ -0,0 +1,38 @@ +2026-06-16 18:27:44.502501+02:00 database: close +org.h2.message.DbException: Eingabe/Ausgabe Fehler: "Closing" +IO Exception: "Closing" [90028-240] + at org.h2.message.DbException.get(DbException.java:212) + at org.h2.mvstore.db.Store.close(Store.java:352) + at org.h2.engine.Database.closeOpenFilesAndUnlock(Database.java:1314) + at org.h2.engine.Database.closeImpl(Database.java:1276) + at org.h2.engine.Database.close(Database.java:1195) + at org.h2.engine.Database.onShutdown(Database.java:1180) + at org.h2.engine.OnExitDatabaseCloser.onShutdown(OnExitDatabaseCloser.java:85) + at org.h2.engine.OnExitDatabaseCloser.run(OnExitDatabaseCloser.java:114) +Caused by: org.h2.jdbc.JdbcSQLNonTransientException: Eingabe/Ausgabe Fehler: "Closing" +IO Exception: "Closing" [90028-240] + at org.h2.message.DbException.getJdbcSQLException(DbException.java:566) + at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) + ... 8 more +Caused by: org.h2.mvstore.MVStoreException: java.lang.AssertionError: 8 [2.4.240/3] + at org.h2.mvstore.DataUtils.newMVStoreException(DataUtils.java:996) + at org.h2.mvstore.MVStore.executeFilestoreOperation(MVStore.java:1048) + at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:463) + at org.h2.mvstore.RandomAccessStore.compactStore(RandomAccessStore.java:443) + at org.h2.mvstore.FileStore.compactStore(FileStore.java:891) + at org.h2.mvstore.FileStore.stop(FileStore.java:275) + at org.h2.mvstore.MVStore.closeStore(MVStore.java:803) + at org.h2.mvstore.MVStore.close(MVStore.java:763) + at org.h2.mvstore.db.Store.close(Store.java:348) + ... 6 more +Caused by: java.lang.AssertionError: 8 + at org.h2.mvstore.FreeSpaceBitSet.allocate(FreeSpaceBitSet.java:153) + at org.h2.mvstore.FreeSpaceBitSet.predictAllocation(FreeSpaceBitSet.java:133) + at org.h2.mvstore.RandomAccessStore.predictAllocation(RandomAccessStore.java:101) + at org.h2.mvstore.RandomAccessStore.moveChunkInside(RandomAccessStore.java:628) + at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:569) + at org.h2.mvstore.RandomAccessStore.compactMoveChunks(RandomAccessStore.java:481) + at org.h2.mvstore.RandomAccessStore.lambda$compactMoveChunks$1(RandomAccessStore.java:468) + at org.h2.mvstore.FileStore.executeFileStoreOperation(FileStore.java:1894) + at org.h2.mvstore.MVStore.executeFilestoreOperation(MVStore.java:1044) + ... 13 more diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/InitiateMakoConsentEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/InitiateMakoConsentEvent.java new file mode 100644 index 0000000..621d433 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/InitiateMakoConsentEvent.java @@ -0,0 +1,3 @@ +package at.mueller.eeg.backend.common.event; + +public record InitiateMakoConsentEvent(String atNumber, String pointType) {} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensApprovedEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensApprovedEvent.java new file mode 100644 index 0000000..9eef81d --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensApprovedEvent.java @@ -0,0 +1,3 @@ +package at.mueller.eeg.backend.common.event; + +public record MakoConsensApprovedEvent(String atNumber) {} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensRejectedEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensRejectedEvent.java new file mode 100644 index 0000000..2ad9d79 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensRejectedEvent.java @@ -0,0 +1,3 @@ +package at.mueller.eeg.backend.common.event; + +public record MakoConsensRejectedEvent(String atNumber, String reason) {} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensTechnicalFailedEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensTechnicalFailedEvent.java new file mode 100644 index 0000000..b2a8bbe --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MakoConsensTechnicalFailedEvent.java @@ -0,0 +1,3 @@ +package at.mueller.eeg.backend.common.event; + +public record MakoConsensTechnicalFailedEvent(String atNumber, String reason) {} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MeteringPointApprovedEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MeteringPointApprovedEvent.java new file mode 100644 index 0000000..ab89454 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/MeteringPointApprovedEvent.java @@ -0,0 +1,9 @@ +package at.mueller.eeg.backend.common.event; + +import java.util.UUID; + +public record MeteringPointApprovedEvent( + UUID userId, + String meteringPointNumber, + String type // "BEZUG" oder "EINSPEISUNG" +) {} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/UserApprovedEvent.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/UserApprovedEvent.java new file mode 100644 index 0000000..ffdf341 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/event/UserApprovedEvent.java @@ -0,0 +1,5 @@ +package at.mueller.eeg.backend.common.event; + +import java.util.UUID; + +public record UserApprovedEvent(UUID userId) {} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/CommunityMakoListener.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/CommunityMakoListener.java new file mode 100644 index 0000000..4582af4 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/CommunityMakoListener.java @@ -0,0 +1,60 @@ +package at.mueller.eeg.backend.community.event.listener; + +import at.mueller.eeg.backend.common.event.InitiateMakoConsentEvent; +import at.mueller.eeg.backend.common.event.UserApprovedEvent; +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.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CommunityMakoListener { + + private final MeteringPointRepository meteringPointRepository; + private final ApplicationEventPublisher eventPublisher; + + @Async + @EventListener + @Transactional + public void onUserApproved(UserApprovedEvent event) { + log.info("Community-Domain: User {} wurde freigegeben. Starte MaKo-Prozess für Zählpunkte...", event.userId()); + + List meteringPoints = meteringPointRepository.findAllByUserId(event.userId()); + + if (meteringPoints.isEmpty()) { + log.warn("Keine Zählpunkte für freigegebenen User {} in der Community-Domain gefunden.", event.userId()); + return; + } + + for (MeteringPoint mp : meteringPoints) { + try { + log.info("Triggere SEND_REQUEST für Zählpunkt {} (Aktueller Status: {})", mp.getAtNumber(), mp.getMakoState()); + + mp.fireTrigger(MakoTrigger.SEND_REQUEST); + + meteringPointRepository.save(mp); + + log.info("Delegiere Consent-Request für {} an MaKo-Domain via Event.", mp.getAtNumber()); + eventPublisher.publishEvent(new InitiateMakoConsentEvent( + mp.getAtNumber(), + mp.getType().name() + )); + + } catch (IllegalStateException e) { + log.error("Zustandsübergang fehlgeschlagen für Zählpunkt {}: {}", mp.getAtNumber(), e.getMessage()); + } catch (Exception e) { + log.error("Unerwarteter Fehler bei EDA-Anbindung für Zählpunkt {}: {}", mp.getAtNumber(), e.getMessage()); + } + } + } +} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MeteringPointEventListener.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MeteringPointEventListener.java index 55838ca..688fe7a 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MeteringPointEventListener.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/event/listener/MeteringPointEventListener.java @@ -8,7 +8,6 @@ import at.mueller.eeg.backend.community.domain.MeteringPoint; import at.mueller.eeg.backend.community.domain.PointType; import at.mueller.eeg.backend.community.domain.state.MakoTrigger; import at.mueller.eeg.backend.community.repository.MeteringPointRepository; -import at.mueller.eeg.backend.mako.service.EdaTopologyService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -26,7 +25,6 @@ public class MeteringPointEventListener { private final MeteringPointRepository meteringPointRepository; private final ApplicationEventPublisher eventPublisher; - private final EdaTopologyService edaTopologyService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -44,16 +42,15 @@ public class MeteringPointEventListener { @Transactional public void handleEdaTopologyCompleted(EdaTopologyCompletedEvent event) { meteringPointRepository.findById(event.meteringPointId()).ifPresent(point -> { - var data = event.topologyData(); try { - if (data.isEligible()) { + if (event.isEligible()) { point.fireTrigger(MakoTrigger.TOPOLOGY_VALID); - point.setGridOperatorId(data.gridOperatorName()); - point.setGridLevel(data.gridLevel()); - point.setSubstationId(data.substationId()); - point.setTransformerId(data.transformerId()); + point.setGridOperatorId(event.gridOperatorId()); + point.setGridLevel(event.gridLevel()); + point.setSubstationId(event.substationId()); + point.setTransformerId(event.transformerId()); } else { point.fireTrigger(MakoTrigger.TOPOLOGY_INVALID); } 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 b4159f3..1f4569e 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 @@ -3,7 +3,10 @@ package at.mueller.eeg.backend.community.repository; import at.mueller.eeg.backend.community.domain.MeteringPoint; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.UUID; public interface MeteringPointRepository extends JpaRepository { + + List findAllByUserId(UUID userId); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MembershipService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MembershipService.java new file mode 100644 index 0000000..c4a3c59 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MembershipService.java @@ -0,0 +1,122 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.EligibleCommunityResponse; +import at.mueller.eeg.backend.community.api.dto.MembershipRequest; +import at.mueller.eeg.backend.community.domain.EnergyCommunity; +import at.mueller.eeg.backend.community.domain.Membership; +import at.mueller.eeg.backend.community.domain.MembershipStatus; +import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.domain.state.MakoState; +import at.mueller.eeg.backend.community.repository.EnergyCommunityRepository; +import at.mueller.eeg.backend.community.repository.MembershipRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +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.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MembershipService { + + private final EnergyCommunityRepository energyCommunityRepository; + private final MembershipRepository membershipRepository; + private final MeteringPointRepository meteringPointRepository; + + @Transactional(readOnly = true) + public List getEligibleCommunities(UUID meteringPointId) { + MeteringPoint point = meteringPointRepository.findById(meteringPointId) + .orElseThrow(() -> new IllegalArgumentException("Zählpunkt nicht gefunden")); + + // Business Rule: Nur technisch validierte Zählpunkte dürfen teilnehmen + if (point.getMakoState() != MakoState.ACTIVE) { + log.warn("Zählpunkt {} hat Status {}. Matching nicht erlaubt.", meteringPointId, point.getMakoState()); + return List.of(); + } + + return energyCommunityRepository.findEligibleCommunities( + point.getSubstationId(), point.getTransformerId(), point.getGridOperatorId() + ).stream() + .map(c -> new EligibleCommunityResponse(c.getId(), c.getName(), c.getType(), c.getEdaEcId())) + .toList(); + } + + @Transactional + public UUID requestMembership(MembershipRequest request) { + MeteringPoint point = meteringPointRepository.findById(request.meteringPointId()) + .orElseThrow(() -> new IllegalArgumentException("Zählpunkt nicht gefunden")); + + EnergyCommunity community = energyCommunityRepository.findById(request.energyCommunityId()) + .orElseThrow(() -> new IllegalArgumentException("Energiegemeinschaft nicht gefunden")); + + if (point.getMakoState() != MakoState.ACTIVE) { + throw new IllegalStateException("Zählpunkt ist im EDA-System noch nicht aktiv."); + } + + if (membershipRepository.existsByMeteringPointIdAndEnergyCommunityId(point.getId(), community.getId())) { + throw new IllegalStateException("Ein Beitrittsantrag für diesen Zählpunkt existiert bereits."); + } + + boolean priorityTaken = membershipRepository.existsByMeteringPointIdAndPriorityLevelAndStatusIn( + point.getId(), + request.priorityLevel(), + List.of(MembershipStatus.ACTIVE, MembershipStatus.PENDING) + ); + + if (priorityTaken) { + throw new IllegalStateException("Die Priorität " + request.priorityLevel() + " ist bereits vergeben. Bitte wähle eine andere oder ändere zuerst deine bestehenden Mitgliedschaften."); + } + + // TODO: Hier könnte man noch prüfen, ob das priorityLevel bereits für diesen Zählpunkt vergeben ist. + + Membership membership = new Membership(); + membership.setMeteringPoint(point); + membership.setEnergyCommunity(community); + membership.setPriorityLevel(request.priorityLevel()); + membership.setStatus(MembershipStatus.PENDING); // Startet als PENDING + + Membership saved = membershipRepository.save(membership); + log.info("Beitrittsantrag für Zählpunkt {} zu Community {} erstellt.", point.getId(), community.getId()); + return saved.getId(); + } + + @Transactional + public void approveMembership(UUID membershipId) { + Membership membership = membershipRepository.findById(membershipId) + .orElseThrow(() -> new IllegalArgumentException("Mitgliedschaft nicht gefunden")); + + if (membership.getStatus() != MembershipStatus.PENDING) { + throw new IllegalStateException("Nur offene Anträge können genehmigt werden."); + } + + membership.setStatus(MembershipStatus.ACTIVE); + membership.setValidFrom(LocalDate.now()); // Ab heute aktiv + + membershipRepository.save(membership); + log.info("Mitgliedschaft {} wurde vom Admin freigeschaltet.", membershipId); + + // TODO: Hier später ein Integration Event feuern (z.B. "MembershipActivatedEvent"), + // damit die mako-Domäne die Marktkommunikation (Beginn der Belieferung/Zuteilung) via EDA anstößt! + } + + @Transactional + public void rejectMembership(UUID membershipId) { + Membership membership = membershipRepository.findById(membershipId) + .orElseThrow(() -> new IllegalArgumentException("Mitgliedschaft nicht gefunden")); + + if (membership.getStatus() != MembershipStatus.PENDING) { + throw new IllegalStateException("Nur offene Anträge können abgelehnt werden."); + } + + membership.setStatus(MembershipStatus.INACTIVE); + membership.setValidTo(LocalDate.now()); + + membershipRepository.save(membership); + log.info("Mitgliedschaft {} wurde vom Admin abgelehnt.", membershipId); + } +} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/AdminIamController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/AdminIamController.java index d12bd78..ce6197b 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/AdminIamController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/AdminIamController.java @@ -1,25 +1,31 @@ package at.mueller.eeg.backend.iam.api; +import at.mueller.eeg.backend.common.event.UserApprovedEvent; import at.mueller.eeg.backend.iam.api.dto.IamDtos.UserProfileResponse; import at.mueller.eeg.backend.iam.domain.RegistrationStatus; import at.mueller.eeg.backend.iam.domain.User; import at.mueller.eeg.backend.iam.mapper.UserMapper; import at.mueller.eeg.backend.iam.repository.UserRepository; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import java.util.List; -import java.util.Optional; import java.util.UUID; @RestController -@RequestMapping("/api/admin/users") +@RequestMapping(value = "/api/admin/users", produces = MediaType.APPLICATION_JSON_VALUE) @PreAuthorize("hasRole('ADMIN')") @Tag(name = "Admin IAM", description = "User-Verwaltung und Freigabe-Workflow") @RequiredArgsConstructor @@ -29,9 +35,21 @@ public class AdminIamController { private final UserMapper userMapper; + private final ApplicationEventPublisher eventPublisher; + @GetMapping("/pending") @Operation(summary = "Lädt alle wartenden User", description = "Zeigt User im Status PENDING inkl. der Ergebnisse des EDA-Netz-Checks.") + @ApiResponse( + responseCode = "200", + description = "Liste der wartenden Benutzer erfolgreich geladen", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema( + schema = @Schema(implementation = UserProfileResponse.class) + ) + ) + ) public ResponseEntity> getPendingUsers() { List allByStatus = userRepository.findAllByStatus(RegistrationStatus.PENDING); return ResponseEntity.ok(userMapper.mapToDto(allByStatus)); @@ -41,23 +59,21 @@ public class AdminIamController { @Operation(summary = "User freigeben", description = "Setzt enabled=true, status=APPROVED und triggert die finale EDA-Anmeldung.") public ResponseEntity approveUser(@PathVariable UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); - Optional userOptional = userRepository.findById(userId); - if (userOptional.isPresent()) { - User user = userOptional.get(); - if (RegistrationStatus.PENDING.equals(user.getStatus())) { - user.setStatus(RegistrationStatus.APPROVED); - } else { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User in wrong status"); - } - if (user.isEnabled()) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User already enabled"); - } - user.setEnabled(true); - userRepository.save(user); + if (!RegistrationStatus.PENDING.equals(user.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "User is not in PENDING status"); } - // Logik: status auf APPROVED, enabled auf true - // Event feuern: UserApprovedEvent (Schickt E-Mail an User) + if (user.isEnabled()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "User is already enabled"); + } + + user.setStatus(RegistrationStatus.APPROVED); + user.setEnabled(true); + userRepository.save(user); + + eventPublisher.publishEvent(new UserApprovedEvent(user.getId())); return ResponseEntity.noContent().build(); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/event/listener/MakoCommandListener.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/event/listener/MakoCommandListener.java new file mode 100644 index 0000000..588d684 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/event/listener/MakoCommandListener.java @@ -0,0 +1,4 @@ +package at.mueller.eeg.backend.mako.event.listener; + +public class MakoCommandListener { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/EdaConsentService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/EdaConsentService.java new file mode 100644 index 0000000..4c0c616 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/EdaConsentService.java @@ -0,0 +1,11 @@ +package at.mueller.eeg.backend.mako.service; + +public interface EdaConsentService { + /** + * Initiiert asynchron den Consent-Request (Einholung der Zustimmung) beim Netzbetreiber via EDA. + * Der Aufruf blockiert nicht. Die Antwort erfolgt asynchron via Event (MakoEvents). + * + * @param atNumber Die 33-stellige Zählpunktnummer. + * @param pointType Der Typ des Zählpunkts (z.B. CONSUMER, PRODUCER). + */ + void sendConsentRequest(String atNumber, String pointType);} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/impl/SimulatedEdaConsentService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/impl/SimulatedEdaConsentService.java new file mode 100644 index 0000000..a6e4145 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/mako/service/impl/SimulatedEdaConsentService.java @@ -0,0 +1,83 @@ +package at.mueller.eeg.backend.mako.service.impl; + +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.mako.service.EdaConsentService; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SimulatedEdaConsentService implements EdaConsentService { + + private final ApplicationEventPublisher eventPublisher; + + @Value("${eda.simulation.consent-delay-ms:3000}") + private long simulationDelayMs; + + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + + @Override + public void sendConsentRequest(String atNumber, String pointType) { + log.info("[EDA-SIMULATION] Eingehender Request. AT-Nummer: {}, Typ: {}", atNumber, pointType); + + if (atNumber == null || atNumber.isBlank()) { + throw new IllegalArgumentException("AT-Nummer darf nicht leer sein für EDA-Request."); + } + + log.info("[EDA-SIMULATION] Request formal gültig. Starte asynchrone Verarbeitung in {} ms...", simulationDelayMs); + + scheduler.schedule(() -> processAsyncCallback(atNumber), simulationDelayMs, TimeUnit.MILLISECONDS); + } + + /** + * Hier sitzt die "Magic Value" Logik für deterministisches Testen deiner States. + */ + private void processAsyncCallback(String atNumber) { + log.info("[EDA-SIMULATION] Verarbeite asynchronen Callback für: {}", atNumber); + + try { + if (atNumber.toUpperCase().endsWith("REJECT")) { + log.info("[EDA-SIMULATION] OUTCOME: Zustimmung verweigert (Magic Value erkannt)."); + eventPublisher.publishEvent(new MakoConsensRejectedEvent(atNumber, "Kunde hat im Portal abgelehnt.")); + } + else if (atNumber.toUpperCase().endsWith("ERROR")) { + log.warn("[EDA-SIMULATION] OUTCOME: Technischer EDA-Fehler (Magic Value erkannt)."); + eventPublisher.publishEvent(new MakoConsensTechnicalFailedEvent(atNumber, "EDA-Gateway Timeout (504).")); + } + else { + log.info("[EDA-SIMULATION] OUTCOME: Zustimmung erteilt!"); + eventPublisher.publishEvent(new MakoConsensApprovedEvent(atNumber)); + } + } catch (Exception e) { + log.error("[EDA-SIMULATION] Kritischer Fehler im Simulator: {}", e.getMessage()); + } + } + + /** + * Clean-Up: Verhindert Memory-Leaks beim Neustarten (Live-Reload/DevTools) + */ + @PreDestroy + public void shutdown() { + log.info("Fahre EDA-Simulator herunter..."); + scheduler.shutdown(); + try { + if (!scheduler.awaitTermination(2, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } catch (InterruptedException e) { + scheduler.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} \ No newline at end of file diff --git a/eeg_frontend/openapi.yaml b/eeg_frontend/openapi.yaml index 0fcbc4e..bd8af08 100644 --- a/eeg_frontend/openapi.yaml +++ b/eeg_frontend/openapi.yaml @@ -11,6 +11,55 @@ tags: - name: Admin IAM description: User-Verwaltung und Freigabe-Workflow paths: + /api/community/admin/memberships/{id}/reject: + put: + tags: + - membership-controller + operationId: rejectMembership + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + /api/community/admin/memberships/{id}/approve: + put: + tags: + - membership-controller + operationId: approveMembership + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + /api/community/memberships/request: + post: + tags: + - membership-controller + operationId: requestMembership + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + type: string + format: uuid /api/auth/register: post: tags: @@ -83,6 +132,27 @@ paths: responses: "200": description: OK + /api/community/metering-points/{id}/eligible-communities: + get: + tags: + - membership-controller + operationId: getEligibleCommunities + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/EligibleCommunityResponse" /api/admin/users/pending: get: tags: @@ -92,15 +162,33 @@ paths: operationId: getPendingUsers responses: "200": - description: OK + description: Liste der wartenden Benutzer erfolgreich geladen content: - '*/*': + application/json: schema: type: array items: $ref: "#/components/schemas/UserProfileResponse" components: schemas: + MembershipRequest: + type: object + properties: + meteringPointId: + type: string + format: uuid + energyCommunityId: + type: string + format: uuid + priorityLevel: + type: integer + format: int32 + maximum: 5 + minimum: 1 + required: + - energyCommunityId + - meteringPointId + - priorityLevel RegistrationRequest: type: object description: Payload für die Registrierung eines neuen Users @@ -165,6 +253,22 @@ components: type: string description: Rolle des Users example: USER + EligibleCommunityResponse: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + type: + type: string + enum: + - EEG_LOKAL + - EEG_REGIONAL + - BEG + edaEcId: + type: string UserProfileResponse: type: object description: Repräsentation eines Users für das Admin-Dashboard diff --git a/eeg_frontend/package-lock.json b/eeg_frontend/package-lock.json index 007982c..263f5a8 100644 --- a/eeg_frontend/package-lock.json +++ b/eeg_frontend/package-lock.json @@ -18,7 +18,8 @@ "@angular/router": "^21.2.0", "@tailwindcss/postcss": "^4.3.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.12", @@ -11562,6 +11563,12 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zone.js": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.2.tgz", + "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", + "license": "MIT" } } } diff --git a/eeg_frontend/package.json b/eeg_frontend/package.json index 993e8fb..9d32011 100644 --- a/eeg_frontend/package.json +++ b/eeg_frontend/package.json @@ -22,7 +22,8 @@ "@angular/router": "^21.2.0", "@tailwindcss/postcss": "^4.3.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zone.js": "^0.16.2" }, "devDependencies": { "@angular/build": "^21.2.12", diff --git a/eeg_frontend/src/app/app.config.ts b/eeg_frontend/src/app/app.config.ts index 4a2cf45..7c1d66a 100644 --- a/eeg_frontend/src/app/app.config.ts +++ b/eeg_frontend/src/app/app.config.ts @@ -1,13 +1,16 @@ -import {ApplicationConfig} from '@angular/core'; +import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core'; import {provideRouter} from '@angular/router'; import {routes} from './app.routes'; -import {provideHttpClient, withFetch} from '@angular/common/http'; +import {provideHttpClient, withFetch, withInterceptors} from '@angular/common/http'; import {BASE_PATH} from './api'; +import {jwtInterceptor} from './interceptors/jwt.interceptor'; export const appConfig: ApplicationConfig = { providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), - provideHttpClient(withFetch()), + provideHttpClient(withFetch(), + withInterceptors([jwtInterceptor])), { provide: BASE_PATH, useValue: 'http://localhost:8080' } ] }; diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index e38ff97..88e63b3 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -2,9 +2,16 @@ import {Routes} from '@angular/router'; import {LandingPage} from './pages/landing-page/landing-page'; import {Register} from './pages/register/register'; import {LoginComponent} from './pages/login/login'; +import {AdminUserApprovalComponent} from './pages/admin-user-approval/admin-user-approval'; +import {adminAuthGuard} from './guards/admin-auth-guard'; export const routes: Routes = [ { path: '', component: LandingPage }, { path: 'register', component: Register }, - { path: 'login', component: LoginComponent} + { path: 'login', component: LoginComponent}, + { + path: 'admin/approvals', + component: AdminUserApprovalComponent, + canActivate: [adminAuthGuard] + } ]; diff --git a/eeg_frontend/src/app/guards/admin-auth-guard.spec.ts b/eeg_frontend/src/app/guards/admin-auth-guard.spec.ts new file mode 100644 index 0000000..7a95060 --- /dev/null +++ b/eeg_frontend/src/app/guards/admin-auth-guard.spec.ts @@ -0,0 +1,17 @@ +import { TestBed } from '@angular/core/testing'; +import { CanActivateFn } from '@angular/router'; + +import { adminAuthGuard } from './admin-auth-guard'; + +describe('adminAuthGuard', () => { + const executeGuard: CanActivateFn = (...guardParameters) => + TestBed.runInInjectionContext(() => adminAuthGuard(...guardParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(executeGuard).toBeTruthy(); + }); +}); diff --git a/eeg_frontend/src/app/guards/admin-auth-guard.ts b/eeg_frontend/src/app/guards/admin-auth-guard.ts new file mode 100644 index 0000000..f76c225 --- /dev/null +++ b/eeg_frontend/src/app/guards/admin-auth-guard.ts @@ -0,0 +1,19 @@ +import {inject} from '@angular/core'; +import {CanActivateFn, Router} from '@angular/router'; +import {AuthService} from '../services/auth'; // Adjust path if necessary + +export const adminAuthGuard: CanActivateFn = (route, state) => { + const authService = inject(AuthService); + const router = inject(Router); + + // Replace these with your actual authentication and role-checking methods + const isAuthenticated = authService.isLoggedIn(); + const isAdmin = authService.getUserRole() === 'ADMIN'; + if (isAuthenticated && isAdmin) { + return true; + } + + // Optional: Redirect to a generic 'unauthorized' page or back to the landing page/login + console.warn('Access denied: Admin privileges required.'); + return router.createUrlTree(['/login']); +}; diff --git a/eeg_frontend/src/app/interceptors/jwt.interceptor.ts b/eeg_frontend/src/app/interceptors/jwt.interceptor.ts new file mode 100644 index 0000000..9045ef8 --- /dev/null +++ b/eeg_frontend/src/app/interceptors/jwt.interceptor.ts @@ -0,0 +1,17 @@ +import {HttpInterceptorFn} from '@angular/common/http'; + +export const jwtInterceptor: HttpInterceptorFn = (req, next) => { + const token = localStorage.getItem('token'); + + // If a token exists, clone the request and add the Authorization header + if (token) { + const clonedRequest = req.clone({ + setHeaders: { + Authorization: `Bearer ${token}` + } + }); + return next(clonedRequest); + } + + return next(req); +}; diff --git a/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.html b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.html new file mode 100644 index 0000000..7cb453a --- /dev/null +++ b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.html @@ -0,0 +1,77 @@ +
+
+

User Freigabe

+

Prüfung der ausstehenden Anmeldungen und EDA-Netz-Checks.

+
+ + @if (errorMessage()) { + + } + + @if (isLoading()) { +
+
Lade ausstehende Anträge...
+
+ } + + @if (!isLoading() && pendingUsers().length === 0) { +
+

Aktuell gibt es keine neuen User im Status PENDING.

+
+ } + + @if (!isLoading() && pendingUsers().length > 0) { +
+
+ + + + + + + + + + + @for (user of pendingUsers(); track user.id) { + + + + + + + + + + + + } + +
BenutzerZählpunktStatusAktionen
+
{{ user.firstName }} {{ user.lastName }}
+
{{ user.email }}
+
+
+ {{ user.primaryAtNumber || 'Kein Zählpunkt hinterlegt' }} +
+
+ + {{ user.status }} + + + + + +
+
+
+ } +
diff --git a/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.scss b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.scss new file mode 100644 index 0000000..e69de29 diff --git a/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.spec.ts b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.spec.ts new file mode 100644 index 0000000..4bc4d94 --- /dev/null +++ b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminUserApproval } from './admin-user-approval'; + +describe('AdminUserApproval', () => { + let component: AdminUserApproval; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminUserApproval], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminUserApproval); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.ts b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.ts new file mode 100644 index 0000000..2e1b08e --- /dev/null +++ b/eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.ts @@ -0,0 +1,78 @@ +import {Component, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {AdminIAMService, UserProfileResponse} from '../../api'; + +@Component({ + selector: 'app-admin-user-approval', + standalone: true, + imports: [], + templateUrl: './admin-user-approval.html', + styleUrls: ['./admin-user-approval.scss'] +}) +export class AdminUserApprovalComponent implements OnInit { + // Signals für reaktives State-Management (perfekt für den Zoneless-Modus) + public readonly pendingUsers = signal([]); + public readonly isLoading = signal(true); + public readonly errorMessage = signal(''); + + private readonly adminService = inject(AdminIAMService); + private readonly destroyRef = inject(DestroyRef); // Für sauberes Ressourcen-Management + + public ngOnInit(): void { + this.loadPendingUsers(); + } + + private loadPendingUsers(): void { + this.isLoading.set(true); + this.errorMessage.set(''); + + this.adminService.getPendingUsers() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (users: UserProfileResponse[]) => { + this.pendingUsers.set(users); + this.isLoading.set(false); + }, + error: (error) => { + console.error('Failed to load pending users:', error); + this.errorMessage.set('Fehler beim Laden der Daten aus dem EDA-Netz-Check.'); + this.isLoading.set(false); + } + }); + } + + public approveUser(userId: string | undefined): void { + if (!userId) return; + this.errorMessage.set(''); + + this.adminService.approveUser(userId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.pendingUsers.update(users => users.filter(user => user.id !== userId)); + }, + error: (error) => { + console.error(`Failed to approve user with ID ${userId}:`, error); + this.errorMessage.set('Der User konnte nicht freigegeben werden.'); + } + }); + } + + public rejectUser(userId: string | undefined): void { + if (!userId) return; + this.errorMessage.set(''); + + this.adminService.rejectUser(userId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + // Den abgelehnten User atomar aus der Liste filtern + this.pendingUsers.update(users => users.filter(user => user.id !== userId)); + }, + error: (error) => { + console.error(`Failed to reject user with ID ${userId}:`, error); + this.errorMessage.set('Der User konnte nicht abgelehnt werden.'); + } + }); + } +} diff --git a/eeg_frontend/src/app/pages/landing-page/landing-page.ts b/eeg_frontend/src/app/pages/landing-page/landing-page.ts index c9340ca..9c3240f 100644 --- a/eeg_frontend/src/app/pages/landing-page/landing-page.ts +++ b/eeg_frontend/src/app/pages/landing-page/landing-page.ts @@ -1,12 +1,11 @@ import {Component} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; -import {RouterLink} from '@angular/router'; @Component({ selector: 'app-landing-page', standalone: true, - imports: [MatButtonModule, MatIconModule, RouterLink], + imports: [MatButtonModule, MatIconModule], templateUrl: './landing-page.html', styleUrls: ['./landing-page.scss'] }) diff --git a/eeg_frontend/src/main.ts b/eeg_frontend/src/main.ts index 190f341..281f176 100644 --- a/eeg_frontend/src/main.ts +++ b/eeg_frontend/src/main.ts @@ -1,5 +1,6 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { App } from './app/app'; +import 'zone.js'; +import {bootstrapApplication} from '@angular/platform-browser'; +import {appConfig} from './app/app.config'; +import {App} from './app/app'; bootstrapApplication(App, appConfig).catch((err) => console.error(err));