diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..7cd9fb6
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,292 @@
+# Deployment Setup für SKAMP API
+
+Diese Anleitung beschreibt das vollständige Setup für das automatische Deployment der Spring Boot Applikation auf einen Remote Server.
+
+## Übersicht
+
+Bei einem Push auf den `main` Branch wird automatisch:
+1. Die Spring Boot Applikation gebaut
+2. Ein Docker Image erstellt
+3. Das Image in die Gitea Container Registry gepusht
+4. Das Image auf dem Remote Server deployed
+
+## Voraussetzungen
+
+### Auf dem Deployment-Server
+
+1. **Docker installiert** (Version 20.10+)
+ ```bash
+ curl -fsSL https://get.docker.com | sh
+ ```
+
+2. **Keine weiteren Dateien notwendig**
+ - Der Workflow deployed direkt via `docker run`
+ - Keine compose.yml oder .env Dateien auf dem Server erforderlich
+ - Alle Konfiguration erfolgt über Gitea Secrets
+
+## Gitea Configuration
+
+Im Gitea Repository müssen folgende Variables und Secrets angelegt werden:
+
+### Repository Settings > Actions > Variables
+
+| Variable Name | Beschreibung | Beispiel |
+|--------------|--------------|----------|
+| `REGISTRY_URL` | Gitea Container Registry URL | `gitea.moz-tech.de` |
+| `NAMESPACE` | Organisation/Benutzer | `murat` |
+| `REPO_NAME` | Repository Name | `skamp` |
+
+### Repository Settings > Actions > Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `CI_GITEA_USER` | Gitea Benutzername | `murat` |
+| `CI_GITEA_TOKEN` | Gitea Access Token | `ghp_xxxxxxxxxxxx` |
+| `SSH_HOST` | Server IP/Hostname | `123.456.789.0` |
+| `SSH_USERNAME` | SSH Benutzername | `deploy` |
+| `SSH_PRIVATE_KEY` | SSH Private Key | `-----BEGIN RSA PRIVATE KEY-----...` |
+| `SSH_PORT` | SSH Port | `22` |
+| `APP_PORT` | Anwendungs-Port | `8080` |
+| `SPRING_PROFILES_ACTIVE` | Spring Profil | `prod` |
+| `POSTGRES_HOST` | PostgreSQL Host | `db.example.com` |
+| `POSTGRES_PORT` | PostgreSQL Port | `5432` |
+| `POSTGRES_DB` | Datenbank Name | `skamp` |
+| `POSTGRES_USER` | Datenbank User | `skamp` |
+| `POSTGRES_PASSWORD` | Datenbank Passwort | `IhrSicheresPasswort123!` |
+| `HIBERNATE_DDL_AUTO` | Hibernate DDL | `update` |
+| `KEYCLOAK_ISSUER_URI` | Keycloak Issuer URI | `https://keycloak.example.com/realms/skamp` |
+| `CORS_ALLOWED_ORIGINS` | Erlaubte CORS Origins | `https://frontend.example.com` |
+| `MINIO_ENDPOINT` | MinIO/S3 Endpoint | `https://s3.example.com` |
+| `MINIO_ACCESS_KEY` | MinIO Access Key | `your_access_key` |
+| `MINIO_SECRET_KEY` | MinIO Secret Key | `your_secret_key` |
+| `JAVA_OPTS` | Java Optionen | `-Xmx1024m -Xms512m` |
+
+### Gitea Token erstellen
+
+1. Gitea → Einstellungen → Anwendungen → Access Tokens
+2. Token Name: `deployment-token`
+3. Berechtigungen: `read:packages`, `write:packages`
+4. Token generieren und als `CI_GITEA_TOKEN` Secret speichern
+
+### SSH Key erstellen
+
+```bash
+# Auf deinem lokalen Rechner
+ssh-keygen -t ed25519 -C "deployment-key" -f ~/.ssh/skamp_deploy
+
+# Public Key auf den Server kopieren
+ssh-copy-id -i ~/.ssh/skamp_deploy.pub user@server
+
+# Private Key als Secret speichern
+cat ~/.ssh/skamp_deploy
+```
+
+## Erstes Deployment
+
+Das erste Deployment erfolgt automatisch beim Push auf `main`. Der Workflow:
+
+1. Baut die Spring Boot 3 Applikation mit Maven
+2. Erstellt ein Docker Image
+3. Pusht das Image zur Gitea Registry
+4. Loggt sich per SSH auf dem Server ein
+5. Pulled das Image
+6. Startet den Container mit allen Environment-Variablen
+
+Alternativ kannst du das erste Deployment manuell testen:
+
+```bash
+# Auf dem Server
+# Login zur Container Registry
+docker login gitea.moz-tech.de -u murat
+
+# Pull Image (ersetze IMAGE_TAG mit einem Commit-SHA oder 'latest')
+docker pull gitea.moz-tech.de/murat/skamp:latest
+
+# Starte Container
+docker run -d \
+ --name skamp-app \
+ --restart unless-stopped \
+ -p 8080:8080 \
+ -e SPRING_PROFILES_ACTIVE=prod \
+ -e SPRING_DATASOURCE_URL=jdbc:postgresql://db.example.com:5432/skamp \
+ -e SPRING_DATASOURCE_USERNAME=skamp \
+ -e SPRING_DATASOURCE_PASSWORD=IhrPasswort \
+ -e SPRING_JPA_HIBERNATE_DDL_AUTO=update \
+ -e KEYCLOAK_ISSUER_URI=https://keycloak.example.com/realms/skamp \
+ -e CORS_ALLOWED_ORIGINS=https://frontend.example.com \
+ -e S3_ACCESS_KEY=your_access_key \
+ -e S3_SECRET_KEY=your_secret_key \
+ -e S3_ENDPOINT=https://s3.example.com \
+ -e JAVA_OPTS="-Xmx1024m -Xms512m" \
+ gitea.moz-tech.de/murat/skamp:latest
+
+# Prüfe Status
+docker ps --filter name=skamp-app
+docker logs -f skamp-app
+```
+
+## Workflow Trigger
+
+Das Deployment wird automatisch ausgelöst durch:
+
+```bash
+git add .
+git commit -m "Deploy: Update application"
+git push origin main
+```
+
+## Monitoring
+
+### Logs anzeigen
+
+```bash
+# Auf dem Server
+
+# Container Logs live anzeigen
+docker logs -f skamp-app
+
+# Letzte 100 Zeilen
+docker logs --tail 100 skamp-app
+
+# Mit Timestamps
+docker logs -f --timestamps skamp-app
+```
+
+### Container Status
+
+```bash
+# Status prüfen
+docker ps --filter name=skamp-app
+
+# Detaillierte Informationen
+docker inspect skamp-app
+
+# Resource-Nutzung
+docker stats skamp-app
+```
+
+### Health Check
+
+```bash
+# HTTP Health Check
+curl http://localhost:8080/actuator/health
+
+# Container Health Status
+docker inspect --format='{{.State.Health.Status}}' skamp-app
+```
+
+## Troubleshooting
+
+### Workflow schlägt fehl
+
+1. **Build Error**: Prüfe die Java Version und Maven Dependencies in `pom.xml`
+2. **Registry Login Error**: Prüfe `CI_GITEA_TOKEN` Secret
+3. **SSH Error**: Prüfe SSH Secrets (`SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`, `SSH_PORT`)
+4. **Deployment Error**: Prüfe Server-Logs mit `docker logs skamp-app`
+
+### Container startet nicht
+
+```bash
+# Prüfe Logs
+docker logs skamp-app
+
+# Prüfe Container-Details
+docker inspect skamp-app
+
+# Container neu starten
+docker restart skamp-app
+
+# Container mit neuen Einstellungen starten
+docker stop skamp-app
+docker rm skamp-app
+# Dann docker run erneut ausführen (siehe Deployment-Sektion)
+```
+
+### Datenbank-Verbindungsfehler
+
+```bash
+# 1. Prüfe ob PostgreSQL erreichbar ist
+docker exec skamp-app ping -c 3 $POSTGRES_HOST
+
+# 2. Prüfe Verbindung zur Datenbank
+docker exec -it skamp-app sh -c "apt update && apt install -y postgresql-client"
+docker exec -it skamp-app psql -h $POSTGRES_HOST -U $POSTGRES_USER -d $POSTGRES_DB
+
+# 3. Prüfe Environment-Variablen im Container
+docker exec skamp-app env | grep SPRING_DATASOURCE
+```
+
+### Port-Konflikte
+
+```bash
+# Prüfe ob Port bereits belegt ist
+sudo netstat -tlnp | grep :8080
+# oder
+sudo lsof -i :8080
+
+# Anderen Port verwenden (z.B. 8081)
+docker stop skamp-app
+docker rm skamp-app
+# Dann docker run mit -p 8081:8080 statt -p 8080:8080
+```
+
+## Updates
+
+### Automatisches Update
+
+Jeder Push auf `main` löst ein automatisches Update aus.
+
+### Manuelles Update auf spezifische Version
+
+```bash
+# Auf dem Server
+
+# Stoppe aktuellen Container
+docker stop skamp-app
+docker rm skamp-app
+
+# Pull spezifische Version (ersetze COMMIT_SHA)
+docker pull gitea.moz-tech.de/murat/skamp:COMMIT_SHA
+
+# Starte Container mit neuer Version
+docker run -d \
+ --name skamp-app \
+ --restart unless-stopped \
+ -p 8080:8080 \
+ -e ... (alle Environment-Variablen) \
+ gitea.moz-tech.de/murat/skamp:COMMIT_SHA
+```
+
+### Rollback zu vorheriger Version
+
+```bash
+# Prüfe verfügbare Images
+docker images | grep skamp
+
+# Stoppe aktuellen Container
+docker stop skamp-app
+docker rm skamp-app
+
+# Starte mit älterer Version
+docker run -d \
+ --name skamp-app \
+ --restart unless-stopped \
+ -p 8080:8080 \
+ -e ... (alle Environment-Variablen) \
+ gitea.moz-tech.de/murat/skamp:OLD_COMMIT_SHA
+```
+
+## Security Best Practices
+
+1. **Secrets niemals committen** - Nutze `.gitignore` für `.env`
+2. **Starke Passwörter** - Generiere sichere Passwörter für DB und S3
+3. **SSH Key Rotation** - Rotiere SSH Keys regelmäßig
+4. **Firewall** - Beschränke Zugriff auf notwendige Ports
+5. **Updates** - Halte Docker und Images aktuell
+6. **Monitoring** - Implementiere Monitoring und Alerting
+
+## Weitere Informationen
+
+- Gitea Actions: https://docs.gitea.com/usage/actions/overview
+- Docker Compose: https://docs.docker.com/compose/
+- Spring Boot: https://spring.io/projects/spring-boot
diff --git a/DEPLOYMENT_SECRETS.md b/DEPLOYMENT_SECRETS.md
new file mode 100644
index 0000000..1000d1c
--- /dev/null
+++ b/DEPLOYMENT_SECRETS.md
@@ -0,0 +1,181 @@
+# Deployment Secrets Configuration
+
+Diese Dokumentation beschreibt die erforderlichen Secrets für den automatischen Deployment-Workflow in Gitea.
+
+## Erforderliche Secrets und Variablen in Gitea
+
+### Gitea Variables (nicht-sensitive Daten)
+
+Navigiere zu: **Repository Settings → Variables → Actions**
+
+Diese Werte sind nicht sensitiv und können als Variables gespeichert werden:
+
+| Variable Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `REGISTRY_URL` | URL der Container Registry | `gitea.moz-tech.de` |
+| `NAMESPACE` | Namespace/Organisation in der Registry | `murat` |
+| `REPO_NAME` | Repository Name für Docker Image | `skamp` |
+
+### Gitea Secrets (sensitive Daten)
+
+Navigiere zu: **Repository Settings → Secrets → Actions**
+
+Diese Werte sind sensitiv und müssen als Secrets gespeichert werden:
+
+#### Container Registry Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `CI_GITEA_USER` | Gitea Benutzername für Registry Login | `murat` |
+| `CI_GITEA_TOKEN` | Gitea Access Token mit Registry-Rechten | `74a7738116bfb99497a7781291efc5766901f497` |
+
+#### SSH Server Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `SSH_HOST` | Hostname oder IP des Deployment-Servers | `server.example.com` |
+| `SSH_USERNAME` | SSH Benutzername | `deploy` |
+| `SSH_PRIVATE_KEY` | SSH Private Key (kompletter Inhalt) | `-----BEGIN OPENSSH PRIVATE KEY-----...` |
+| `SSH_PORT` | SSH Port (optional, Standard: 22) | `22` |
+| `DEPLOY_PATH` | Pfad zum Deployment-Verzeichnis auf dem Server | `/opt/skamp/docker` |
+
+### Application Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `APP_PORT` | Port für die Anwendung | `8082` |
+| `SPRING_PROFILES_ACTIVE` | Spring Boot Profil | `prod` |
+| `APPLICATION_NAME` | Name der Anwendung | `skamp-api` |
+| `CORS_ALLOWED_ORIGINS` | Erlaubte CORS Origins | `https://app.example.com` |
+
+### PostgreSQL Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `POSTGRES_HOST` | PostgreSQL Hostname | `postgres` oder `db.example.com` |
+| `POSTGRES_PORT` | PostgreSQL Port | `5432` |
+| `POSTGRES_DB` | Datenbank Name | `skamp` |
+| `POSTGRES_USER` | Datenbank Benutzername | `skamp_user` |
+| `POSTGRES_PASSWORD` | Datenbank Passwort | `secure_password_here` |
+| `HIBERNATE_DDL_AUTO` | Hibernate DDL Modus | `update` oder `validate` |
+
+### Keycloak Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `KEYCLOAK_URL` | Keycloak Server URL | `https://auth.moz-tech.de/` |
+| `KEYCLOAK_REALM` | Keycloak Realm | `enerport` |
+| `KEYCLOAK_ISSUER_URI` | Keycloak Issuer URI | `https://auth.moz-tech.de/realms/enerport` |
+
+### MinIO (S3) Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `MINIO_ENDPOINT` | MinIO Endpoint URL | `http://minio:9000` |
+| `MINIO_ACCESS_KEY` | MinIO Access Key | `minioadmin` |
+| `MINIO_SECRET_KEY` | MinIO Secret Key | `minioadmin123` |
+
+### AWS S3 Secrets (Optional)
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `AWS_ACCESS_KEY_ID` | AWS Access Key ID | `AKIAIOSFODNN7EXAMPLE` |
+| `AWS_SECRET_ACCESS_KEY` | AWS Secret Access Key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` |
+| `AWS_S3_BUCKET_NAME` | AWS S3 Bucket Name | `my-bucket` |
+| `AWS_S3_REGION` | AWS S3 Region | `eu-central-1` |
+| `AWS_S3_ENDPOINT` | AWS S3 Endpoint (optional) | `https://s3.eu-central-1.amazonaws.com` |
+
+### Additional Configuration Secrets
+
+| Secret Name | Beschreibung | Beispiel |
+|------------|--------------|----------|
+| `JAVA_OPTS` | Java JVM Optionen | `-Xmx512m -Xms256m` |
+
+## Workflow Ablauf
+
+Der Workflow wird bei jedem Push auf den `main` Branch ausgelöst:
+
+1. **Checkout Code** - Lädt den Repository-Code
+2. **Login to Container Registry** - Authentifiziert sich bei der Gitea Registry
+3. **Extract metadata** - Erstellt Image-Tags (commit SHA + latest)
+4. **Build and Push Docker Image** - Baut das Docker Image mit Multi-Stage Build (Maven Build + Runtime Image) und pusht es zur Registry
+5. **Deploy to Remote Server** - Verbindet per SSH zum Server, erstellt .env Datei aus Secrets und deployed die Anwendung
+
+**Hinweis:** Der Maven Build erfolgt im Docker Multi-Stage Build (siehe Dockerfile), daher ist kein separater Maven-Build-Step im Workflow erforderlich.
+
+## Server-Vorbereitung
+
+Auf dem Deployment-Server müssen folgende Voraussetzungen erfüllt sein:
+
+1. **Docker und Docker Compose installiert**
+ ```bash
+ docker --version
+ docker compose version
+ ```
+
+2. **Deployment-Verzeichnis eingerichtet**
+ ```bash
+ mkdir -p /opt/skamp/docker
+ cd /opt/skamp/docker
+ ```
+
+3. **Docker Compose Konfiguration**
+ - `compose.yml` - Die Docker Compose Konfiguration
+ - `.env` - Umgebungsvariablen für das Deployment
+
+4. **SSH-Zugriff konfiguriert**
+ - SSH Public Key des CI/CD Servers muss auf dem Deployment-Server hinterlegt sein
+ - SSH Private Key muss als Secret `SSH_PRIVATE_KEY` in Gitea hinterlegt sein
+
+## Image Tagging
+
+Der Workflow erstellt zwei Image-Tags:
+
+- `${REGISTRY_URL}/${NAMESPACE}/${REPO_NAME}:${COMMIT_SHA}` - Spezifischer Commit
+- `${REGISTRY_URL}/${NAMESPACE}/${REPO_NAME}:latest` - Immer die neueste Version
+
+## Deployment auf dem Server
+
+Das Deployment-Script auf dem Server:
+
+1. Navigiert zum Deployment-Verzeichnis
+2. Authentifiziert sich bei der Container Registry
+3. Setzt die IMAGE_TAG Umgebungsvariable auf den Commit SHA
+4. Pullt das neueste Image
+5. Startet die Services neu mit `docker compose up -d`
+6. Räumt alte Images auf (älter als 7 Tage)
+7. Zeigt laufende Container an
+
+## Troubleshooting
+
+### Problem: Login zur Registry schlägt fehl
+- Überprüfe CI_GITEA_TOKEN hat die richtigen Berechtigungen
+- Stelle sicher, dass REGISTRY_URL korrekt ist (ohne `https://`)
+
+### Problem: SSH-Verbindung schlägt fehl
+- Überprüfe SSH_HOST, SSH_USERNAME und SSH_PORT
+- Stelle sicher, dass SSH_PRIVATE_KEY korrekt formatiert ist (mit Zeilenumbrüchen)
+- Prüfe, ob der Public Key auf dem Server hinterlegt ist
+
+### Problem: Docker Compose startet nicht
+- Überprüfe, ob die .env Datei auf dem Server existiert und korrekt ist
+- Stelle sicher, dass compose.yml im DEPLOY_PATH vorhanden ist
+- Prüfe die Logs mit `docker compose logs`
+
+## Manuelles Deployment
+
+Falls ein manuelles Deployment erforderlich ist:
+
+```bash
+# Auf dem Server
+cd /opt/skamp/docker
+
+# Login zur Registry
+echo "YOUR_TOKEN" | docker login gitea.moz-tech.de -u murat --password-stdin
+
+# Image pullen und starten
+docker compose pull
+docker compose up -d
+
+# Logs prüfen
+docker compose logs -f app
diff --git a/bruno/propify/Authenticate/Auth.bru b/bruno/propify/Authenticate/Auth App.bru
similarity index 97%
rename from bruno/propify/Authenticate/Auth.bru
rename to bruno/propify/Authenticate/Auth App.bru
index c223dd4..3fa271b 100644
--- a/bruno/propify/Authenticate/Auth.bru
+++ b/bruno/propify/Authenticate/Auth App.bru
@@ -1,7 +1,7 @@
meta {
- name: Auth
+ name: Auth App
type: http
- seq: 2
+ seq: 1
}
post {
diff --git a/bruno/propify/DataImport/Auth Bruno.bru b/bruno/propify/DataImport/Auth Bruno.bru
new file mode 100644
index 0000000..aec34d0
--- /dev/null
+++ b/bruno/propify/DataImport/Auth Bruno.bru
@@ -0,0 +1,47 @@
+meta {
+ name: Auth Bruno
+ type: http
+ seq: 2
+}
+
+post {
+ url: {{KEYCLOAK_BASE_URL}}/realms/{{KEYCLOAK_REALM}}/protocol/openid-connect/token
+ body: formUrlEncoded
+ auth: inherit
+}
+
+headers {
+ Content-Type: application/x-www-form-urlencoded
+}
+
+body:form-urlencoded {
+ grant_type: client_credentials
+ client_id: {{KEYCLOAK_BRUNO_CLIENT_ID}}
+ client_secret: {{KEYCLOAK_BRUNO_CLIENT_SECRET}}
+}
+
+script:post-response {
+ // Parse die JSON-Antwort
+ const jsonResponse = res.body;
+
+
+ if (jsonResponse.access_token) {
+ // Schreibe den access_token in eine Umgebungsvariable
+ // oder in eine collection-Variable
+
+ // Option 1: In eine Umgebungsvariable schreiben
+ // (z.B. für eine bestimmte Umgebung wie "Development")
+ bru.setEnvVar("BEARER_TOKEN", jsonResponse.access_token);
+
+ // Option 2: In eine Collection-Variable schreiben
+ // (Diese Variable ist global für alle Anfragen in deiner Collection)
+ // bru.setVar("bearerToken", "Bearer " + jsonResponse.access_token);
+ } else {
+ // optional: Error Handling, falls der Token nicht in der Antwort ist
+ console.log("Error: access_token not found in the response.");
+ }
+}
+
+settings {
+ encodeUrl: false
+}
diff --git a/bruno/propify/DataImport/Upload files.bru b/bruno/propify/DataImport/Upload files.bru
new file mode 100644
index 0000000..92023f3
--- /dev/null
+++ b/bruno/propify/DataImport/Upload files.bru
@@ -0,0 +1,39 @@
+meta {
+ name: Upload files
+ type: http
+ seq: 1
+}
+
+post {
+ url: {{API_BASE_URL}}/api/{{API_VERSION}}/data-import/persons
+ body: multipartForm
+ auth: bearer
+}
+
+auth:bearer {
+ token: {{BEARER_TOKEN}}
+}
+
+body:json {
+ {
+ "name": "Bungalow",
+ "street": "Hebbelstraße",
+ "houseNumber": "30",
+ "zipCode": "55127",
+ "city": "Mainz",
+ "country": "DE",
+ "notes": "Lorem ipsum"
+ }
+}
+
+body:multipart-form {
+ file: @file(/Users/muratoezkorkmaz/projects/misc/skamp/init_data/people-17911945-22.xlsx)
+}
+
+body:file {
+ file: @file(/Users/murat/Pictures/IMG_0229.jpeg) @contentType(image/jpeg)
+}
+
+settings {
+ encodeUrl: true
+}
diff --git a/bruno/propify/DataImport/folder.bru b/bruno/propify/DataImport/folder.bru
new file mode 100644
index 0000000..3053f79
--- /dev/null
+++ b/bruno/propify/DataImport/folder.bru
@@ -0,0 +1,8 @@
+meta {
+ name: DataImport
+ seq: 6
+}
+
+auth {
+ mode: inherit
+}
diff --git a/bruno/propify/environments/local.bru b/bruno/propify/environments/local.bru
index 917650f..1da1817 100644
--- a/bruno/propify/environments/local.bru
+++ b/bruno/propify/environments/local.bru
@@ -1,13 +1,15 @@
vars {
- API_BASE_URL: http://localhost:8080
+ API_BASE_URL: http://localhost:8180
API_VERSION: v1
- KEYCLOAK_BASE_URL: http://localhost:8280
+ KEYCLOAK_BASE_URL: https://kc.dev.localhost
DEV_USERNAME: dev@example.com
DEV_PASSWORD: dev
ADMIN_USERNAME: admin@example.com
ADMIN_PASSWORD: admin
KEYCLOAK_CLIENT_ID: skamp-app
KEYCLOAK_REALM: skamp
+ KEYCLOAK_BRUNO_CLIENT_ID: skamp-bruno
+ KEYCLOAK_BRUNO_CLIENT_SECRET: sNQpCVyVckGo5AZw7FqeW0POtgWuXzJt
}
vars:secret [
BEARER_TOKEN
diff --git a/init_data/people-17911945-22.xlsx b/init_data/people-17911945-22.xlsx
new file mode 100644
index 0000000..bf0c254
Binary files /dev/null and b/init_data/people-17911945-22.xlsx differ
diff --git a/pom.xml b/pom.xml
index 6a87b03..aea0f04 100644
--- a/pom.xml
+++ b/pom.xml
@@ -110,6 +110,13 @@
provided
+
+
+ com.alibaba
+ easyexcel
+ 3.3.4
+
+
diff --git a/src/main/java/de/iwomm/propify_api/controller/DataImportController.java b/src/main/java/de/iwomm/propify_api/controller/DataImportController.java
new file mode 100644
index 0000000..612d340
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/controller/DataImportController.java
@@ -0,0 +1,42 @@
+package de.iwomm.propify_api.controller;
+
+import de.iwomm.propify_api.service.PersonImportService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+
+@RestController
+@RequestMapping("/api/v1/data-import")
+public class DataImportController {
+
+ private final PersonImportService personImportService;
+
+ public DataImportController(PersonImportService personImportService) {
+ this.personImportService = personImportService;
+ }
+
+ @PostMapping("/persons")
+ public ResponseEntity> importPersons(@RequestParam("file") MultipartFile file) {
+ if (file.isEmpty()) {
+ return ResponseEntity.badRequest().body("Please upload a file");
+ }
+
+ if (!file.getOriginalFilename().endsWith(".xlsx")) {
+ return ResponseEntity.badRequest().body("Only .xlsx files are supported");
+ }
+
+ try {
+ PersonImportService.ImportResult result = personImportService.importPersonsFromExcel(file);
+ return ResponseEntity.ok(result);
+ } catch (IOException e) {
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body("Error processing file: " + e.getMessage());
+ } catch (Exception e) {
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body("Unexpected error: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/de/iwomm/propify_api/dto/PersonImportDTO.java b/src/main/java/de/iwomm/propify_api/dto/PersonImportDTO.java
new file mode 100644
index 0000000..9d74239
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/dto/PersonImportDTO.java
@@ -0,0 +1,117 @@
+package de.iwomm.propify_api.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+
+public class PersonImportDTO {
+
+ @ExcelProperty(index = 0, value = "Person - Name")
+ private String name;
+
+ @ExcelProperty(index = 1, value = "Person - Label")
+ private String labels;
+
+ @ExcelProperty(index = 2, value = "Person - Organisation")
+ private String organization;
+
+ @ExcelProperty(index = 3, value = "Person - E-Mail-Adresse - Büro")
+ private String emailOffice;
+
+ @ExcelProperty(index = 4, value = "Person - E-Mail-Adresse - Privat")
+ private String emailPrivate;
+
+ @ExcelProperty(index = 5, value = "Person - E-Mail-Adresse - Sonstiger")
+ private String emailOther;
+
+ @ExcelProperty(index = 6, value = "Person - Telefon - Büro")
+ private String phoneOffice;
+
+ @ExcelProperty(index = 7, value = "Person - Telefon - Privat")
+ private String phonePrivate;
+
+ @ExcelProperty(index = 8, value = "Person - Telefon - Mobil")
+ private String phoneMobile;
+
+ @ExcelProperty(index = 9, value = "Person - Telefon - Sonstiger")
+ private String phoneOther;
+
+ // Getters and Setters
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getLabels() {
+ return labels;
+ }
+
+ public void setLabels(String labels) {
+ this.labels = labels;
+ }
+
+ public String getOrganization() {
+ return organization;
+ }
+
+ public void setOrganization(String organization) {
+ this.organization = organization;
+ }
+
+ public String getEmailOffice() {
+ return emailOffice;
+ }
+
+ public void setEmailOffice(String emailOffice) {
+ this.emailOffice = emailOffice;
+ }
+
+ public String getEmailPrivate() {
+ return emailPrivate;
+ }
+
+ public void setEmailPrivate(String emailPrivate) {
+ this.emailPrivate = emailPrivate;
+ }
+
+ public String getEmailOther() {
+ return emailOther;
+ }
+
+ public void setEmailOther(String emailOther) {
+ this.emailOther = emailOther;
+ }
+
+ public String getPhoneOffice() {
+ return phoneOffice;
+ }
+
+ public void setPhoneOffice(String phoneOffice) {
+ this.phoneOffice = phoneOffice;
+ }
+
+ public String getPhonePrivate() {
+ return phonePrivate;
+ }
+
+ public void setPhonePrivate(String phonePrivate) {
+ this.phonePrivate = phonePrivate;
+ }
+
+ public String getPhoneMobile() {
+ return phoneMobile;
+ }
+
+ public void setPhoneMobile(String phoneMobile) {
+ this.phoneMobile = phoneMobile;
+ }
+
+ public String getPhoneOther() {
+ return phoneOther;
+ }
+
+ public void setPhoneOther(String phoneOther) {
+ this.phoneOther = phoneOther;
+ }
+}
diff --git a/src/main/java/de/iwomm/propify_api/entity/Industry.java b/src/main/java/de/iwomm/propify_api/entity/Industry.java
new file mode 100644
index 0000000..951e549
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/entity/Industry.java
@@ -0,0 +1,31 @@
+package de.iwomm.propify_api.entity;
+
+import jakarta.persistence.*;
+import java.util.UUID;
+
+@Entity
+public class Industry {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ @Column(nullable = false)
+ private UUID id;
+
+ @Column(nullable = false)
+ private String name;
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/de/iwomm/propify_api/entity/Organization.java b/src/main/java/de/iwomm/propify_api/entity/Organization.java
new file mode 100644
index 0000000..4162215
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/entity/Organization.java
@@ -0,0 +1,60 @@
+package de.iwomm.propify_api.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Entity
+public class Organization {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ @Column(nullable = false)
+ private UUID id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @ManyToOne()
+ @JoinColumn(name = "industry_id")
+ private Industry industry;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ @PrePersist
+ protected void onCreate() {
+ this.createdAt = LocalDateTime.now();
+ }
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Industry getIndustry() {
+ return industry;
+ }
+
+ public void setIndustry(Industry industry) {
+ this.industry = industry;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/de/iwomm/propify_api/entity/PersonLabel.java b/src/main/java/de/iwomm/propify_api/entity/PersonLabel.java
new file mode 100644
index 0000000..db396d3
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/entity/PersonLabel.java
@@ -0,0 +1,31 @@
+package de.iwomm.propify_api.entity;
+
+import jakarta.persistence.*;
+import java.util.UUID;
+
+@Entity
+public class PersonLabel {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ @Column(nullable = false)
+ private UUID id;
+
+ @Column(nullable = false)
+ private String name;
+
+ public UUID getId() {
+ return id;
+ }
+
+ public void setId(UUID id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/src/main/java/de/iwomm/propify_api/repository/IndustryRepository.java b/src/main/java/de/iwomm/propify_api/repository/IndustryRepository.java
new file mode 100644
index 0000000..9078b22
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/repository/IndustryRepository.java
@@ -0,0 +1,11 @@
+package de.iwomm.propify_api.repository;
+
+import de.iwomm.propify_api.entity.Industry;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface IndustryRepository extends JpaRepository {
+ Optional findByName(String name);
+}
diff --git a/src/main/java/de/iwomm/propify_api/repository/OrganizationRepository.java b/src/main/java/de/iwomm/propify_api/repository/OrganizationRepository.java
new file mode 100644
index 0000000..5f8e155
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/repository/OrganizationRepository.java
@@ -0,0 +1,11 @@
+package de.iwomm.propify_api.repository;
+
+import de.iwomm.propify_api.entity.Organization;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface OrganizationRepository extends JpaRepository {
+ Optional findByName(String name);
+}
diff --git a/src/main/java/de/iwomm/propify_api/repository/PersonLabelRepository.java b/src/main/java/de/iwomm/propify_api/repository/PersonLabelRepository.java
new file mode 100644
index 0000000..5bf9163
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/repository/PersonLabelRepository.java
@@ -0,0 +1,11 @@
+package de.iwomm.propify_api.repository;
+
+import de.iwomm.propify_api.entity.PersonLabel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface PersonLabelRepository extends JpaRepository {
+ Optional findByName(String name);
+}
diff --git a/src/main/java/de/iwomm/propify_api/repository/PersonRepository.java b/src/main/java/de/iwomm/propify_api/repository/PersonRepository.java
new file mode 100644
index 0000000..2df3f39
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/repository/PersonRepository.java
@@ -0,0 +1,11 @@
+package de.iwomm.propify_api.repository;
+
+import de.iwomm.propify_api.entity.Person;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface PersonRepository extends JpaRepository {
+ Optional findByKeycloakId(String keycloakId);
+}
diff --git a/src/main/java/de/iwomm/propify_api/service/PersonImportService.java b/src/main/java/de/iwomm/propify_api/service/PersonImportService.java
new file mode 100644
index 0000000..4702ac6
--- /dev/null
+++ b/src/main/java/de/iwomm/propify_api/service/PersonImportService.java
@@ -0,0 +1,229 @@
+package de.iwomm.propify_api.service;
+
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.context.AnalysisContext;
+import com.alibaba.excel.read.listener.ReadListener;
+import de.iwomm.propify_api.dto.PersonImportDTO;
+import de.iwomm.propify_api.entity.Person;
+import de.iwomm.propify_api.entity.PersonLabel;
+import de.iwomm.propify_api.repository.OrganizationRepository;
+import de.iwomm.propify_api.repository.PersonLabelRepository;
+import de.iwomm.propify_api.repository.PersonRepository;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Service
+public class PersonImportService {
+
+ private final PersonRepository personRepository;
+ private final PersonLabelRepository personLabelRepository;
+ private final OrganizationRepository organizationRepository;
+
+ public PersonImportService(PersonRepository personRepository,
+ PersonLabelRepository personLabelRepository,
+ OrganizationRepository organizationRepository) {
+ this.personRepository = personRepository;
+ this.personLabelRepository = personLabelRepository;
+ this.organizationRepository = organizationRepository;
+ }
+
+ @Transactional
+ public ImportResult importPersonsFromExcel(MultipartFile file) throws IOException {
+ ImportResult result = new ImportResult();
+
+ EasyExcel.read(file.getInputStream(), PersonImportDTO.class, new ReadListener() {
+ private int currentRow = 1; // Start at 1 to account for header
+
+ @Override
+ public void invoke(PersonImportDTO data, AnalysisContext context) {
+ currentRow++;
+ try {
+ Person person = convertToPerson(data);
+ personRepository.save(person);
+ result.incrementSuccess();
+ } catch (Exception e) {
+ result.addError(currentRow, e.getMessage());
+ }
+ }
+
+ @Override
+ public void doAfterAllAnalysed(AnalysisContext context) {
+ // Called after all data has been analyzed
+ }
+ }).sheet().doRead();
+
+ return result;
+ }
+
+ private Person convertToPerson(PersonImportDTO dto) {
+ Person person = new Person();
+
+ // Parse name (format: "firstName lastName")
+ if (dto.getName() != null && !dto.getName().trim().isEmpty()) {
+ String[] nameParts = dto.getName().trim().split("\\s+", 2);
+ person.setFirstName(nameParts[0]);
+ person.setLastName(nameParts.length > 1 ? nameParts[1] : null);
+ } else {
+ person.setFirstName(null);
+ person.setLastName(null);
+ }
+
+ // Parse labels (comma-separated)
+ if (dto.getLabels() != null && !dto.getLabels().trim().isEmpty()) {
+ Set labels = new HashSet<>();
+ String[] labelNames = dto.getLabels().split(",");
+ for (String labelName : labelNames) {
+ String trimmedName = labelName.trim();
+ if (!trimmedName.isEmpty()) {
+ PersonLabel label = personLabelRepository.findByName(trimmedName)
+ .orElseGet(() -> {
+ PersonLabel newLabel = new PersonLabel();
+ newLabel.setName(trimmedName);
+ return personLabelRepository.save(newLabel);
+ });
+ labels.add(label);
+ }
+ }
+ person.setLabels(labels);
+ }
+
+ // Organization (not yet linked in Person entity)
+ // String organization = dto.getOrganization();
+
+ // Email addresses
+ person.setEmailOffice(trimOrNull(dto.getEmailOffice()));
+ person.setEmailPrivate(trimOrNull(dto.getEmailPrivate()));
+ person.setEmailOther(trimOrNull(dto.getEmailOther()));
+
+ // Office phone
+ if (dto.getPhoneOffice() != null && !dto.getPhoneOffice().trim().isEmpty()) {
+ PhoneNumber parsed = parsePhoneNumber(dto.getPhoneOffice());
+ person.setPhoneOfficeCountryCode(parsed.countryCode);
+ person.setPhoneOfficeAreaCode(parsed.areaCode);
+ person.setPhoneOfficeNumber(parsed.number);
+ }
+
+ // Private phone
+ if (dto.getPhonePrivate() != null && !dto.getPhonePrivate().trim().isEmpty()) {
+ PhoneNumber parsed = parsePhoneNumber(dto.getPhonePrivate());
+ person.setPhonePrivateCountryCode(parsed.countryCode);
+ person.setPhonePrivateAreaCode(parsed.areaCode);
+ person.setPhonePrivateNumber(parsed.number);
+ }
+
+ // Mobile phone (prefer phoneMobile, fallback to phoneOther)
+ String mobile = dto.getPhoneMobile();
+ String other = dto.getPhoneOther();
+
+ if (mobile != null && !mobile.trim().isEmpty()) {
+ person.setPhoneMobile(mobile.trim());
+ } else if (other != null && !other.trim().isEmpty()) {
+ person.setPhoneMobile(other.trim());
+ }
+
+ return person;
+ }
+
+ private String trimOrNull(String value) {
+ if (value == null || value.trim().isEmpty()) {
+ return null;
+ }
+ return value.trim();
+ }
+
+ private PhoneNumber parsePhoneNumber(String phone) {
+ PhoneNumber result = new PhoneNumber();
+
+ // Remove all whitespace and special characters except + and digits
+ String cleaned = phone.replaceAll("[^+\\d]", "");
+
+ // Pattern to match: +CountryCode AreaCode Number
+ // Examples: +493012345678, +4930123456
+ Pattern pattern = Pattern.compile("^(\\+\\d{1,3})(\\d{2,5})(\\d+)$");
+ Matcher matcher = pattern.matcher(cleaned);
+
+ if (matcher.matches()) {
+ result.countryCode = matcher.group(1);
+ result.areaCode = matcher.group(2);
+ result.number = matcher.group(3);
+ } else {
+ // If pattern doesn't match, try simpler parsing
+ if (cleaned.startsWith("+")) {
+ // Assume format: +CC followed by rest
+ int ccEnd = Math.min(4, cleaned.length()); // Max 3 digits + '+'
+ result.countryCode = cleaned.substring(0, ccEnd);
+
+ String rest = cleaned.substring(ccEnd);
+ if (rest.length() > 4) {
+ // Split remaining into area code (2-3 digits) and number
+ result.areaCode = rest.substring(0, Math.min(3, rest.length() - 4));
+ result.number = rest.substring(Math.min(3, rest.length() - 4));
+ } else {
+ result.number = rest;
+ }
+ } else {
+ // No country code, store as-is in number field
+ result.number = cleaned;
+ }
+ }
+
+ return result;
+ }
+
+ private static class PhoneNumber {
+ String countryCode;
+ String areaCode;
+ String number;
+ }
+
+ public static class ImportResult {
+ private int successCount = 0;
+ private int errorCount = 0;
+ private List errors = new ArrayList<>();
+
+ public void incrementSuccess() {
+ successCount++;
+ }
+
+ public void addError(int row, String message) {
+ errorCount++;
+ errors.add(new ImportError(row, message));
+ }
+
+ public int getSuccessCount() {
+ return successCount;
+ }
+
+ public int getErrorCount() {
+ return errorCount;
+ }
+
+ public List getErrors() {
+ return errors;
+ }
+ }
+
+ public static class ImportError {
+ private int row;
+ private String message;
+
+ public ImportError(int row, String message) {
+ this.row = row;
+ this.message = message;
+ }
+
+ public int getRow() {
+ return row;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+ }
+}