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:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# --- Secrets ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- OS ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- IDE ---
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
*.code-workspace
|
||||
|
||||
# --- Java / Maven ---
|
||||
target/
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.log
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# --- Node ---
|
||||
node_modules/
|
||||
node/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# --- Vaadin / Vite ---
|
||||
.vaadin/
|
||||
.vite/
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
core/vite.generated.ts
|
||||
core/src/main/frontend/generated/
|
||||
33
.vscode/launch.json
vendored
Normal file
33
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Spring Boot: Swyx Core",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/core",
|
||||
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
|
||||
"projectName": "swyx-core",
|
||||
"args": [],
|
||||
"vmArgs": "-Dspring.profiles.active=dev -Dspring.devtools.restart.enabled=true",
|
||||
"env": {
|
||||
"SERVER_PORT": "8080"
|
||||
},
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"type": "java",
|
||||
"name": "Spring Boot: Swyx Core (Production)",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/core",
|
||||
"mainClass": "de.assecutor.swyx.SwyxCoreApplication",
|
||||
"projectName": "swyx-core",
|
||||
"args": [],
|
||||
"vmArgs": "-Dspring.profiles.active=prod -Dvaadin.productionMode=true",
|
||||
"env": {
|
||||
"SERVER_PORT": "8080"
|
||||
},
|
||||
"console": "internalConsole"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
core/.env.example
Normal file
24
core/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Template — copy to `.env` and fill in your values.
|
||||
# Do NOT commit `.env` (contains secrets).
|
||||
|
||||
SERVER_PORT=8080
|
||||
|
||||
SWYX_ADMIN_USERNAME=admin
|
||||
SWYX_ADMIN_PASSWORD=change-me
|
||||
|
||||
SWYX_PLUGINS_OUTLOOK_ENABLED=false
|
||||
SWYX_PLUGINS_OUTLOOK_TENANT_ID=
|
||||
SWYX_PLUGINS_OUTLOOK_CLIENT_ID=
|
||||
SWYX_PLUGINS_OUTLOOK_CLIENT_SECRET=
|
||||
SWYX_PLUGINS_OUTLOOK_BASE_URL=https://graph.microsoft.com/v1.0
|
||||
SWYX_PLUGINS_OUTLOOK_SCOPE=https://graph.microsoft.com/.default
|
||||
|
||||
MONGODB_URI=
|
||||
MONGODB_HOST=localhost
|
||||
MONGODB_PORT=27017
|
||||
MONGODB_DATABASE=swyx
|
||||
MONGODB_USERNAME=
|
||||
MONGODB_PASSWORD=
|
||||
MONGODB_AUTH_DATABASE=admin
|
||||
|
||||
LOGGING_LEVEL_DE_ASSECUTOR_SWYX=INFO
|
||||
15039
core/package-lock.json
generated
Normal file
15039
core/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
104
core/package.json
Normal file
104
core/package.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"name": "no-name",
|
||||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@vaadin/bundles": "24.6.4",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/polymer-legacy-adapter": "24.6.4",
|
||||
"@vaadin/react-components": "24.6.4",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.6.4",
|
||||
"@vaadin/vaadin-material-styles": "24.6.4",
|
||||
"@vaadin/vaadin-themable-mixin": "24.6.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
"lit": "3.2.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@preact/signals-react-transform": "0.5.1",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"async": "3.2.6",
|
||||
"glob": "10.4.5",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
"rollup-plugin-visualizer": "5.14.0",
|
||||
"strip-css-comments": "5.0.0",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"vite-plugin-checker": "0.8.0",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
},
|
||||
"vaadin": {
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@vaadin/bundles": "24.6.4",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/polymer-legacy-adapter": "24.6.4",
|
||||
"@vaadin/react-components": "24.6.4",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.6.4",
|
||||
"@vaadin/vaadin-material-styles": "24.6.4",
|
||||
"@vaadin/vaadin-themable-mixin": "24.6.4",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
"lit": "3.2.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@preact/signals-react-transform": "0.5.1",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"async": "3.2.6",
|
||||
"glob": "10.4.5",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
"rollup-plugin-visualizer": "5.14.0",
|
||||
"strip-css-comments": "5.0.0",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.0.11",
|
||||
"vite-plugin-checker": "0.8.0",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
},
|
||||
"hash": "eb4789b12aacb7dfe73ac33a6dd463d0f265e60c4e8b5123fb0bd291e624727e"
|
||||
},
|
||||
"overrides": {
|
||||
"@vaadin/bundles": "$@vaadin/bundles",
|
||||
"@vaadin/polymer-legacy-adapter": "$@vaadin/polymer-legacy-adapter",
|
||||
"@vaadin/vaadin-development-mode-detector": "$@vaadin/vaadin-development-mode-detector",
|
||||
"@vaadin/vaadin-usage-statistics": "$@vaadin/vaadin-usage-statistics",
|
||||
"@vaadin/react-components": "$@vaadin/react-components",
|
||||
"@vaadin/common-frontend": "$@vaadin/common-frontend",
|
||||
"react-dom": "$react-dom",
|
||||
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
|
||||
"react-router-dom": "$react-router-dom",
|
||||
"lit": "$lit",
|
||||
"@polymer/polymer": "$@polymer/polymer",
|
||||
"react": "$react",
|
||||
"date-fns": "$date-fns",
|
||||
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
|
||||
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
|
||||
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles"
|
||||
}
|
||||
}
|
||||
156
core/pom.xml
Normal file
156
core/pom.xml
Normal file
@@ -0,0 +1,156 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.4.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>de.assecutor.swyx</groupId>
|
||||
<artifactId>swyx-core</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>swyx-core</name>
|
||||
<description>Swyx Webhook Service für Anrufsignalisierung mit Admin-Oberfläche</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<vaadin.version>24.6.5</vaadin.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.vaadin</groupId>
|
||||
<artifactId>vaadin-bom</artifactId>
|
||||
<version>${vaadin.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.vaadin</groupId>
|
||||
<artifactId>vaadin-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.38</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.38</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>com.vaadin</groupId>
|
||||
<artifactId>vaadin-maven-plugin</artifactId>
|
||||
<version>${vaadin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-frontend</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>production</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.vaadin</groupId>
|
||||
<artifactId>vaadin-maven-plugin</artifactId>
|
||||
<version>${vaadin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>build-frontend</goal>
|
||||
</goals>
|
||||
<phase>compile</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<productionMode>true</productionMode>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
23
core/src/main/frontend/index.html
Normal file
23
core/src/main/frontend/index.html
Normal 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>
|
||||
1
core/src/main/frontend/themes/swyx-admin/styles.css
Normal file
1
core/src/main/frontend/themes/swyx-admin/styles.css
Normal file
@@ -0,0 +1 @@
|
||||
/* swyx-admin theme styles */
|
||||
3
core/src/main/frontend/themes/swyx-admin/theme.json
Normal file
3
core/src/main/frontend/themes/swyx-admin/theme.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"lumoImports": ["typography", "color", "spacing", "badge", "utility"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
8
core/src/main/java/de/assecutor/swyx/plugins/Plugin.java
Normal file
8
core/src/main/java/de/assecutor/swyx/plugins/Plugin.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package de.assecutor.swyx.plugins;
|
||||
|
||||
public interface Plugin {
|
||||
|
||||
String name();
|
||||
|
||||
void start();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
79
core/src/main/java/de/assecutor/swyx/ui/MainLayout.java
Normal file
79
core/src/main/java/de/assecutor/swyx/ui/MainLayout.java
Normal 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);
|
||||
}
|
||||
}
|
||||
117
core/src/main/java/de/assecutor/swyx/ui/view/DashboardView.java
Normal file
117
core/src/main/java/de/assecutor/swyx/ui/view/DashboardView.java
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
170
core/src/main/java/de/assecutor/swyx/ui/view/EventsView.java
Normal file
170
core/src/main/java/de/assecutor/swyx/ui/view/EventsView.java
Normal 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;
|
||||
}
|
||||
}
|
||||
55
core/src/main/java/de/assecutor/swyx/ui/view/LoginView.java
Normal file
55
core/src/main/java/de/assecutor/swyx/ui/view/LoginView.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.assecutor.swyx.webhook;
|
||||
|
||||
public enum CallEventType {
|
||||
RINGING,
|
||||
ANSWERED,
|
||||
ENDED,
|
||||
TRANSFERRED,
|
||||
MISSED,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
43
core/src/main/resources/application.yml
Normal file
43
core/src/main/resources/application.yml
Normal 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}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
38
core/tsconfig.json
Normal file
38
core/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
||||
// This TypeScript configuration file is generated by vaadin-maven-plugin.
|
||||
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
|
||||
// It is recommended to commit this file to the VCS.
|
||||
// You might want to change the configurations to fit your preferences
|
||||
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
|
||||
{
|
||||
"_version": "9.1",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"inlineSources": true,
|
||||
"module": "esNext",
|
||||
"target": "es2020",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"paths": {
|
||||
"@vaadin/flow-frontend": ["./src/main/frontend/generated/jar-resources"],
|
||||
"@vaadin/flow-frontend/*": ["./src/main/frontend/generated/jar-resources/*"],
|
||||
"Frontend/*": ["./src/main/frontend/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/main/frontend/**/*",
|
||||
"types.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/main/frontend/generated/jar-resources/**"
|
||||
]
|
||||
}
|
||||
17
core/types.d.ts
vendored
Normal file
17
core/types.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// This TypeScript modules definition file is generated by vaadin-maven-plugin.
|
||||
// You can not directly import your different static files into TypeScript,
|
||||
// This is needed for TypeScript compiler to declare and export as a TypeScript module.
|
||||
// It is recommended to commit this file to the VCS.
|
||||
// You might want to change the configurations to fit your preferences
|
||||
declare module '*.css?inline' {
|
||||
import type { CSSResultGroup } from 'lit';
|
||||
const content: CSSResultGroup;
|
||||
export default content;
|
||||
}
|
||||
|
||||
// Allow any CSS Custom Properties
|
||||
declare module 'csstype' {
|
||||
interface Properties {
|
||||
[index: `--${string}`]: any;
|
||||
}
|
||||
}
|
||||
9
core/vite.config.ts
Normal file
9
core/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { UserConfigFn } from 'vite';
|
||||
import { overrideVaadinConfig } from './vite.generated';
|
||||
|
||||
const customConfig: UserConfigFn = (env) => ({
|
||||
// Here you can add custom Vite parameters
|
||||
// https://vitejs.dev/config/
|
||||
});
|
||||
|
||||
export default overrideVaadinConfig(customConfig);
|
||||
Reference in New Issue
Block a user