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; + } + } +}