added meteringpoint management

This commit is contained in:
Bernhard Müller 2026-07-05 15:08:01 +02:00
parent 2e9790b3b2
commit 70e05e524b
15 changed files with 508 additions and 6 deletions

View File

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

View File

@ -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<MeteringPointResponse> 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<List<MeteringPointResponse>> getOwnMeteringPoints(@CurrentUserId String userId) {
return ResponseEntity.ok(meteringPointService.getOwnMeteringPoints(UUID.fromString(userId)));
}
// --- ADMIN: alle Zählpunkte im System ---
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<List<MeteringPointResponse>> getAllMeteringPoints() {
return ResponseEntity.ok(meteringPointService.getAllMeteringPoints());
}
}

View File

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

View File

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

View File

@ -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<MeteringPointResponse> getOwnMeteringPoints(UUID userId) {
return meteringPointRepository.findAllByUserId(userId).stream()
.map(mp -> toResponse(mp, null))
.toList();
}
public List<MeteringPointResponse> getAllMeteringPoints() {
List<MeteringPoint> allPoints = meteringPointRepository.findAll();
Map<UUID, String> 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
);
}
}

View File

@ -1,12 +1,12 @@
package at.mueller.eeg.backend.dashboard.api; 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.api.dto.DashboardStatsDto;
import at.mueller.eeg.backend.dashboard.service.DashboardService; import at.mueller.eeg.backend.dashboard.service.DashboardService;
import at.mueller.eeg.backend.iam.domain.User; import at.mueller.eeg.backend.iam.domain.User;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@ -26,7 +26,7 @@ public class DashboardController {
@GetMapping("/user") @GetMapping("/user")
@PreAuthorize("hasRole('MEMBER')") @PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<DashboardStatsDto.UserStats> getUserDashboard(@AuthenticationPrincipal User user) { public ResponseEntity<DashboardStatsDto.UserStats> getUserDashboard(@CurrentUserId User user) {
// user.getId() funktioniert, da UserDetails implementiert ist // user.getId() funktioniert, da UserDetails implementiert ist
return ResponseEntity.ok(dashboardService.getUserStats(user.getId())); return ResponseEntity.ok(dashboardService.getUserStats(user.getId()));
} }

View File

@ -43,12 +43,15 @@ public class AuthService {
new UsernamePasswordAuthenticationToken(request.email(), request.password()) 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() String role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) .map(GrantedAuthority::getAuthority)
.findFirst() .findFirst()
.orElse("ROLE_MEMBER"); .orElse("ROLE_MEMBER");
String token = generateToken(authentication.getName(), role); String token = generateToken(user, role);
return new AuthResponse(token, authentication.getName(), role); return new AuthResponse(token, authentication.getName(), role);
} }
@ -92,14 +95,15 @@ public class AuthService {
/** /**
* Hilfsmethode zum Bauen des JWT. * Hilfsmethode zum Bauen des JWT.
*/ */
private String generateToken(String email, String role) { private String generateToken(User user, String role) {
Instant now = Instant.now(); Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder() JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("eeg-portal") .issuer("eeg-portal")
.issuedAt(now) .issuedAt(now)
.expiresAt(now.plus(2, ChronoUnit.HOURS)) .expiresAt(now.plus(2, ChronoUnit.HOURS))
.subject(email) .subject(user.getEmail())
.claim("role", role.replace("ROLE_", "")) // Dynamische Rolle einfügen .claim("userId", user.getId().toString())
.claim("role", role.replace("ROLE_", ""))
.build(); .build();
JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256).build(); JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256).build();

View File

@ -98,6 +98,37 @@ paths:
responses: responses:
"200": "200":
description: OK 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: /api/community/memberships/request:
post: post:
tags: tags:
@ -264,6 +295,20 @@ paths:
type: array type: array
items: items:
$ref: "#/components/schemas/EligibleCommunityResponse" $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: /api/auth/verify-email:
get: get:
tags: tags:
@ -321,6 +366,51 @@ components:
memberCount: memberCount:
type: integer type: integer
format: int32 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: MembershipRequest:
type: object type: object
properties: properties:

View File

@ -9,6 +9,7 @@ import {DashboardLayout} from './layout/dashboard-layout/dashboard-layout';
import {DashboardOverview} from './pages/dashboard-overview/dashboard-overview'; import {DashboardOverview} from './pages/dashboard-overview/dashboard-overview';
import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community'; import {AdminEnergyCommunity} from './pages/admin-energy-community/admin-energy-community';
import {VerifyEmailComponent} from './pages/verify-email/verify-email'; import {VerifyEmailComponent} from './pages/verify-email/verify-email';
import {MeteringPointsComponent} from './pages/metering-points/metering-points';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: LandingPage }, { path: '', component: LandingPage },
@ -21,6 +22,11 @@ export const routes: Routes = [
canActivate: [authGuard], canActivate: [authGuard],
children: [ children: [
{ path: '', component: DashboardOverview }, { path: '', component: DashboardOverview },
{
path: 'metering-points',
component: MeteringPointsComponent,
canActivate: [roleGuard(['ADMIN', 'MEMBER'])]
},
{ {
path: 'approvals', path: 'approvals',
component: AdminUserApprovalComponent, component: AdminUserApprovalComponent,

View File

@ -8,6 +8,7 @@ export interface NavItem {
// {label: 'Meine Zählpunkte', path: '/dashboard/metering-points', roles: ['MEMBER']} // {label: 'Meine Zählpunkte', path: '/dashboard/metering-points', roles: ['MEMBER']}
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{label: 'Übersicht', path: '/dashboard', roles: ['ADMIN', 'MEMBER']}, {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: 'Benutzerverwaltung', path: '/dashboard/approvals', roles: ['ADMIN']},
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']} {label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN']}
]; ];

View File

@ -0,0 +1,77 @@
<div class="max-w-5xl">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800">
{{ isAdmin ? 'Alle Zählpunkte im System' : 'Meine Zählpunkte' }}
</h2>
<p class="text-sm text-slate-500 mt-1">
{{ isAdmin
? 'Übersicht aller registrierten Zählpunkte inkl. Besitzer.'
: 'Hier kannst du deine Zählpunkte verwalten und neue hinzufügen.' }}
</p>
</div>
@if (errorMessage()) {
<div class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-4 py-2">
{{ errorMessage() }}
</div>
}
@if (!isAdmin) {
<form [formGroup]="addForm" (ngSubmit)="onSubmit()"
class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6 mb-6 flex flex-col sm:flex-row gap-4 items-start">
<mat-form-field appearance="outline" class="flex-1 w-full">
<mat-label>Zählpunktnummer</mat-label>
<input matInput formControlName="atNumber" placeholder="AT0010000000000000000001100000000">
@if (addForm.get('atNumber')?.hasError('pattern')) {
<mat-error>Muss eine gültige 33-stellige österreichische Zählpunktnummer sein (AT...)</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="w-full sm:w-48">
<mat-label>Typ</mat-label>
<mat-select formControlName="type">
<mat-option value="CONSUMER">Verbraucher</mat-option>
<mat-option value="PRODUCER">Erzeuger</mat-option>
<mat-option value="PROSUME">Prosumer</mat-option>
</mat-select>
</mat-form-field>
<button mat-flat-button color="accent" type="submit" [disabled]="addForm.invalid" class="sm:mt-2">
Hinzufügen
</button>
</form>
}
<div class="overflow-x-auto ring-1 ring-slate-200 rounded-xl bg-white">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 text-sm text-slate-500 border-b border-slate-200">
<th class="py-3 px-6 font-semibold">Zählpunktnummer</th>
<th class="py-3 px-6 font-semibold">Typ</th>
<th class="py-3 px-6 font-semibold">Status</th>
@if (isAdmin) {
<th class="py-3 px-6 font-semibold">Besitzer</th>
}
</tr>
</thead>
<tbody>
@if (isLoading()) {
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 4 : 3">Lade Zählpunkte...</td></tr>
} @else if (points().length === 0) {
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 4 : 3">Keine Zählpunkte vorhanden.</td></tr>
} @else {
@for (point of points(); track point.id) {
<tr class="border-b border-slate-100 last:border-0">
<td class="py-3 px-6 font-mono text-sm text-slate-700">{{ point.atNumber }}</td>
<td class="py-3 px-6 text-sm text-slate-600">{{ point.type }}</td>
<td class="py-3 px-6 text-sm text-slate-600">{{ point.makoState }}</td>
@if (isAdmin) {
<td class="py-3 px-6 text-sm text-slate-600">{{ point.ownerEmail }}</td>
}
</tr>
}
}
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MeteringPoints } from './metering-points';
describe('MeteringPoints', () => {
let component: MeteringPoints;
let fixture: ComponentFixture<MeteringPoints>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MeteringPoints],
}).compileComponents();
fixture = TestBed.createComponent(MeteringPoints);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<MeteringPoint[]>([]);
isLoading = signal(true);
errorMessage = signal('');
addForm = this.fb.group({
atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]],
type: this.fb.control<CreateMeteringPointRequest['type']>('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.');
}
});
}
}

View File

@ -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<MeteringPoint[]> {
return this.http.get<MeteringPoint[]>(`${this.apiUrl}/me`);
}
getAllMeteringPoints(): Observable<MeteringPoint[]> {
return this.http.get<MeteringPoint[]>(this.apiUrl);
}
addMeteringPoint(payload: CreateMeteringPointRequest): Observable<MeteringPoint> {
return this.http.post<MeteringPoint>(this.apiUrl, payload);
}
}