Swyx Core Grundgerüst: Webhook-Service, Admin-UI und Outlook-Plugin

Initiale Projektstruktur mit Spring Boot 3.4 + Vaadin 24.6:
- Webhook-Endpoint zur Entgegennahme von Swyx-Anrufsignalisierungen
- Vaadin-basierte Admin-Oberfläche (Dashboard, Events, Login) mit
  custom Theme 'swyx-admin' und Spring-Security-Schutz (In-Memory
  Admin aus swyx.admin.*)
- Plugin-Framework mit ApplicationReadyEvent-gesteuertem Lifecycle
- Outlook-Plugin: stündlicher Kontakt-Sync via Microsoft Graph
  (Client-Credentials-Flow) mit Paging
- MongoDB-Auto-Konfiguration via spring-boot-starter-data-mongodb
- Env-basierte Konfiguration (.env / .env.example) mit Fallback-
  Defaults in application.yml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 12:59:04 +02:00
parent 62d34de239
commit a5ed2b3355
34 changed files with 16564 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
body, #outlet {
height: 100vh;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>

View File

@@ -0,0 +1 @@
/* swyx-admin theme styles */

View File

@@ -0,0 +1,3 @@
{
"lumoImports": ["typography", "color", "spacing", "badge", "utility"]
}

View File

@@ -0,0 +1,20 @@
package de.assecutor.swyx;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.theme.Theme;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@Push
@Theme("swyx-admin")
@EnableScheduling
@SpringBootApplication
public class SwyxCoreApplication implements AppShellConfigurator {
public static void main(String[] args) {
SpringApplication.run(SwyxCoreApplication.class, args);
}
}

View File

@@ -0,0 +1,8 @@
package de.assecutor.swyx.plugins;
public interface Plugin {
String name();
void start();
}

View File

@@ -0,0 +1,38 @@
package de.assecutor.swyx.plugins;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class PluginManager {
private static final Logger log = LoggerFactory.getLogger(PluginManager.class);
private final List<Plugin> plugins;
public PluginManager(List<Plugin> plugins) {
this.plugins = plugins;
}
@EventListener(ApplicationReadyEvent.class)
public void startAll() {
log.info("Starting {} plugin(s)", plugins.size());
for (Plugin plugin : plugins) {
try {
log.info("Starting plugin '{}'", plugin.name());
plugin.start();
} catch (Exception ex) {
log.error("Failed to start plugin '{}'", plugin.name(), ex);
}
}
}
public List<Plugin> getPlugins() {
return List.copyOf(plugins);
}
}

View File

@@ -0,0 +1,20 @@
package de.assecutor.swyx.plugins.outlook;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record OutlookContact(
String id,
String userPrincipalName,
String displayName,
String givenName,
String surname,
String mail,
String mobilePhone,
@JsonProperty("businessPhones") List<String> businessPhones,
String jobTitle,
String department) {
}

View File

@@ -0,0 +1,116 @@
package de.assecutor.swyx.plugins.outlook;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClient;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class OutlookContactService {
private static final Logger log = LoggerFactory.getLogger(OutlookContactService.class);
private final OutlookProperties properties;
private final RestClient restClient = RestClient.create();
private final AtomicReference<List<OutlookContact>> cache = new AtomicReference<>(List.of());
private volatile Instant lastSync;
public OutlookContactService(OutlookProperties properties) {
this.properties = properties;
}
public List<OutlookContact> getContacts() {
return cache.get();
}
public Instant getLastSync() {
return lastSync;
}
public List<OutlookContact> sync() {
if (!properties.enabled()) {
log.debug("Outlook plugin disabled, skipping sync");
return cache.get();
}
if (isBlank(properties.tenantId()) || isBlank(properties.clientId()) || isBlank(properties.clientSecret())) {
log.warn("Outlook credentials incomplete — skipping sync");
return cache.get();
}
String accessToken = requestAccessToken();
List<OutlookContact> contacts = fetchAllContacts(accessToken);
cache.set(List.copyOf(contacts));
lastSync = Instant.now();
log.info("Outlook sync completed: {} contacts loaded", contacts.size());
return contacts;
}
private String requestAccessToken() {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("client_id", properties.clientId());
form.add("client_secret", properties.clientSecret());
form.add("scope", properties.scope());
form.add("grant_type", "client_credentials");
TokenResponse response = restClient.post()
.uri(properties.tokenUrl())
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(form)
.retrieve()
.body(TokenResponse.class);
if (response == null || isBlank(response.accessToken())) {
throw new IllegalStateException("Outlook token endpoint returned no access_token");
}
return response.accessToken();
}
private List<OutlookContact> fetchAllContacts(String accessToken) {
List<OutlookContact> all = new ArrayList<>();
String url = properties.baseUrl() + "/users?$select=id,userPrincipalName,displayName,givenName,surname,mail,mobilePhone,businessPhones,jobTitle,department&$top=100";
while (url != null && !url.isBlank()) {
UsersResponse response = restClient.get()
.uri(url)
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.body(UsersResponse.class);
if (response == null) {
break;
}
if (response.value() != null) {
all.addAll(response.value());
}
url = response.nextLink();
}
return Collections.unmodifiableList(all);
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record TokenResponse(@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") Long expiresIn) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record UsersResponse(List<OutlookContact> value,
@JsonProperty("@odata.nextLink") String nextLink) {
}
}

View File

@@ -0,0 +1,56 @@
package de.assecutor.swyx.plugins.outlook;
import de.assecutor.swyx.plugins.Plugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
@Configuration
@EnableConfigurationProperties(OutlookProperties.class)
public class OutlookPlugin implements Plugin {
private static final Logger log = LoggerFactory.getLogger(OutlookPlugin.class);
private final OutlookProperties properties;
private final OutlookContactService contactService;
public OutlookPlugin(OutlookProperties properties, OutlookContactService contactService) {
this.properties = properties;
this.contactService = contactService;
}
@Override
public String name() {
return "outlook";
}
@Override
public void start() {
if (!properties.enabled()) {
log.info("Outlook plugin disabled via configuration");
return;
}
log.info("Outlook plugin starting — performing initial contact sync");
try {
contactService.sync();
} catch (Exception ex) {
log.error("Initial Outlook contact sync failed", ex);
}
}
@Scheduled(fixedRate = 60L * 60L * 1000L)
public void scheduledSync() {
if (!properties.enabled()) {
return;
}
try {
contactService.sync();
} catch (Exception ex) {
log.error("Scheduled Outlook contact sync failed", ex);
}
}
}

View File

@@ -0,0 +1,28 @@
package de.assecutor.swyx.plugins.outlook;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "swyx.plugins.outlook")
public record OutlookProperties(
boolean enabled,
String tenantId,
String clientId,
String clientSecret,
String baseUrl,
String tokenUrl,
String scope) {
public OutlookProperties {
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = "https://graph.microsoft.com/v1.0";
}
if (tokenUrl == null || tokenUrl.isBlank()) {
tokenUrl = tenantId != null
? "https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token"
: "";
}
if (scope == null || scope.isBlank()) {
scope = "https://graph.microsoft.com/.default";
}
}
}

View File

@@ -0,0 +1,7 @@
package de.assecutor.swyx.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "swyx.admin")
public record AdminAccountProperties(String username, String password) {
}

View File

@@ -0,0 +1,53 @@
package de.assecutor.swyx.security;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import de.assecutor.swyx.ui.view.LoginView;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(AdminAccountProperties.class)
public class SecurityConfig extends VaadinWebSecurity {
private final AdminAccountProperties adminAccount;
public SecurityConfig(AdminAccountProperties adminAccount) {
this.adminAccount = adminAccount;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails admin = User.builder()
.username(adminAccount.username())
.password(encoder.encode(adminAccount.password()))
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/api/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/actuator/health")).permitAll());
http.csrf(csrf -> csrf.ignoringRequestMatchers(new AntPathRequestMatcher("/api/**")));
super.configure(http);
setLoginView(http, LoginView.class);
}
}

View File

@@ -0,0 +1,25 @@
package de.assecutor.swyx.security;
import com.vaadin.flow.spring.security.AuthenticationContext;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class SecurityService {
private final AuthenticationContext authenticationContext;
public SecurityService(AuthenticationContext authenticationContext) {
this.authenticationContext = authenticationContext;
}
public Optional<UserDetails> getAuthenticatedUser() {
return authenticationContext.getAuthenticatedUser(UserDetails.class);
}
public void logout() {
authenticationContext.logout();
}
}

View File

@@ -0,0 +1,79 @@
package de.assecutor.swyx.ui;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.applayout.DrawerToggle;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.sidenav.SideNav;
import com.vaadin.flow.component.sidenav.SideNavItem;
import com.vaadin.flow.router.Layout;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.security.SecurityService;
import de.assecutor.swyx.ui.view.DashboardView;
import de.assecutor.swyx.ui.view.EventsView;
import jakarta.annotation.security.PermitAll;
@Layout
@PermitAll
public class MainLayout extends AppLayout {
private final SecurityService securityService;
public MainLayout(SecurityService securityService) {
this.securityService = securityService;
setPrimarySection(Section.DRAWER);
addHeaderContent();
addDrawerContent();
}
private void addHeaderContent() {
DrawerToggle toggle = new DrawerToggle();
toggle.setAriaLabel("Menü umschalten");
H1 title = new H1("Swyx Admin");
title.addClassNames(LumoUtility.FontSize.LARGE, LumoUtility.Margin.NONE);
Span status = new Span(VaadinIcon.CIRCLE.create(), new Span(" Webhook aktiv"));
status.getStyle().set("color", "var(--lumo-success-color)");
status.getStyle().set("font-size", "var(--lumo-font-size-s)");
HorizontalLayout rightSection = new HorizontalLayout(status);
rightSection.setAlignItems(FlexComponent.Alignment.CENTER);
rightSection.setSpacing(true);
securityService.getAuthenticatedUser().ifPresent(user -> {
Span userName = new Span(user.getUsername());
userName.addClassNames(LumoUtility.FontWeight.MEDIUM);
Button logoutBtn = new Button(VaadinIcon.SIGN_OUT.create(), e -> securityService.logout());
logoutBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
logoutBtn.setTooltipText("Abmelden");
rightSection.add(userName, logoutBtn);
});
HorizontalLayout header = new HorizontalLayout(toggle, title, rightSection);
header.setAlignItems(FlexComponent.Alignment.CENTER);
header.setWidthFull();
header.setPadding(true);
header.expand(title);
addToNavbar(true, header);
}
private void addDrawerContent() {
Span appName = new Span("Swyx Core");
appName.addClassNames(LumoUtility.FontWeight.SEMIBOLD, LumoUtility.Padding.MEDIUM);
SideNav nav = new SideNav();
nav.addItem(new SideNavItem("Dashboard", DashboardView.class, VaadinIcon.DASHBOARD.create()));
nav.addItem(new SideNavItem("Anruf-Events", EventsView.class, VaadinIcon.PHONE.create()));
addToDrawer(appName, nav);
}
}

View File

@@ -0,0 +1,117 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.webhook.CallEventStore;
import de.assecutor.swyx.webhook.CallEventType;
import de.assecutor.swyx.webhook.StoredCallEvent;
import jakarta.annotation.security.PermitAll;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Route("")
@PageTitle("Dashboard")
@PermitAll
public class DashboardView extends VerticalLayout {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private final CallEventStore store;
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
private final StatCard totalCard = new StatCard("Events gesamt");
private final StatCard ringingCard = new StatCard("Eingehend (Ringing)");
private final StatCard answeredCard = new StatCard("Angenommen");
private final StatCard missedCard = new StatCard("Verpasst");
private CallEventStore.Registration registration;
public DashboardView(CallEventStore store) {
this.store = store;
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H2("Live Übersicht"));
add(buildStatsRow());
add(buildRecentGrid());
expand(grid);
refresh(store.snapshot());
}
private HorizontalLayout buildStatsRow() {
HorizontalLayout row = new HorizontalLayout(totalCard, ringingCard, answeredCard, missedCard);
row.setWidthFull();
row.setSpacing(true);
return row;
}
private Grid<StoredCallEvent> buildRecentGrid() {
grid.setAllRowsVisible(false);
grid.setSizeFull();
grid.addColumn(e -> e.receivedAt().format(TIME_FORMAT)).setHeader("Zeit").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
return grid;
}
@Override
protected void onAttach(AttachEvent attachEvent) {
registration = store.register(event -> attachEvent.getUI().access(() -> refresh(store.snapshot())));
}
@Override
protected void onDetach(DetachEvent detachEvent) {
if (registration != null) {
registration.remove();
registration = null;
}
}
private void refresh(List<StoredCallEvent> events) {
List<StoredCallEvent> recent = events.size() > 20 ? events.subList(0, 20) : events;
grid.setItems(recent);
totalCard.setValue(events.size());
ringingCard.setValue(count(events, CallEventType.RINGING));
answeredCard.setValue(count(events, CallEventType.ANSWERED));
missedCard.setValue(count(events, CallEventType.MISSED));
}
private static long count(List<StoredCallEvent> events, CallEventType type) {
return events.stream().filter(e -> e.eventType() == type).count();
}
private static class StatCard extends VerticalLayout {
private final Span value = new Span("0");
StatCard(String label) {
Span title = new Span(label);
title.addClassNames(LumoUtility.FontSize.SMALL, LumoUtility.TextColor.SECONDARY);
value.addClassNames(LumoUtility.FontSize.XXLARGE, LumoUtility.FontWeight.BOLD);
add(title, value);
setSpacing(false);
setPadding(true);
getStyle().set("background", "var(--lumo-contrast-5pct)");
getStyle().set("border-radius", "var(--lumo-border-radius-l)");
setAlignItems(FlexComponent.Alignment.START);
setWidthFull();
}
void setValue(long number) {
value.setText(Long.toString(number));
}
}
}

View File

@@ -0,0 +1,170 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.AttachEvent;
import com.vaadin.flow.component.DetachEvent;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.provider.ListDataProvider;
import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.swyx.webhook.CallEventStore;
import de.assecutor.swyx.webhook.CallEventType;
import de.assecutor.swyx.webhook.StoredCallEvent;
import jakarta.annotation.security.PermitAll;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Objects;
@Route("events")
@PageTitle("Anruf-Events")
@PermitAll
public class EventsView extends VerticalLayout {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss");
private final CallEventStore store;
private final Grid<StoredCallEvent> grid = new Grid<>(StoredCallEvent.class, false);
private final ListDataProvider<StoredCallEvent> dataProvider = new ListDataProvider<>(new ArrayList<>());
private final TextField searchField = new TextField();
private final ComboBox<CallEventType> typeFilter = new ComboBox<>();
private CallEventStore.Registration registration;
public EventsView(CallEventStore store) {
this.store = store;
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H3("Alle empfangenen Events"));
add(buildToolbar());
add(grid);
expand(grid);
configureGrid();
refresh();
}
private HorizontalLayout buildToolbar() {
searchField.setPlaceholder("Suchen (Rufnummer, Call-ID, Nebenstelle)");
searchField.setPrefixComponent(VaadinIcon.SEARCH.create());
searchField.setValueChangeMode(ValueChangeMode.LAZY);
searchField.setClearButtonVisible(true);
searchField.addValueChangeListener(e -> applyFilters());
searchField.setWidth("360px");
typeFilter.setPlaceholder("Alle Typen");
typeFilter.setItems(Arrays.asList(CallEventType.values()));
typeFilter.setClearButtonVisible(true);
typeFilter.addValueChangeListener(e -> applyFilters());
Button refresh = new Button("Aktualisieren", VaadinIcon.REFRESH.create(), e -> refresh());
HorizontalLayout toolbar = new HorizontalLayout(searchField, typeFilter, refresh);
toolbar.setWidthFull();
return toolbar;
}
private void configureGrid() {
grid.setSizeFull();
grid.addColumn(e -> e.receivedAt().format(DATE_TIME_FORMAT)).setHeader("Empfangen").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(e -> e.eventType().name()).setHeader("Typ").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::from).setHeader("Von").setAutoWidth(true);
grid.addColumn(StoredCallEvent::to).setHeader("An").setAutoWidth(true);
grid.addColumn(StoredCallEvent::user).setHeader("Nebenstelle").setAutoWidth(true);
grid.addColumn(e -> e.durationSeconds() != null ? e.durationSeconds() + " s" : "")
.setHeader("Dauer").setAutoWidth(true).setFlexGrow(0);
grid.addColumn(StoredCallEvent::callId).setHeader("Call ID").setAutoWidth(true);
grid.setDataProvider(dataProvider);
grid.addItemClickListener(e -> showDetails(e.getItem()));
}
private void applyFilters() {
String query = searchField.getValue() == null ? "" : searchField.getValue().trim().toLowerCase();
CallEventType type = typeFilter.getValue();
dataProvider.setFilter(event -> {
if (type != null && event.eventType() != type) {
return false;
}
if (query.isEmpty()) {
return true;
}
return matches(event.from(), query)
|| matches(event.to(), query)
|| matches(event.callId(), query)
|| matches(event.user(), query);
});
}
private static boolean matches(String value, String query) {
return value != null && value.toLowerCase().contains(query);
}
@Override
protected void onAttach(AttachEvent attachEvent) {
registration = store.register(event -> attachEvent.getUI().access(this::refresh));
}
@Override
protected void onDetach(DetachEvent detachEvent) {
if (registration != null) {
registration.remove();
registration = null;
}
}
private void refresh() {
dataProvider.getItems().clear();
dataProvider.getItems().addAll(store.snapshot());
dataProvider.refreshAll();
applyFilters();
}
private void showDetails(StoredCallEvent event) {
Dialog dialog = new Dialog();
dialog.setHeaderTitle("Event-Details");
dialog.setWidth("480px");
VerticalLayout content = new VerticalLayout();
content.setPadding(false);
content.setSpacing(false);
content.add(row("Call ID", event.callId()));
content.add(row("Event-Typ", event.eventType().name()));
content.add(row("Von", event.from()));
content.add(row("An", event.to()));
content.add(row("Nebenstelle", event.user()));
content.add(row("Empfangen", event.receivedAt().format(DATE_TIME_FORMAT)));
content.add(row("Zeitstempel (Swyx)",
event.timestamp() != null ? event.timestamp().format(DATE_TIME_FORMAT) : null));
content.add(row("Dauer", event.durationSeconds() != null ? event.durationSeconds() + " s" : null));
dialog.add(content);
dialog.getFooter().add(new Button("Schließen", e -> dialog.close()));
dialog.open();
}
private static HorizontalLayout row(String label, String value) {
Span labelSpan = new Span(label);
labelSpan.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL);
labelSpan.setWidth("140px");
Span valueSpan = new Span(Objects.toString(value, ""));
valueSpan.addClassNames(LumoUtility.FontWeight.MEDIUM);
HorizontalLayout row = new HorizontalLayout(labelSpan, valueSpan);
row.setPadding(false);
row.getStyle().set("padding", "var(--lumo-space-xs) 0");
return row;
}
}

View File

@@ -0,0 +1,55 @@
package de.assecutor.swyx.ui.view;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.login.LoginForm;
import com.vaadin.flow.component.login.LoginI18n;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
@Route(value = "login", autoLayout = false)
@PageTitle("Anmelden — Swyx Admin")
@AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver {
private final LoginForm loginForm = new LoginForm();
public LoginView() {
setSizeFull();
setAlignItems(FlexComponent.Alignment.CENTER);
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
H1 title = new H1("Swyx Admin");
title.getStyle().set("margin-bottom", "var(--lumo-space-m)");
LoginI18n i18n = LoginI18n.createDefault();
LoginI18n.Form form = i18n.getForm();
form.setTitle("Anmelden");
form.setUsername("Benutzername");
form.setPassword("Passwort");
form.setSubmit("Anmelden");
i18n.setForm(form);
LoginI18n.ErrorMessage errorMessage = i18n.getErrorMessage();
errorMessage.setTitle("Anmeldung fehlgeschlagen");
errorMessage.setMessage("Benutzername oder Passwort ist ungültig.");
i18n.setErrorMessage(errorMessage);
loginForm.setI18n(i18n);
loginForm.setAction("login");
loginForm.setForgotPasswordButtonVisible(false);
add(title, loginForm);
}
@Override
public void beforeEnter(BeforeEnterEvent event) {
if (event.getLocation().getQueryParameters().getParameters().containsKey("error")) {
loginForm.setError(true);
}
}
}

View File

@@ -0,0 +1,35 @@
package de.assecutor.swyx.webhook;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.time.OffsetDateTime;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CallEventRequest {
@NotBlank
@JsonAlias({"callId", "call_id", "CallId"})
private String callId;
@JsonAlias({"eventType", "event", "type"})
private CallEventType eventType;
@JsonAlias({"from", "caller", "callerNumber", "CallingPartyNumber"})
private String from;
@JsonAlias({"to", "callee", "calleeNumber", "CalledPartyNumber"})
private String to;
@JsonAlias({"user", "userName", "extension"})
private String user;
@JsonAlias({"timestamp", "eventTime", "time"})
private OffsetDateTime timestamp;
@JsonAlias({"durationSeconds", "duration"})
private Integer durationSeconds;
}

View File

@@ -0,0 +1,46 @@
package de.assecutor.swyx.webhook;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class CallEventService {
private final CallEventStore store;
public void handle(CallEventRequest event) {
store.add(event);
CallEventType type = event.getEventType() != null ? event.getEventType() : CallEventType.UNKNOWN;
switch (type) {
case RINGING -> onRinging(event);
case ANSWERED -> onAnswered(event);
case ENDED -> onEnded(event);
case TRANSFERRED -> onTransferred(event);
case MISSED -> onMissed(event);
default -> log.warn("Unhandled Swyx event type for callId={}", event.getCallId());
}
}
private void onRinging(CallEventRequest event) {
log.info("Ringing: {} -> {} (user={})", event.getFrom(), event.getTo(), event.getUser());
}
private void onAnswered(CallEventRequest event) {
log.info("Answered: callId={} user={}", event.getCallId(), event.getUser());
}
private void onEnded(CallEventRequest event) {
log.info("Ended: callId={} duration={}s", event.getCallId(), event.getDurationSeconds());
}
private void onTransferred(CallEventRequest event) {
log.info("Transferred: callId={}", event.getCallId());
}
private void onMissed(CallEventRequest event) {
log.info("Missed: {} -> {}", event.getFrom(), event.getTo());
}
}

View File

@@ -0,0 +1,43 @@
package de.assecutor.swyx.webhook;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
@Component
public class CallEventStore {
private static final int MAX_ENTRIES = 500;
private final Deque<StoredCallEvent> buffer = new ArrayDeque<>(MAX_ENTRIES);
private final List<Consumer<StoredCallEvent>> listeners = new CopyOnWriteArrayList<>();
public synchronized StoredCallEvent add(CallEventRequest request) {
StoredCallEvent entry = StoredCallEvent.of(request, OffsetDateTime.now());
buffer.addFirst(entry);
while (buffer.size() > MAX_ENTRIES) {
buffer.removeLast();
}
listeners.forEach(l -> l.accept(entry));
return entry;
}
public synchronized List<StoredCallEvent> snapshot() {
return List.copyOf(buffer);
}
public Registration register(Consumer<StoredCallEvent> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
@FunctionalInterface
public interface Registration {
void remove();
}
}

View File

@@ -0,0 +1,10 @@
package de.assecutor.swyx.webhook;
public enum CallEventType {
RINGING,
ANSWERED,
ENDED,
TRANSFERRED,
MISSED,
UNKNOWN
}

View File

@@ -0,0 +1,30 @@
package de.assecutor.swyx.webhook;
import java.time.OffsetDateTime;
import java.util.UUID;
public record StoredCallEvent(
String id,
OffsetDateTime receivedAt,
String callId,
CallEventType eventType,
String from,
String to,
String user,
OffsetDateTime timestamp,
Integer durationSeconds
) {
public static StoredCallEvent of(CallEventRequest request, OffsetDateTime receivedAt) {
return new StoredCallEvent(
UUID.randomUUID().toString(),
receivedAt,
request.getCallId(),
request.getEventType() != null ? request.getEventType() : CallEventType.UNKNOWN,
request.getFrom(),
request.getTo(),
request.getUser(),
request.getTimestamp(),
request.getDurationSeconds()
);
}
}

View File

@@ -0,0 +1,32 @@
package de.assecutor.swyx.webhook;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/swyx")
@RequiredArgsConstructor
public class SwyxWebhookController {
private final CallEventService callEventService;
@PostMapping("/webhook/call")
public ResponseEntity<Map<String, Object>> receiveCallEvent(@Valid @RequestBody CallEventRequest request) {
log.info("Swyx call event received: callId={}, type={}, from={}, to={}",
request.getCallId(), request.getEventType(), request.getFrom(), request.getTo());
callEventService.handle(request);
return ResponseEntity.ok(Map.of(
"status", "ok",
"callId", request.getCallId()
));
}
}

View File

@@ -0,0 +1,43 @@
spring:
application:
name: swyx-core
jackson:
default-property-inclusion: non_null
data:
mongodb:
uri: ${MONGODB_URI:}
host: ${MONGODB_HOST:localhost}
port: ${MONGODB_PORT:27017}
database: ${MONGODB_DATABASE:swyx}
username: ${MONGODB_USERNAME:}
password: ${MONGODB_PASSWORD:}
authentication-database: ${MONGODB_AUTH_DATABASE:admin}
server:
port: ${SERVER_PORT:8080}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
swyx:
admin:
username: ${SWYX_ADMIN_USERNAME:admin}
password: ${SWYX_ADMIN_PASSWORD:admin}
plugins:
outlook:
enabled: ${SWYX_PLUGINS_OUTLOOK_ENABLED:false}
tenant-id: ${SWYX_PLUGINS_OUTLOOK_TENANT_ID:}
client-id: ${SWYX_PLUGINS_OUTLOOK_CLIENT_ID:}
client-secret: ${SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET:}
base-url: ${SWYX_PLUGINS_OUTLOOK_BASE_URL:https://graph.microsoft.com/v1.0}
scope: ${SWYX_PLUGINS_OUTLOOK_SCOPE:https://graph.microsoft.com/.default}
logging:
level:
de.assecutor.swyx: ${LOGGING_LEVEL_DE_ASSECUTOR_SWYX:INFO}

View File

@@ -0,0 +1,48 @@
package de.assecutor.swyx.webhook;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class SwyxWebhookControllerTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate rest;
@Test
void acceptsCallEventPayload() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = """
{
"callId": "abc-123",
"eventType": "RINGING",
"from": "+4930111111",
"to": "+4930222222",
"user": "200",
"timestamp": "2026-04-16T10:00:00+02:00"
}
""";
ResponseEntity<String> response = rest.postForEntity(
"http://localhost:" + port + "/api/swyx/webhook/call",
new HttpEntity<>(body, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("abc-123");
}
}