feat(community): add metering point update endpoint and UI

- Add UpdateMeteringPointRequest DTO (type-only update)
- Add PUT /{id} endpoint with owner and state validation
- Only NEW/REJECTED/ERROR states allow editing
- Frontend: inline edit form with type dropdown
- Frontend: updateMeteringPoint() service method
- Add 7 unit tests for update workflow
This commit is contained in:
Bernhard Müller 2026-07-22 07:42:22 +02:00
parent caf282cb1e
commit cadd5a697e
7 changed files with 196 additions and 6 deletions

View File

@ -3,6 +3,7 @@ package at.mueller.eeg.backend.community.api;
import at.mueller.eeg.backend.common.security.CurrentUserId; 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.CreateMeteringPointRequest;
import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse;
import at.mueller.eeg.backend.community.api.dto.UpdateMeteringPointRequest;
import at.mueller.eeg.backend.community.service.MeteringPointService; import at.mueller.eeg.backend.community.service.MeteringPointService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -47,6 +48,16 @@ public class MeteringPointController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping("/{id}")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<MeteringPointResponse> updateOwnMeteringPoint(
@CurrentUserId String userId,
@PathVariable UUID id,
@Valid @RequestBody UpdateMeteringPointRequest request) {
MeteringPointResponse updated = meteringPointService.updateOwnMeteringPoint(UUID.fromString(userId), id, request);
return ResponseEntity.ok(updated);
}
// --- ADMIN: alle Zählpunkte im System --- // --- ADMIN: alle Zählpunkte im System ---
@GetMapping @GetMapping

View File

@ -0,0 +1,10 @@
package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.PointType;
import jakarta.validation.constraints.NotNull;
public record UpdateMeteringPointRequest(
@NotNull
PointType type
) {
}

View File

@ -4,6 +4,7 @@ import at.mueller.eeg.backend.common.event.InitiateMakoConsentEvent;
import at.mueller.eeg.backend.common.exception.AtNumberAlreadyExistsException; 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.CreateMeteringPointRequest;
import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse;
import at.mueller.eeg.backend.community.api.dto.UpdateMeteringPointRequest;
import at.mueller.eeg.backend.community.domain.MeteringPoint; import at.mueller.eeg.backend.community.domain.MeteringPoint;
import at.mueller.eeg.backend.community.domain.state.MakoState; import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.domain.state.MakoTrigger; import at.mueller.eeg.backend.community.domain.state.MakoTrigger;
@ -69,6 +70,24 @@ public class MeteringPointService {
.toList(); .toList();
} }
@Transactional
public MeteringPointResponse updateOwnMeteringPoint(UUID userId, UUID pointId, UpdateMeteringPointRequest request) {
MeteringPoint point = meteringPointRepository.findById(pointId)
.orElseThrow(() -> new IllegalArgumentException("Zählpunkt nicht gefunden"));
if (!point.getUserId().equals(userId)) {
throw new org.springframework.security.access.AccessDeniedException("Keine Berechtigung zum Bearbeiten dieses Zählpunkts");
}
if (point.getMakoState() != MakoState.NEW && point.getMakoState() != MakoState.REJECTED && point.getMakoState() != MakoState.ERROR) {
throw new IllegalStateException("Zählpunkte im Consent-Flow oder aktive Zählpunkte können nicht bearbeitet werden");
}
point.setType(request.type());
MeteringPoint saved = meteringPointRepository.save(point);
return toResponse(saved, null);
}
public List<MeteringPointResponse> getAllMeteringPoints() { public List<MeteringPointResponse> getAllMeteringPoints() {
List<MeteringPoint> allPoints = meteringPointRepository.findAll(); List<MeteringPoint> allPoints = meteringPointRepository.findAll();

View File

@ -4,6 +4,7 @@ import at.mueller.eeg.backend.common.event.InitiateMakoConsentEvent;
import at.mueller.eeg.backend.common.exception.AtNumberAlreadyExistsException; 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.CreateMeteringPointRequest;
import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse;
import at.mueller.eeg.backend.community.api.dto.UpdateMeteringPointRequest;
import at.mueller.eeg.backend.community.domain.MeteringPoint; 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.MakoState; import at.mueller.eeg.backend.community.domain.state.MakoState;
@ -162,4 +163,81 @@ class MeteringPointServiceTest {
assertThrows(IllegalArgumentException.class, () -> assertThrows(IllegalArgumentException.class, () ->
meteringPointService.deleteOwnMeteringPoint(testUserId, UUID.randomUUID())); meteringPointService.deleteOwnMeteringPoint(testUserId, UUID.randomUUID()));
} }
@Test
void updateOwnMeteringPoint_updatesType() {
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
when(meteringPointRepository.save(any())).thenReturn(testMeteringPoint);
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
MeteringPointResponse response = meteringPointService.updateOwnMeteringPoint(testUserId, testPointId, request);
assertNotNull(response);
verify(meteringPointRepository).save(any());
}
@Test
void updateOwnMeteringPoint_throwsForWrongOwner() {
UUID otherUserId = UUID.randomUUID();
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
assertThrows(org.springframework.security.access.AccessDeniedException.class, () ->
meteringPointService.updateOwnMeteringPoint(otherUserId, testPointId, request));
}
@Test
void updateOwnMeteringPoint_throwsForActivePoint() {
testMeteringPoint.setMakoState(MakoState.ACTIVE);
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
assertThrows(IllegalStateException.class, () ->
meteringPointService.updateOwnMeteringPoint(testUserId, testPointId, request));
}
@Test
void updateOwnMeteringPoint_throwsForWaitingPoint() {
testMeteringPoint.setMakoState(MakoState.WAITING_FOR_CONSENT);
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
assertThrows(IllegalStateException.class, () ->
meteringPointService.updateOwnMeteringPoint(testUserId, testPointId, request));
}
@Test
void updateOwnMeteringPoint_allowsUpdateForRejectedPoint() {
testMeteringPoint.setMakoState(MakoState.REJECTED);
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
when(meteringPointRepository.save(any())).thenReturn(testMeteringPoint);
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
MeteringPointResponse response = meteringPointService.updateOwnMeteringPoint(testUserId, testPointId, request);
assertNotNull(response);
verify(meteringPointRepository).save(any());
}
@Test
void updateOwnMeteringPoint_allowsUpdateForErrorPoint() {
testMeteringPoint.setMakoState(MakoState.ERROR);
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
when(meteringPointRepository.save(any())).thenReturn(testMeteringPoint);
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
MeteringPointResponse response = meteringPointService.updateOwnMeteringPoint(testUserId, testPointId, request);
assertNotNull(response);
verify(meteringPointRepository).save(any());
}
@Test
void updateOwnMeteringPoint_throwsForUnknownPoint() {
when(meteringPointRepository.findById(any())).thenReturn(Optional.empty());
UpdateMeteringPointRequest request = new UpdateMeteringPointRequest(PointType.PRODUCER);
assertThrows(IllegalArgumentException.class, () ->
meteringPointService.updateOwnMeteringPoint(testUserId, UUID.randomUUID(), request));
}
} }

View File

@ -64,18 +64,47 @@
@for (point of points(); track point.id) { @for (point of points(); track point.id) {
<tr class="border-b border-slate-100 last:border-0"> <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 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">
@if (editingId() === point.id) {
<mat-form-field appearance="outline" class="w-40">
<mat-select [formControl]="editForm.controls.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>
} @else {
{{ point.type }}
}
</td>
<td class="py-3 px-6 text-sm text-slate-600">{{ point.makoState }}</td> <td class="py-3 px-6 text-sm text-slate-600">{{ point.makoState }}</td>
@if (isAdmin) { @if (isAdmin) {
<td class="py-3 px-6 text-sm text-slate-600">{{ point.ownerEmail }}</td> <td class="py-3 px-6 text-sm text-slate-600">{{ point.ownerEmail }}</td>
} }
<td class="py-3 px-6"> <td class="py-3 px-6">
@if (canDelete(point)) { @if (editingId() === point.id) {
<button (click)="confirmDelete(point)" <button (click)="onEditSubmit(point)"
[disabled]="deletingId() === point.id" class="text-emerald-600 hover:text-emerald-800 text-sm font-medium mr-2">
class="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50"> Speichern
{{ deletingId() === point.id ? 'Lösche...' : 'Löschen' }}
</button> </button>
<button (click)="cancelEdit()"
class="text-slate-500 hover:text-slate-700 text-sm font-medium">
Abbrechen
</button>
} @else {
@if (canEdit(point)) {
<button (click)="startEdit(point)"
class="text-blue-600 hover:text-blue-800 text-sm font-medium mr-2">
Bearbeiten
</button>
}
@if (canDelete(point)) {
<button (click)="confirmDelete(point)"
[disabled]="deletingId() === point.id"
class="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50">
{{ deletingId() === point.id ? 'Lösche...' : 'Löschen' }}
</button>
}
} }
</td> </td>
</tr> </tr>

View File

@ -26,12 +26,17 @@ export class MeteringPointsComponent implements OnInit {
isLoading = signal(true); isLoading = signal(true);
errorMessage = signal(''); errorMessage = signal('');
deletingId = signal<string | null>(null); deletingId = signal<string | null>(null);
editingId = signal<string | null>(null);
addForm = this.fb.group({ addForm = this.fb.group({
atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]], atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]],
type: this.fb.control<CreateMeteringPointRequest['type']>('CONSUMER', {nonNullable: true, validators: Validators.required}) type: this.fb.control<CreateMeteringPointRequest['type']>('CONSUMER', {nonNullable: true, validators: Validators.required})
}); });
editForm = this.fb.group({
type: this.fb.control<MeteringPoint['type']>('CONSUMER', {nonNullable: true, validators: Validators.required})
});
ngOnInit() { ngOnInit() {
this.loadPoints(); this.loadPoints();
} }
@ -77,6 +82,36 @@ export class MeteringPointsComponent implements OnInit {
}); });
} }
canEdit(point: MeteringPoint): boolean {
return !this.isAdmin && (point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR');
}
startEdit(point: MeteringPoint) {
this.editingId.set(point.id);
this.editForm.patchValue({type: point.type});
}
cancelEdit() {
this.editingId.set(null);
}
onEditSubmit(point: MeteringPoint) {
if (this.editForm.invalid) return;
const payload = {type: this.editForm.value.type!};
this.meteringPointService.updateMeteringPoint(point.id, payload).subscribe({
next: updated => {
this.points.update(list => list.map(p => p.id === updated.id ? updated : p));
this.editingId.set(null);
this.toastService.success('Zählpunkt erfolgreich aktualisiert.');
},
error: err => {
this.toastService.error(err.error?.message || 'Zählpunkt konnte nicht aktualisiert werden.');
}
});
}
canDelete(point: MeteringPoint): boolean { canDelete(point: MeteringPoint): boolean {
return this.isAdmin || point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR'; return this.isAdmin || point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR';
} }

View File

@ -20,6 +20,10 @@ export interface CreateMeteringPointRequest {
type: PointType; type: PointType;
} }
export interface UpdateMeteringPointRequest {
type: PointType;
}
@Injectable({providedIn: 'root'}) @Injectable({providedIn: 'root'})
export class MeteringPointService { export class MeteringPointService {
private http = inject(HttpClient); private http = inject(HttpClient);
@ -40,4 +44,8 @@ export class MeteringPointService {
deleteMeteringPoint(id: string): Observable<void> { deleteMeteringPoint(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`); return this.http.delete<void>(`${this.apiUrl}/${id}`);
} }
updateMeteringPoint(id: string, payload: UpdateMeteringPointRequest): Observable<MeteringPoint> {
return this.http.put<MeteringPoint>(`${this.apiUrl}/${id}`, payload);
}
} }