diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/common/security/CurrentUserId.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/security/CurrentUserId.java new file mode 100644 index 0000000..cf87bf4 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/common/security/CurrentUserId.java @@ -0,0 +1,14 @@ +package at.mueller.eeg.backend.common.security; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal(expression = "claims['userId']") +public @interface CurrentUserId { +} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java new file mode 100644 index 0000000..b45cd24 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java @@ -0,0 +1,48 @@ +package at.mueller.eeg.backend.community.api; + +import at.mueller.eeg.backend.common.security.CurrentUserId; +import at.mueller.eeg.backend.community.api.dto.CreateMeteringPointRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; +import at.mueller.eeg.backend.community.service.MeteringPointService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/community/metering-points") +@RequiredArgsConstructor +public class MeteringPointController { + + private final MeteringPointService meteringPointService; + + // --- MEMBER: eigene Zählpunkte verwalten --- + + @PostMapping + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity addOwnMeteringPoint( + @CurrentUserId String userId, + @Valid @RequestBody CreateMeteringPointRequest request) { + MeteringPointResponse created = meteringPointService.addOwnMeteringPoint(UUID.fromString(userId), request); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @GetMapping("/me") + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity> getOwnMeteringPoints(@CurrentUserId String userId) { + return ResponseEntity.ok(meteringPointService.getOwnMeteringPoints(UUID.fromString(userId))); + } + + // --- ADMIN: alle Zählpunkte im System --- + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getAllMeteringPoints() { + return ResponseEntity.ok(meteringPointService.getAllMeteringPoints()); + } +} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/CreateMeteringPointRequest.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/CreateMeteringPointRequest.java new file mode 100644 index 0000000..e656308 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/CreateMeteringPointRequest.java @@ -0,0 +1,15 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.PointType; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record CreateMeteringPointRequest( + @NotNull + @Pattern(regexp = "^AT[0-9]{31}$", message = "Muss eine gültige österreichische 33-stellige Zählpunktnummer sein") + String atNumber, + + @NotNull + PointType type +) { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringPointResponse.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringPointResponse.java new file mode 100644 index 0000000..c90cb7f --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringPointResponse.java @@ -0,0 +1,16 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.PointType; +import at.mueller.eeg.backend.community.domain.state.MakoState; + +import java.util.UUID; + +public record MeteringPointResponse( + UUID id, + UUID userId, + String atNumber, + PointType type, + MakoState makoState, + String gridOperatorId, + String ownerEmail +){} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java new file mode 100644 index 0000000..15103ff --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java @@ -0,0 +1,95 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.common.event.InitiateMakoConsentEvent; +import at.mueller.eeg.backend.common.exception.AtNumberAlreadyExistsException; +import at.mueller.eeg.backend.community.api.dto.CreateMeteringPointRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; +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 at.mueller.eeg.backend.iam.domain.RegistrationStatus; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MeteringPointService { + + private final MeteringPointRepository meteringPointRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public MeteringPointResponse addOwnMeteringPoint(UUID userId, CreateMeteringPointRequest request) { + if (meteringPointRepository.existsByAtNumber(request.atNumber())) { + throw new AtNumberAlreadyExistsException(request.atNumber()); + } + + User owner = userRepository.findById(userId) + .orElseThrow(() -> new IllegalStateException("User nicht gefunden: " + userId)); + + MeteringPoint point = new MeteringPoint(); + point.setUserId(userId); + point.setAtNumber(request.atNumber()); + point.setType(request.type()); + + boolean alreadyApproved = owner.getStatus() == RegistrationStatus.APPROVED; + if (alreadyApproved) { + // Der reguläre "Neuer User"-Flow löst den Consent-Prozess erst bei der Freigabe aus + // (siehe CommunityMakoListener). Ist der User aber schon freigegeben, muss ein + // nachträglich hinzugefügter Zählpunkt den Prozess sofort selbst anstoßen. + point.fireTrigger(MakoTrigger.SEND_REQUEST); + } + + MeteringPoint saved = meteringPointRepository.save(point); + + if (alreadyApproved) { + eventPublisher.publishEvent(new InitiateMakoConsentEvent( + saved.getUserId(), saved.getId(), saved.getAtNumber(), saved.getType().name() + )); + } + + return toResponse(saved, owner.getEmail()); + } + + public List getOwnMeteringPoints(UUID userId) { + return meteringPointRepository.findAllByUserId(userId).stream() + .map(mp -> toResponse(mp, null)) + .toList(); + } + + public List getAllMeteringPoints() { + List allPoints = meteringPointRepository.findAll(); + + Map emailsByUserId = userRepository + .findAllById(allPoints.stream().map(MeteringPoint::getUserId).distinct().toList()) + .stream() + .collect(Collectors.toMap(User::getId, User::getEmail)); + + return allPoints.stream() + .map(mp -> toResponse(mp, emailsByUserId.get(mp.getUserId()))) + .toList(); + } + + private MeteringPointResponse toResponse(MeteringPoint mp, String ownerEmail) { + return new MeteringPointResponse( + mp.getId(), + mp.getUserId(), + mp.getAtNumber(), + mp.getType(), + mp.getMakoState(), + mp.getGridOperatorId(), + ownerEmail + ); + } +} \ No newline at end of file diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java index d183d13..8bb18f9 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/dashboard/api/DashboardController.java @@ -1,12 +1,12 @@ package at.mueller.eeg.backend.dashboard.api; +import at.mueller.eeg.backend.common.security.CurrentUserId; import at.mueller.eeg.backend.dashboard.api.dto.DashboardStatsDto; import at.mueller.eeg.backend.dashboard.service.DashboardService; import at.mueller.eeg.backend.iam.domain.User; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -26,7 +26,7 @@ public class DashboardController { @GetMapping("/user") @PreAuthorize("hasRole('MEMBER')") - public ResponseEntity getUserDashboard(@AuthenticationPrincipal User user) { + public ResponseEntity getUserDashboard(@CurrentUserId User user) { // user.getId() funktioniert, da UserDetails implementiert ist return ResponseEntity.ok(dashboardService.getUserStats(user.getId())); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AuthService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AuthService.java index da556f2..4358cba 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AuthService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AuthService.java @@ -43,12 +43,15 @@ public class AuthService { new UsernamePasswordAuthenticationToken(request.email(), request.password()) ); + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new IllegalStateException("User nach erfolgreicher Authentifizierung nicht gefunden")); + String role = authentication.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .findFirst() .orElse("ROLE_MEMBER"); - String token = generateToken(authentication.getName(), role); + String token = generateToken(user, role); return new AuthResponse(token, authentication.getName(), role); } @@ -92,14 +95,15 @@ public class AuthService { /** * Hilfsmethode zum Bauen des JWT. */ - private String generateToken(String email, String role) { + private String generateToken(User user, String role) { Instant now = Instant.now(); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("eeg-portal") .issuedAt(now) .expiresAt(now.plus(2, ChronoUnit.HOURS)) - .subject(email) - .claim("role", role.replace("ROLE_", "")) // Dynamische Rolle einfügen + .subject(user.getEmail()) + .claim("userId", user.getId().toString()) + .claim("role", role.replace("ROLE_", "")) .build(); JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256).build(); diff --git a/eeg_frontend/openapi.yaml b/eeg_frontend/openapi.yaml index 04c4fd4..f0995ce 100644 --- a/eeg_frontend/openapi.yaml +++ b/eeg_frontend/openapi.yaml @@ -98,6 +98,37 @@ paths: responses: "200": description: OK + /api/community/metering-points: + get: + tags: + - metering-point-controller + operationId: getAllMeteringPoints + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/MeteringPointResponse" + post: + tags: + - metering-point-controller + operationId: addOwnMeteringPoint + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeteringPointRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MeteringPointResponse" /api/community/memberships/request: post: tags: @@ -264,6 +295,20 @@ paths: type: array items: $ref: "#/components/schemas/EligibleCommunityResponse" + /api/community/metering-points/me: + get: + tags: + - metering-point-controller + operationId: getOwnMeteringPoints + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/MeteringPointResponse" /api/auth/verify-email: get: tags: @@ -321,6 +366,51 @@ components: memberCount: type: integer format: int32 + CreateMeteringPointRequest: + type: object + properties: + atNumber: + type: string + pattern: "^AT[0-9]{31}$" + type: + type: string + enum: + - CONSUMER + - PRODUCER + - PROSUME + required: + - atNumber + - type + MeteringPointResponse: + type: object + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + atNumber: + type: string + type: + type: string + enum: + - CONSUMER + - PRODUCER + - PROSUME + makoState: + type: string + enum: + - NEW + - WAITING_FOR_CONSENT + - CONSENT_GRANTED + - ACTIVE + - REJECTED + - ERROR + gridOperatorId: + type: string + ownerEmail: + type: string MembershipRequest: type: object properties: diff --git a/eeg_frontend/src/app/app.routes.ts b/eeg_frontend/src/app/app.routes.ts index c22920a..520fd32 100644 --- a/eeg_frontend/src/app/app.routes.ts +++ b/eeg_frontend/src/app/app.routes.ts @@ -9,6 +9,7 @@ import {DashboardLayout} from './layout/dashboard-layout/dashboard-layout'; import {DashboardOverview} from './pages/dashboard-overview/dashboard-overview'; import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community'; import {VerifyEmailComponent} from './pages/verify-email/verify-email'; +import {MeteringPointsComponent} from './pages/metering-points/metering-points'; export const routes: Routes = [ { path: '', component: LandingPage }, @@ -21,6 +22,11 @@ export const routes: Routes = [ canActivate: [authGuard], children: [ { path: '', component: DashboardOverview }, + { + path: 'metering-points', + component: MeteringPointsComponent, + canActivate: [roleGuard(['ADMIN', 'MEMBER'])] + }, { path: 'approvals', component: AdminUserApprovalComponent, diff --git a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts index 2758f97..d64a9b5 100644 --- a/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts +++ b/eeg_frontend/src/app/layout/dashboard-layout/dashboard-nav-items.ts @@ -8,6 +8,7 @@ export interface NavItem { // {label: 'Meine Zählpunkte', path: '/dashboard/metering-points', roles: ['MEMBER']} export const NAV_ITEMS: NavItem[] = [ {label: 'Übersicht', path: '/dashboard', roles: ['ADMIN', 'MEMBER']}, + {label: 'Zählpunkte', path: '/dashboard/metering-points', roles: ['ADMIN', 'MEMBER']}, {label: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']}, {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']} ]; diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.html b/eeg_frontend/src/app/pages/metering-points/metering-points.html new file mode 100644 index 0000000..08653db --- /dev/null +++ b/eeg_frontend/src/app/pages/metering-points/metering-points.html @@ -0,0 +1,77 @@ +
+
+

+ {{ isAdmin ? 'Alle Zählpunkte im System' : 'Meine Zählpunkte' }} +

+

+ {{ isAdmin + ? 'Übersicht aller registrierten Zählpunkte inkl. Besitzer.' + : 'Hier kannst du deine Zählpunkte verwalten und neue hinzufügen.' }} +

+
+ + @if (errorMessage()) { +
+ {{ errorMessage() }} +
+ } + + @if (!isAdmin) { +
+ + Zählpunktnummer + + @if (addForm.get('atNumber')?.hasError('pattern')) { + Muss eine gültige 33-stellige österreichische Zählpunktnummer sein (AT...) + } + + + + Typ + + Verbraucher + Erzeuger + Prosumer + + + + +
+ } + +
+ + + + + + + @if (isAdmin) { + + } + + + + @if (isLoading()) { + + } @else if (points().length === 0) { + + } @else { + @for (point of points(); track point.id) { + + + + + @if (isAdmin) { + + } + + } + } + +
ZählpunktnummerTypStatusBesitzer
Lade Zählpunkte...
Keine Zählpunkte vorhanden.
{{ point.atNumber }}{{ point.type }}{{ point.makoState }}{{ point.ownerEmail }}
+
+
diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.scss b/eeg_frontend/src/app/pages/metering-points/metering-points.scss new file mode 100644 index 0000000..e69de29 diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.spec.ts b/eeg_frontend/src/app/pages/metering-points/metering-points.spec.ts new file mode 100644 index 0000000..d9ffd1f --- /dev/null +++ b/eeg_frontend/src/app/pages/metering-points/metering-points.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MeteringPoints } from './metering-points'; + +describe('MeteringPoints', () => { + let component: MeteringPoints; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MeteringPoints], + }).compileComponents(); + + fixture = TestBed.createComponent(MeteringPoints); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.ts b/eeg_frontend/src/app/pages/metering-points/metering-points.ts new file mode 100644 index 0000000..b30d144 --- /dev/null +++ b/eeg_frontend/src/app/pages/metering-points/metering-points.ts @@ -0,0 +1,75 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatButtonModule} from '@angular/material/button'; +import {MatSelectModule} from '@angular/material/select'; +import {AuthService} from '../../services/auth'; +import {CreateMeteringPointRequest, MeteringPoint, MeteringPointService} from '../../services/metering-point'; + +@Component({ + selector: 'app-metering-points', + standalone: true, + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatButtonModule, MatSelectModule], + templateUrl: './metering-points.html' +}) +export class MeteringPointsComponent implements OnInit { + private fb = inject(FormBuilder); + private authService = inject(AuthService); + private meteringPointService = inject(MeteringPointService); + + isAdmin = this.authService.currentUserRole() === 'ADMIN'; + + points = signal([]); + isLoading = signal(true); + errorMessage = signal(''); + + addForm = this.fb.group({ + atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]], + type: this.fb.control('CONSUMER', {nonNullable: true, validators: Validators.required}) + }); + + ngOnInit() { + this.loadPoints(); + } + + private loadPoints() { + this.isLoading.set(true); + const request$ = this.isAdmin + ? this.meteringPointService.getAllMeteringPoints() + : this.meteringPointService.getMyMeteringPoints(); + + request$.subscribe({ + next: points => { + this.points.set(points); + this.isLoading.set(false); + }, + error: () => { + this.errorMessage.set('Zählpunkte konnten nicht geladen werden.'); + this.isLoading.set(false); + } + }); + } + + onSubmit() { + if (this.addForm.invalid) { + this.addForm.markAllAsTouched(); + return; + } + + const payload: CreateMeteringPointRequest = { + atNumber: this.addForm.value.atNumber!, + type: this.addForm.value.type! + }; + + this.meteringPointService.addMeteringPoint(payload).subscribe({ + next: created => { + this.points.update(list => [...list, created]); + this.addForm.reset({atNumber: '', type: 'CONSUMER'}); + }, + error: err => { + this.errorMessage.set(err.error?.message || 'Zählpunkt konnte nicht hinzugefügt werden.'); + } + }); + } +} diff --git a/eeg_frontend/src/app/services/metering-point.ts b/eeg_frontend/src/app/services/metering-point.ts new file mode 100644 index 0000000..689f555 --- /dev/null +++ b/eeg_frontend/src/app/services/metering-point.ts @@ -0,0 +1,39 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; + +export type PointType = 'CONSUMER' | 'PRODUCER' | 'PROSUME'; +export type MakoState = 'NEW' | 'WAITING_FOR_CONSENT' | 'CONSENT_GRANTED' | 'ACTIVE' | 'REJECTED' | 'ERROR'; + +export interface MeteringPoint { + id: string; + userId: string; + atNumber: string; + type: PointType; + makoState: MakoState; + gridOperatorId?: string; + ownerEmail?: string; +} + +export interface CreateMeteringPointRequest { + atNumber: string; + type: PointType; +} + +@Injectable({providedIn: 'root'}) +export class MeteringPointService { + private http = inject(HttpClient); + private apiUrl = 'http://localhost:8080/api/community/metering-points'; + + getMyMeteringPoints(): Observable { + return this.http.get(`${this.apiUrl}/me`); + } + + getAllMeteringPoints(): Observable { + return this.http.get(this.apiUrl); + } + + addMeteringPoint(payload: CreateMeteringPointRequest): Observable { + return this.http.post(this.apiUrl, payload); + } +}