feat(mako): add admin-selectable EDA communication method (email vs messenger)

- Add EdaCommunicationMethod enum (EMAIL, MESSENGER)
- Add EdaSettings entity with DB-persisted communication method
- Add AdminEdaSettingsController (GET/PUT /api/admin/eda-settings)
- Add EdaCommunicationRouter that resolves the correct EdaConsentService
- Add MessengerEdaConsentService as placeholder (throws UnsupportedOperationException)
- Add AdminEdaSettingsComponent in frontend with radio button selection
- Route /dashboard/admin-eda-settings (ADMIN only)
- Update EdaCommunicationEventListener to use router
- Remove @Profile from EmailEdaConsentService/SimulatedEdaConsentService
- Use @Lazy for EdaMailSender dependency to support non-prod contexts
- Fix pre-existing deleteUserTariff API call mismatch
This commit is contained in:
Bernhard Müller 2026-07-25 15:29:58 +02:00
parent 459f976b67
commit 675c10b949
16 changed files with 380 additions and 4 deletions

View file

@ -0,0 +1,35 @@
package at.mueller.eeg.backend.mako.api;
import at.mueller.eeg.backend.mako.api.dto.EdaSettingsDto;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import at.mueller.eeg.backend.mako.service.EdaSettingsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping(value = "/api/admin/eda-settings", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Admin EDA Settings", description = "Konfiguration des EDA-Kommunikationswegs")
@RequiredArgsConstructor
public class AdminEdaSettingsController {
private final EdaSettingsService edaSettingsService;
@GetMapping
@Operation(summary = "Aktuelle EDA-Einstellungen abrufen")
public EdaSettingsDto getSettings() {
EdaSettings settings = edaSettingsService.getSettings();
return new EdaSettingsDto(settings.getCommunicationMethod());
}
@PutMapping
@Operation(summary = "EDA-Kommunikationsweg aktualisieren")
public EdaSettingsDto updateSettings(@RequestBody EdaSettingsDto dto) {
EdaSettings updated = edaSettingsService.updateCommunicationMethod(dto.communicationMethod());
return new EdaSettingsDto(updated.getCommunicationMethod());
}
}

View file

@ -0,0 +1,6 @@
package at.mueller.eeg.backend.mako.api.dto;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
public record EdaSettingsDto(EdaCommunicationMethod communicationMethod) {
}

View file

@ -0,0 +1,6 @@
package at.mueller.eeg.backend.mako.domain;
public enum EdaCommunicationMethod {
EMAIL,
MESSENGER
}

View file

@ -0,0 +1,22 @@
package at.mueller.eeg.backend.mako.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Entity
@Table(name = "eda_settings")
@Getter
@Setter
public class EdaSettings {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private EdaCommunicationMethod communicationMethod = EdaCommunicationMethod.EMAIL;
}

View file

@ -2,7 +2,7 @@ package at.mueller.eeg.backend.mako.event.listener;
import at.mueller.eeg.backend.common.event.*;
import at.mueller.eeg.backend.mako.api.dto.EdaTopologyResponse;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import at.mueller.eeg.backend.mako.service.EdaCommunicationRouter;
import at.mueller.eeg.backend.mako.service.EdaTopologyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -18,7 +18,7 @@ import org.springframework.stereotype.Component;
public class EdaCommunicationEventListener {
private final EdaTopologyService edaTopologyService;
private final EdaConsentService edaConsentService;
private final EdaCommunicationRouter edaCommunicationRouter;
private final ApplicationEventPublisher eventPublisher;
@EventListener
@ -59,7 +59,7 @@ public class EdaCommunicationEventListener {
event.atNumber(), event.pointType());
try {
edaConsentService.sendConsentRequest(event.atNumber(), event.pointType());
edaCommunicationRouter.resolve().sendConsentRequest(event.atNumber(), event.pointType());
log.info("MaKo-Domain: Consent-Request erfolgreich an EDA übermittelt für {}", event.atNumber());

View file

@ -0,0 +1,11 @@
package at.mueller.eeg.backend.mako.repository;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface EdaSettingsRepository extends JpaRepository<EdaSettings, UUID> {
Optional<EdaSettings> findFirstByOrderByIdAsc();
}

View file

@ -0,0 +1,52 @@
package at.mueller.eeg.backend.mako.service;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import java.util.Arrays;
@Slf4j
@Service
public class EdaCommunicationRouter {
private final EdaSettingsService settingsService;
private final Environment environment;
private final EdaConsentService simulatedService;
private final ObjectProvider<EdaConsentService> emailServiceProvider;
private final ObjectProvider<EdaConsentService> messengerServiceProvider;
public EdaCommunicationRouter(
EdaSettingsService settingsService,
Environment environment,
@Qualifier("simulatedEdaConsentService") EdaConsentService simulatedService,
@Qualifier("emailEdaConsentService") ObjectProvider<EdaConsentService> emailServiceProvider,
@Qualifier("messengerEdaConsentService") ObjectProvider<EdaConsentService> messengerServiceProvider) {
this.settingsService = settingsService;
this.environment = environment;
this.simulatedService = simulatedService;
this.emailServiceProvider = emailServiceProvider;
this.messengerServiceProvider = messengerServiceProvider;
}
public EdaConsentService resolve() {
if (Arrays.asList(environment.getActiveProfiles()).contains("dev")) {
log.debug("[EDA-ROUTER] Dev-Modus: Simulation wird verwendet.");
return simulatedService;
}
EdaSettings settings = settingsService.getSettings();
EdaCommunicationMethod method = settings.getCommunicationMethod();
log.info("[EDA-ROUTER] Kommunikationsweg = {}", method);
return switch (method) {
case EMAIL -> emailServiceProvider.getObject();
case MESSENGER -> messengerServiceProvider.getObject();
};
}
}

View file

@ -0,0 +1,38 @@
package at.mueller.eeg.backend.mako.service;
import at.mueller.eeg.backend.mako.domain.EdaCommunicationMethod;
import at.mueller.eeg.backend.mako.domain.EdaSettings;
import at.mueller.eeg.backend.mako.repository.EdaSettingsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class EdaSettingsService {
private final EdaSettingsRepository repository;
@Transactional(readOnly = true)
public EdaSettings getSettings() {
return repository.findFirstByOrderByIdAsc()
.orElseGet(this::createDefault);
}
@Transactional
public EdaSettings updateCommunicationMethod(EdaCommunicationMethod method) {
EdaSettings settings = getSettings();
settings.setCommunicationMethod(method);
EdaSettings saved = repository.save(settings);
log.info("EDA-Kommunikationsweg geaendert auf: {}", method);
return saved;
}
private EdaSettings createDefault() {
EdaSettings defaultSettings = new EdaSettings();
defaultSettings.setCommunicationMethod(EdaCommunicationMethod.EMAIL);
return repository.save(defaultSettings);
}
}

View file

@ -0,0 +1,16 @@
package at.mueller.eeg.backend.mako.service.impl;
import at.mueller.eeg.backend.mako.service.EdaConsentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service("messengerEdaConsentService")
public class MessengerEdaConsentService implements EdaConsentService {
@Override
public void sendConsentRequest(String atNumber, String pointType) {
log.warn("[EDA-MESSENGER] Messenger-Interface noch nicht implementiert. AT-Nummer: {}, Typ: {}", atNumber, pointType);
throw new UnsupportedOperationException("Messenger-Interface fuer EDA-Kommunikation noch nicht implementiert.");
}
}

View file

@ -10,6 +10,8 @@ tags:
description: Public Endpoints für Login und Registrierung
- name: User Profile
description: Profilverwaltung und Passwort-Reset
- name: Admin EDA Settings
description: Konfiguration des EDA-Kommunikationswegs
- name: Admin IAM
description: User-Verwaltung und Freigabe-Workflow
paths:
@ -305,6 +307,37 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/CommunityTariffResponse"
/api/admin/eda-settings:
get:
tags:
- Admin EDA Settings
summary: Aktuelle EDA-Einstellungen abrufen
operationId: getSettings
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
put:
tags:
- Admin EDA Settings
summary: EDA-Kommunikationsweg aktualisieren
operationId: updateSettings
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
required: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/EdaSettingsDto"
/api/tariffs:
post:
tags:
@ -1235,6 +1268,14 @@ components:
updatedAt:
type: string
format: date-time
EdaSettingsDto:
type: object
properties:
communicationMethod:
type: string
enum:
- EMAIL
- MESSENGER
TariffInviteRequest:
type: object
properties:
@ -1339,6 +1380,7 @@ components:
type: string
enum:
- EMAIL_XLSX
- EMAIL_AUTO
- API
- MANUAL
fileName:
@ -1630,6 +1672,7 @@ components:
type: string
enum:
- EMAIL_XLSX
- EMAIL_AUTO
- API
- MANUAL
MembershipResponse:

View file

@ -22,6 +22,7 @@ import {ConsumptionDashboardComponent} from './pages/consumption-dashboard/consu
import {UploadMeteringDataComponent} from './pages/upload-metering-data/upload-metering-data';
import {ProducerInvitesComponent} from './pages/producer-invites/producer-invites';
import {ConsumerInvitesComponent} from './pages/consumer-invites/consumer-invites';
import {AdminEdaSettingsComponent} from './pages/admin-eda-settings/admin-eda-settings';
export const routes: Routes = [
{ path: '', component: LandingPage },
@ -76,6 +77,11 @@ export const routes: Routes = [
component: UploadMeteringDataComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'admin-eda-settings',
component: AdminEdaSettingsComponent,
canActivate: [roleGuard(['ADMIN'])]
},
{
path: 'my-tariffs',
component: UserTariffComponent,

View file

@ -29,4 +29,5 @@ export const NAV_ITEMS: NavItem[] = [
{label: 'Energiegemeinschaften', path: '/dashboard/energy-communities', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'Tarife verwalten', path: '/dashboard/tariffs', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'Messdaten-Upload', path: '/dashboard/upload-metering-data', roles: ['ADMIN'], section: 'Verwaltung'},
{label: 'EDA-Einstellungen', path: '/dashboard/admin-eda-settings', roles: ['ADMIN'], section: 'Verwaltung'},
];

View file

@ -0,0 +1,54 @@
<div class="max-w-2xl mx-auto p-4 md:p-6">
<div class="mb-6">
<h2 class="text-2xl font-bold text-slate-800">EDA-Einstellungen</h2>
<p class="text-sm text-slate-500 mt-1">Konfiguriere den Kommunikationsweg fuer die EDA-Zustimmungsabfrage.</p>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-6">
@if (isLoading()) {
<div class="text-center py-4 text-slate-500">Lade Einstellungen...</div>
} @else {
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-slate-700 mb-3">Kommunikationsweg</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-4 border border-slate-200 rounded-xl cursor-pointer transition-colors"
[class.border-blue-500]="selectedMethod() === 'EMAIL'"
[class.bg-blue-50]="selectedMethod() === 'EMAIL'"
[class.hover:border-slate-300]="selectedMethod() !== 'EMAIL'">
<input type="radio" name="edaMethod" value="EMAIL"
[checked]="selectedMethod() === 'EMAIL'"
(change)="selectedMethod.set('EMAIL')"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<div class="font-medium text-slate-800">E-Mail</div>
<div class="text-sm text-slate-500 mt-0.5">Consent-Requests werden per E-Mail an das EDA-Gateway gesendet.</div>
</div>
</label>
<label class="flex items-start gap-3 p-4 border border-slate-200 rounded-xl cursor-pointer transition-colors"
[class.border-blue-500]="selectedMethod() === 'MESSENGER'"
[class.bg-blue-50]="selectedMethod() === 'MESSENGER'"
[class.hover:border-slate-300]="selectedMethod() !== 'MESSENGER'">
<input type="radio" name="edaMethod" value="MESSENGER"
[checked]="selectedMethod() === 'MESSENGER'"
(change)="selectedMethod.set('MESSENGER')"
class="mt-0.5 text-blue-600 focus:ring-blue-500">
<div>
<div class="font-medium text-slate-800">Messenger</div>
<div class="text-sm text-slate-500 mt-0.5">Consent-Requests werden ueber den Messenger gesendet (noch nicht implementiert).</div>
</div>
</label>
</div>
</div>
<div class="flex justify-end">
<button (click)="saveSettings()" [disabled]="isSaving()"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-xl font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed">
{{ isSaving() ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</div>
}
</div>
</div>

View file

@ -0,0 +1,28 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {provideHttpClient} from '@angular/common/http';
import {provideHttpClientTesting} from '@angular/common/http/testing';
import {AdminEdaSettingsComponent} from './admin-eda-settings';
import {describe, expect, it} from 'vitest';
describe('AdminEdaSettingsComponent', () => {
let component: AdminEdaSettingsComponent;
let fixture: ComponentFixture<AdminEdaSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdminEdaSettingsComponent],
providers: [provideHttpClient(), provideHttpClientTesting()]
}).compileComponents();
fixture = TestBed.createComponent(AdminEdaSettingsComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default method EMAIL', () => {
expect(component.selectedMethod()).toBe('EMAIL');
});
});

View file

@ -0,0 +1,58 @@
import {Component, inject, OnInit, signal} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {ToastService} from '../../services/toast';
import {AdminEDASettingsService, EdaSettingsDto} from '../../api';
@Component({
selector: 'app-admin-eda-settings',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-eda-settings.html',
})
export class AdminEdaSettingsComponent implements OnInit {
private edaSettingsService = inject(AdminEDASettingsService);
private toastService = inject(ToastService);
selectedMethod = signal<EdaSettingsDto.CommunicationMethodEnum>('EMAIL');
isLoading = signal(false);
isSaving = signal(false);
ngOnInit() {
this.loadSettings();
}
loadSettings() {
this.isLoading.set(true);
this.edaSettingsService.getSettings().subscribe({
next: (settings) => {
if (settings.communicationMethod) {
this.selectedMethod.set(settings.communicationMethod);
}
this.isLoading.set(false);
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Laden der EDA-Einstellungen.');
this.isLoading.set(false);
}
});
}
saveSettings() {
this.isSaving.set(true);
const dto: EdaSettingsDto = {communicationMethod: this.selectedMethod()};
this.edaSettingsService.updateSettings(dto).subscribe({
next: (settings) => {
if (settings.communicationMethod) {
this.selectedMethod.set(settings.communicationMethod);
}
this.toastService.success('EDA-Einstellungen erfolgreich gespeichert.');
this.isSaving.set(false);
},
error: (err) => {
this.toastService.error(err.error?.message || 'Fehler beim Speichern der EDA-Einstellungen.');
this.isSaving.set(false);
}
});
}
}

View file

@ -195,7 +195,7 @@ export class UserTariffComponent implements OnInit, OnDestroy {
return;
}
this.userTariffService.deleteUserTariff(tariff.id!, userId).subscribe({
this.userTariffService.deleteUserTariff(tariff.id!).subscribe({
next: () => {
this.toastService.success('Tarif erfolgreich gelöscht.');
this.loadUserTariffs(this.selectedCommunityId());