added admin approval list

implemented approval (untested)
implemented eda consent simulation (untested)
This commit is contained in:
Bernhard Müller 2026-06-17 11:08:27 +02:00
parent 5f845833cb
commit ad47f7399e
29 changed files with 750 additions and 38 deletions

View File

@ -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

View File

@ -0,0 +1,3 @@
package at.mueller.eeg.backend.common.event;
public record InitiateMakoConsentEvent(String atNumber, String pointType) {}

View File

@ -0,0 +1,3 @@
package at.mueller.eeg.backend.common.event;
public record MakoConsensApprovedEvent(String atNumber) {}

View File

@ -0,0 +1,3 @@
package at.mueller.eeg.backend.common.event;
public record MakoConsensRejectedEvent(String atNumber, String reason) {}

View File

@ -0,0 +1,3 @@
package at.mueller.eeg.backend.common.event;
public record MakoConsensTechnicalFailedEvent(String atNumber, String reason) {}

View File

@ -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"
) {}

View File

@ -0,0 +1,5 @@
package at.mueller.eeg.backend.common.event;
import java.util.UUID;
public record UserApprovedEvent(UUID userId) {}

View File

@ -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<MeteringPoint> 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());
}
}
}
}

View File

@ -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.PointType;
import at.mueller.eeg.backend.community.domain.state.MakoTrigger; import at.mueller.eeg.backend.community.domain.state.MakoTrigger;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository; import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.mako.service.EdaTopologyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
@ -26,7 +25,6 @@ public class MeteringPointEventListener {
private final MeteringPointRepository meteringPointRepository; private final MeteringPointRepository meteringPointRepository;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final EdaTopologyService edaTopologyService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
@ -44,16 +42,15 @@ public class MeteringPointEventListener {
@Transactional @Transactional
public void handleEdaTopologyCompleted(EdaTopologyCompletedEvent event) { public void handleEdaTopologyCompleted(EdaTopologyCompletedEvent event) {
meteringPointRepository.findById(event.meteringPointId()).ifPresent(point -> { meteringPointRepository.findById(event.meteringPointId()).ifPresent(point -> {
var data = event.topologyData();
try { try {
if (data.isEligible()) { if (event.isEligible()) {
point.fireTrigger(MakoTrigger.TOPOLOGY_VALID); point.fireTrigger(MakoTrigger.TOPOLOGY_VALID);
point.setGridOperatorId(data.gridOperatorName()); point.setGridOperatorId(event.gridOperatorId());
point.setGridLevel(data.gridLevel()); point.setGridLevel(event.gridLevel());
point.setSubstationId(data.substationId()); point.setSubstationId(event.substationId());
point.setTransformerId(data.transformerId()); point.setTransformerId(event.transformerId());
} else { } else {
point.fireTrigger(MakoTrigger.TOPOLOGY_INVALID); point.fireTrigger(MakoTrigger.TOPOLOGY_INVALID);
} }

View File

@ -3,7 +3,10 @@ package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringPoint; import at.mueller.eeg.backend.community.domain.MeteringPoint;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UUID> { public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UUID> {
List<MeteringPoint> findAllByUserId(UUID userId);
} }

View File

@ -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<EligibleCommunityResponse> 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);
}
}

View File

@ -1,25 +1,31 @@
package at.mueller.eeg.backend.iam.api; 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.api.dto.IamDtos.UserProfileResponse;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus; import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.domain.User; import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.mapper.UserMapper; import at.mueller.eeg.backend.iam.mapper.UserMapper;
import at.mueller.eeg.backend.iam.repository.UserRepository; import at.mueller.eeg.backend.iam.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation; 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 io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/admin/users") @RequestMapping(value = "/api/admin/users", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin IAM", description = "User-Verwaltung und Freigabe-Workflow") @Tag(name = "Admin IAM", description = "User-Verwaltung und Freigabe-Workflow")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -29,9 +35,21 @@ public class AdminIamController {
private final UserMapper userMapper; private final UserMapper userMapper;
private final ApplicationEventPublisher eventPublisher;
@GetMapping("/pending") @GetMapping("/pending")
@Operation(summary = "Lädt alle wartenden User", @Operation(summary = "Lädt alle wartenden User",
description = "Zeigt User im Status PENDING inkl. der Ergebnisse des EDA-Netz-Checks.") 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<List<UserProfileResponse>> getPendingUsers() { public ResponseEntity<List<UserProfileResponse>> getPendingUsers() {
List<User> allByStatus = userRepository.findAllByStatus(RegistrationStatus.PENDING); List<User> allByStatus = userRepository.findAllByStatus(RegistrationStatus.PENDING);
return ResponseEntity.ok(userMapper.mapToDto(allByStatus)); return ResponseEntity.ok(userMapper.mapToDto(allByStatus));
@ -41,23 +59,21 @@ public class AdminIamController {
@Operation(summary = "User freigeben", @Operation(summary = "User freigeben",
description = "Setzt enabled=true, status=APPROVED und triggert die finale EDA-Anmeldung.") description = "Setzt enabled=true, status=APPROVED und triggert die finale EDA-Anmeldung.")
public ResponseEntity<Void> approveUser(@PathVariable UUID userId) { public ResponseEntity<Void> approveUser(@PathVariable UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
Optional<User> userOptional = userRepository.findById(userId); if (!RegistrationStatus.PENDING.equals(user.getStatus())) {
if (userOptional.isPresent()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "User is not in PENDING status");
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()) { if (user.isEnabled()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "User already enabled"); throw new ResponseStatusException(HttpStatus.CONFLICT, "User is already enabled");
} }
user.setStatus(RegistrationStatus.APPROVED);
user.setEnabled(true); user.setEnabled(true);
userRepository.save(user); userRepository.save(user);
}
// Logik: status auf APPROVED, enabled auf true eventPublisher.publishEvent(new UserApprovedEvent(user.getId()));
// Event feuern: UserApprovedEvent (Schickt E-Mail an User)
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }

View File

@ -0,0 +1,4 @@
package at.mueller.eeg.backend.mako.event.listener;
public class MakoCommandListener {
}

View File

@ -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);}

View File

@ -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();
}
}
}

View File

@ -11,6 +11,55 @@ tags:
- name: Admin IAM - name: Admin IAM
description: User-Verwaltung und Freigabe-Workflow description: User-Verwaltung und Freigabe-Workflow
paths: 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: /api/auth/register:
post: post:
tags: tags:
@ -83,6 +132,27 @@ paths:
responses: responses:
"200": "200":
description: OK 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: /api/admin/users/pending:
get: get:
tags: tags:
@ -92,15 +162,33 @@ paths:
operationId: getPendingUsers operationId: getPendingUsers
responses: responses:
"200": "200":
description: OK description: Liste der wartenden Benutzer erfolgreich geladen
content: content:
'*/*': application/json:
schema: schema:
type: array type: array
items: items:
$ref: "#/components/schemas/UserProfileResponse" $ref: "#/components/schemas/UserProfileResponse"
components: components:
schemas: 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: RegistrationRequest:
type: object type: object
description: Payload für die Registrierung eines neuen Users description: Payload für die Registrierung eines neuen Users
@ -165,6 +253,22 @@ components:
type: string type: string
description: Rolle des Users description: Rolle des Users
example: USER 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: UserProfileResponse:
type: object type: object
description: Repräsentation eines Users für das Admin-Dashboard description: Repräsentation eines Users für das Admin-Dashboard

View File

@ -18,7 +18,8 @@
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0",
"zone.js": "^0.16.2"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.2.12", "@angular/build": "^21.2.12",
@ -11562,6 +11563,12 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.28 || ^4" "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"
} }
} }
} }

View File

@ -22,7 +22,8 @@
"@angular/router": "^21.2.0", "@angular/router": "^21.2.0",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0",
"zone.js": "^0.16.2"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.2.12", "@angular/build": "^21.2.12",

View File

@ -1,13 +1,16 @@
import {ApplicationConfig} from '@angular/core'; import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router'; import {provideRouter} from '@angular/router';
import {routes} from './app.routes'; 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 {BASE_PATH} from './api';
import {jwtInterceptor} from './interceptors/jwt.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes),
provideHttpClient(withFetch()), provideHttpClient(withFetch(),
withInterceptors([jwtInterceptor])),
{ provide: BASE_PATH, useValue: 'http://localhost:8080' } { provide: BASE_PATH, useValue: 'http://localhost:8080' }
] ]
}; };

View File

@ -2,9 +2,16 @@ import {Routes} from '@angular/router';
import {LandingPage} from './pages/landing-page/landing-page'; import {LandingPage} from './pages/landing-page/landing-page';
import {Register} from './pages/register/register'; import {Register} from './pages/register/register';
import {LoginComponent} from './pages/login/login'; 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 = [ export const routes: Routes = [
{ path: '', component: LandingPage }, { path: '', component: LandingPage },
{ path: 'register', component: Register }, { path: 'register', component: Register },
{ path: 'login', component: LoginComponent} { path: 'login', component: LoginComponent},
{
path: 'admin/approvals',
component: AdminUserApprovalComponent,
canActivate: [adminAuthGuard]
}
]; ];

View File

@ -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();
});
});

View File

@ -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']);
};

View File

@ -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);
};

View File

@ -0,0 +1,77 @@
<div class="container mx-auto p-6 max-w-6xl">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">User Freigabe</h1>
<p class="text-gray-600 mt-2">Prüfung der ausstehenden Anmeldungen und EDA-Netz-Checks.</p>
</div>
@if (errorMessage()) {
<div class="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
{{ errorMessage() }}
</div>
}
@if (isLoading()) {
<div class="flex items-center justify-center py-12">
<div class="text-gray-500 text-lg">Lade ausstehende Anträge...</div>
</div>
}
@if (!isLoading() && pendingUsers().length === 0) {
<div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p class="text-gray-600 text-lg">Aktuell gibt es keine neuen User im Status PENDING.</p>
</div>
}
@if (!isLoading() && pendingUsers().length > 0) {
<div class="bg-white shadow-sm border border-gray-200 rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Benutzer</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Zählpunkt</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider">Status</th>
<th class="p-4 text-sm font-semibold text-gray-700 uppercase tracking-wider text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@for (user of pendingUsers(); track user.id) {
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4">
<div class="font-medium text-gray-900">{{ user.firstName }} {{ user.lastName }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</td>
<td class="p-4">
<div class="text-gray-900 font-mono text-sm">
{{ user.primaryAtNumber || 'Kein Zählpunkt hinterlegt' }}
</div>
</td>
<td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ user.status }}
</span>
</td>
<td class="p-4 flex justify-end gap-3">
<button (click)="approveUser(user.id)"
class="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition">
Freigeben
</button>
<button (click)="rejectUser(user.id)"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition">
Ablehnen
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>

View File

@ -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<AdminUserApproval>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminUserApproval],
}).compileComponents();
fixture = TestBed.createComponent(AdminUserApproval);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<UserProfileResponse[]>([]);
public readonly isLoading = signal<boolean>(true);
public readonly errorMessage = signal<string>('');
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.');
}
});
}
}

View File

@ -1,12 +1,11 @@
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon'; import {MatIconModule} from '@angular/material/icon';
import {RouterLink} from '@angular/router';
@Component({ @Component({
selector: 'app-landing-page', selector: 'app-landing-page',
standalone: true, standalone: true,
imports: [MatButtonModule, MatIconModule, RouterLink], imports: [MatButtonModule, MatIconModule],
templateUrl: './landing-page.html', templateUrl: './landing-page.html',
styleUrls: ['./landing-page.scss'] styleUrls: ['./landing-page.scss']
}) })

View File

@ -1,3 +1,4 @@
import 'zone.js';
import {bootstrapApplication} from '@angular/platform-browser'; import {bootstrapApplication} from '@angular/platform-browser';
import {appConfig} from './app/app.config'; import {appConfig} from './app/app.config';
import {App} from './app/app'; import {App} from './app/app';