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:
parent
caf282cb1e
commit
cadd5a697e
@ -3,6 +3,7 @@ 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.api.dto.UpdateMeteringPointRequest;
|
||||
import at.mueller.eeg.backend.community.service.MeteringPointService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -47,6 +48,16 @@ public class MeteringPointController {
|
||||
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 ---
|
||||
|
||||
@GetMapping
|
||||
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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.community.api.dto.CreateMeteringPointRequest;
|
||||
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.state.MakoState;
|
||||
import at.mueller.eeg.backend.community.domain.state.MakoTrigger;
|
||||
@ -69,6 +70,24 @@ public class MeteringPointService {
|
||||
.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() {
|
||||
List<MeteringPoint> allPoints = meteringPointRepository.findAll();
|
||||
|
||||
|
||||
@ -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.community.api.dto.CreateMeteringPointRequest;
|
||||
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.PointType;
|
||||
import at.mueller.eeg.backend.community.domain.state.MakoState;
|
||||
@ -162,4 +163,81 @@ class MeteringPointServiceTest {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,18 +64,47 @@
|
||||
@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">
|
||||
@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>
|
||||
@if (isAdmin) {
|
||||
<td class="py-3 px-6 text-sm text-slate-600">{{ point.ownerEmail }}</td>
|
||||
}
|
||||
<td class="py-3 px-6">
|
||||
@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' }}
|
||||
@if (editingId() === point.id) {
|
||||
<button (click)="onEditSubmit(point)"
|
||||
class="text-emerald-600 hover:text-emerald-800 text-sm font-medium mr-2">
|
||||
Speichern
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
@ -26,12 +26,17 @@ export class MeteringPointsComponent implements OnInit {
|
||||
isLoading = signal(true);
|
||||
errorMessage = signal('');
|
||||
deletingId = signal<string | null>(null);
|
||||
editingId = signal<string | null>(null);
|
||||
|
||||
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})
|
||||
});
|
||||
|
||||
editForm = this.fb.group({
|
||||
type: this.fb.control<MeteringPoint['type']>('CONSUMER', {nonNullable: true, validators: Validators.required})
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
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 {
|
||||
return this.isAdmin || point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR';
|
||||
}
|
||||
|
||||
@ -20,6 +20,10 @@ export interface CreateMeteringPointRequest {
|
||||
type: PointType;
|
||||
}
|
||||
|
||||
export interface UpdateMeteringPointRequest {
|
||||
type: PointType;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class MeteringPointService {
|
||||
private http = inject(HttpClient);
|
||||
@ -40,4 +44,8 @@ export class MeteringPointService {
|
||||
deleteMeteringPoint(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
updateMeteringPoint(id: string, payload: UpdateMeteringPointRequest): Observable<MeteringPoint> {
|
||||
return this.http.put<MeteringPoint>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user