feat(community): add metering point deletion with business rules
Backend:
- Add deleteByIdAndUserId to MeteringPointRepository
- Add deleteOwnMeteringPoint with validation (owner, state checks)
- Add DELETE /{id} endpoint to MeteringPointController
Frontend:
- Add deleteMeteringPoint to MeteringPointService
- Add delete button with confirmation dialog
- Only show delete for inactive points (NEW, REJECTED, ERROR)
Business Rules:
- Only owner can delete own metering points
- Only inactive points (NEW, REJECTED, ERROR) can be deleted
- ACTIVE and WAITING_FOR_CONSENT points cannot be deleted
Tests:
- Add MeteringPointServiceTest with 10 unit tests
- All 37 tests passing
This commit is contained in:
parent
04be9a62ea
commit
96bcf57cee
@ -38,6 +38,15 @@ public class MeteringPointController {
|
||||
return ResponseEntity.ok(meteringPointService.getOwnMeteringPoints(UUID.fromString(userId)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('MEMBER')")
|
||||
public ResponseEntity<Void> deleteOwnMeteringPoint(
|
||||
@CurrentUserId String userId,
|
||||
@PathVariable UUID id) {
|
||||
meteringPointService.deleteOwnMeteringPoint(UUID.fromString(userId), id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// --- ADMIN: alle Zählpunkte im System ---
|
||||
|
||||
@GetMapping
|
||||
|
||||
@ -18,4 +18,6 @@ public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UU
|
||||
Optional<MeteringPoint> findByAtNumber(String atNumber);
|
||||
|
||||
void deleteByUserId(UUID userId);
|
||||
|
||||
void deleteByIdAndUserId(UUID id, UUID userId);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ 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.MakoState;
|
||||
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;
|
||||
@ -81,6 +82,27 @@ public class MeteringPointService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteOwnMeteringPoint(UUID userId, UUID pointId) {
|
||||
MeteringPoint point = meteringPointRepository.findById(pointId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Zählpunkt nicht gefunden"));
|
||||
|
||||
if (!point.getUserId().equals(userId)) {
|
||||
throw new SecurityException("Keine Berechtigung zum Löschen dieses Zählpunkts");
|
||||
}
|
||||
|
||||
if (point.getMakoState() == MakoState.ACTIVE) {
|
||||
throw new IllegalStateException("Aktive Zählpunkte können nicht gelöscht werden");
|
||||
}
|
||||
|
||||
if (point.getMakoState() == MakoState.WAITING_FOR_CONSENT ||
|
||||
point.getMakoState() == MakoState.CONSENT_GRANTED) {
|
||||
throw new IllegalStateException("Zählpunkte im Consent-Flow können nicht gelöscht werden");
|
||||
}
|
||||
|
||||
meteringPointRepository.deleteById(pointId);
|
||||
}
|
||||
|
||||
private MeteringPointResponse toResponse(MeteringPoint mp, String ownerEmail) {
|
||||
return new MeteringPointResponse(
|
||||
mp.getId(),
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
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.PointType;
|
||||
import at.mueller.eeg.backend.community.domain.state.MakoState;
|
||||
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
|
||||
import at.mueller.eeg.backend.iam.domain.ParticipantType;
|
||||
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 org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MeteringPointServiceTest {
|
||||
|
||||
@Mock
|
||||
private MeteringPointRepository meteringPointRepository;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@InjectMocks
|
||||
private MeteringPointService meteringPointService;
|
||||
|
||||
private User testUser;
|
||||
private MeteringPoint testMeteringPoint;
|
||||
private UUID testUserId;
|
||||
private UUID testPointId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUserId = UUID.randomUUID();
|
||||
testPointId = UUID.randomUUID();
|
||||
|
||||
testUser = new User();
|
||||
testUser.setId(testUserId);
|
||||
testUser.setEmail("test@example.com");
|
||||
testUser.setStatus(RegistrationStatus.APPROVED);
|
||||
|
||||
testMeteringPoint = new MeteringPoint();
|
||||
testMeteringPoint.setId(testPointId);
|
||||
testMeteringPoint.setUserId(testUserId);
|
||||
testMeteringPoint.setAtNumber("AT0010000000000000000000001234567");
|
||||
testMeteringPoint.setType(PointType.CONSUMER);
|
||||
testMeteringPoint.setMakoState(MakoState.NEW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addOwnMeteringPoint_createsNewPoint() {
|
||||
when(meteringPointRepository.existsByAtNumber(any())).thenReturn(false);
|
||||
when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser));
|
||||
when(meteringPointRepository.save(any())).thenReturn(testMeteringPoint);
|
||||
|
||||
CreateMeteringPointRequest request = new CreateMeteringPointRequest("AT0010000000000000000000001234567", PointType.CONSUMER);
|
||||
|
||||
MeteringPointResponse response = meteringPointService.addOwnMeteringPoint(testUserId, request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals("AT0010000000000000000000001234567", response.atNumber());
|
||||
verify(meteringPointRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addOwnMeteringPoint_throwsForDuplicateAtNumber() {
|
||||
when(meteringPointRepository.existsByAtNumber(any())).thenReturn(true);
|
||||
|
||||
CreateMeteringPointRequest request = new CreateMeteringPointRequest("AT0010000000000000000000001234567", PointType.CONSUMER);
|
||||
|
||||
assertThrows(AtNumberAlreadyExistsException.class, () ->
|
||||
meteringPointService.addOwnMeteringPoint(testUserId, request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_deletesInactivePoint() {
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId);
|
||||
|
||||
verify(meteringPointRepository).deleteById(testPointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_throwsForWrongOwner() {
|
||||
UUID otherUserId = UUID.randomUUID();
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
assertThrows(SecurityException.class, () ->
|
||||
meteringPointService.deleteOwnMeteringPoint(otherUserId, testPointId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_throwsForActivePoint() {
|
||||
testMeteringPoint.setMakoState(MakoState.ACTIVE);
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_throwsForWaitingPoint() {
|
||||
testMeteringPoint.setMakoState(MakoState.WAITING_FOR_CONSENT);
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_throwsForConsentGrantedPoint() {
|
||||
testMeteringPoint.setMakoState(MakoState.CONSENT_GRANTED);
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_allowsDeleteForRejectedPoint() {
|
||||
testMeteringPoint.setMakoState(MakoState.REJECTED);
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId);
|
||||
|
||||
verify(meteringPointRepository).deleteById(testPointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_allowsDeleteForErrorPoint() {
|
||||
testMeteringPoint.setMakoState(MakoState.ERROR);
|
||||
when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint));
|
||||
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId);
|
||||
|
||||
verify(meteringPointRepository).deleteById(testPointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOwnMeteringPoint_throwsForUnknownPoint() {
|
||||
when(meteringPointRepository.findById(any())).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
meteringPointService.deleteOwnMeteringPoint(testUserId, UUID.randomUUID()));
|
||||
}
|
||||
}
|
||||
@ -52,13 +52,14 @@
|
||||
@if (isAdmin) {
|
||||
<th class="py-3 px-6 font-semibold">Besitzer</th>
|
||||
}
|
||||
<th class="py-3 px-6 font-semibold">Aktion</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>
|
||||
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 5 : 4">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>
|
||||
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 5 : 4">Keine Zählpunkte vorhanden.</td></tr>
|
||||
} @else {
|
||||
@for (point of points(); track point.id) {
|
||||
<tr class="border-b border-slate-100 last:border-0">
|
||||
@ -68,6 +69,15 @@
|
||||
@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' }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ 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';
|
||||
import {ToastService} from '../../services/toast';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metering-points',
|
||||
@ -17,12 +18,14 @@ export class MeteringPointsComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
private authService = inject(AuthService);
|
||||
private meteringPointService = inject(MeteringPointService);
|
||||
private toastService = inject(ToastService);
|
||||
|
||||
isAdmin = this.authService.currentUserRole() === 'ADMIN';
|
||||
|
||||
points = signal<MeteringPoint[]>([]);
|
||||
isLoading = signal(true);
|
||||
errorMessage = signal('');
|
||||
deletingId = signal<string | null>(null);
|
||||
|
||||
addForm = this.fb.group({
|
||||
atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]],
|
||||
@ -66,10 +69,35 @@ export class MeteringPointsComponent implements OnInit {
|
||||
next: created => {
|
||||
this.points.update(list => [...list, created]);
|
||||
this.addForm.reset({atNumber: '', type: 'CONSUMER'});
|
||||
this.toastService.success('Zählpunkt erfolgreich hinzugefügt.');
|
||||
},
|
||||
error: err => {
|
||||
this.errorMessage.set(err.error?.message || 'Zählpunkt konnte nicht hinzugefügt werden.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
canDelete(point: MeteringPoint): boolean {
|
||||
return this.isAdmin || point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR';
|
||||
}
|
||||
|
||||
confirmDelete(point: MeteringPoint) {
|
||||
if (!confirm(`Zählpunkt ${point.atNumber} wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deletingId.set(point.id);
|
||||
|
||||
this.meteringPointService.deleteMeteringPoint(point.id).subscribe({
|
||||
next: () => {
|
||||
this.points.update(list => list.filter(p => p.id !== point.id));
|
||||
this.toastService.success('Zählpunkt erfolgreich gelöscht.');
|
||||
this.deletingId.set(null);
|
||||
},
|
||||
error: err => {
|
||||
this.toastService.error(err.error?.message || 'Zählpunkt konnte nicht gelöscht werden.');
|
||||
this.deletingId.set(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,4 +36,8 @@ export class MeteringPointService {
|
||||
addMeteringPoint(payload: CreateMeteringPointRequest): Observable<MeteringPoint> {
|
||||
return this.http.post<MeteringPoint>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
deleteMeteringPoint(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user