Compare commits

...

4 Commits

Author SHA1 Message Date
Murat Özkorkmaz
fb416dff55 Several fixes
All checks were successful
Build, Push and Deploy / build-and-deploy (push) Successful in 55s
- added organizations
- added industries
- added logo in 2 colors for light and dark theme
- improved authorization to allow multi tenancy
- added Bruno configs
2025-11-13 19:56:12 +01:00
Murat Özkorkmaz
62c13ff0b1 Implemented bulk import of persons
All checks were successful
Build, Push and Deploy / build-and-deploy (push) Successful in 1m43s
2025-11-04 13:40:33 +01:00
Murat Özkorkmaz
d85406f0c7 Implemented bulk import of persons 2025-11-04 12:29:32 +01:00
Murat Özkorkmaz
60dc35961a Updating local dev server port 2025-11-04 08:49:58 +01:00
55 changed files with 2533 additions and 14 deletions

292
DEPLOYMENT.md Normal file
View File

@@ -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

181
DEPLOYMENT_SECRETS.md Normal file
View File

@@ -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

View File

@@ -1,7 +1,7 @@
meta {
name: Auth
name: Auth Bruno
type: http
seq: 2
seq: 1
}
post {
@@ -15,10 +15,9 @@ headers {
}
body:form-urlencoded {
grant_type: password
client_id: {{KEYCLOAK_CLIENT_ID}}
username: {{ADMIN_USERNAME}}
password: {{ADMIN_PASSWORD}}
grant_type: client_credentials
client_id: {{KEYCLOAK_BRUNO_CLIENT_ID}}
client_secret: {{KEYCLOAK_BRUNO_CLIENT_SECRET}}
}
script:post-response {
@@ -34,6 +33,7 @@ script:post-response {
// (z.B. für eine bestimmte Umgebung wie "Development")
bru.setEnvVar("BEARER_TOKEN", jsonResponse.access_token);
console.log("Updated 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);

View File

@@ -0,0 +1,39 @@
meta {
name: Upload Organizations
type: http
seq: 2
}
post {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/data-import/organizations
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/organizations-17911945-23.xlsx)
}
body:file {
file: @file(/Users/murat/Pictures/IMG_0229.jpeg) @contentType(image/jpeg)
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,39 @@
meta {
name: Upload Persons
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
}

View File

@@ -0,0 +1,8 @@
meta {
name: DataImport
seq: 6
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,31 @@
meta {
name: Create new
type: http
seq: 3
}
post {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/industries
body: json
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"
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,31 @@
meta {
name: Delete one by ID
type: http
seq: 4
}
delete {
url: API_BASE_URL}}/api/{{API_VERSION}}/industries/e64d5e53-cd45-45fb-9237-46078077bf22
body: json
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
body:json {
{
"name": "Mustername 1",
"street": "Musterstraße",
"houseNumber": "1",
"zipCode": "55123",
"city": "Musterstadt",
"country": "de",
"notes": "Lorem ipsum"
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get all
type: http
seq: 1
}
get {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/industries
body: none
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get one by ID
type: http
seq: 2
}
get {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/industries/da002464-33e9-4bfd-9ae2-836b26955502
body: none
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,39 @@
meta {
name: Upload files
type: http
seq: 5
}
post {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/industries/f4eb0c54-7c8f-4e60-b71f-1d08b8b6e2d4/upload
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 {
attachments: @file(/Users/murat/Pictures/IMG_0229.jpeg|/Users/murat/Pictures/schnürsenkel technik.gif)
}
body:file {
file: @file(/Users/murat/Pictures/IMG_0229.jpeg) @contentType(image/jpeg)
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Industries
seq: 9
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,31 @@
meta {
name: Create new
type: http
seq: 3
}
post {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/organizations
body: json
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"
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,31 @@
meta {
name: Delete one by ID
type: http
seq: 4
}
delete {
url: API_BASE_URL}}/api/{{API_VERSION}}/organizations/e64d5e53-cd45-45fb-9237-46078077bf22
body: json
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
body:json {
{
"name": "Mustername 1",
"street": "Musterstraße",
"houseNumber": "1",
"zipCode": "55123",
"city": "Musterstadt",
"country": "de",
"notes": "Lorem ipsum"
}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get all
type: http
seq: 1
}
get {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/organizations
body: none
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,19 @@
meta {
name: Get one by ID
type: http
seq: 2
}
get {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/organizations/5dba067e-d7fd-4d79-a08a-ec379834938a
body: none
auth: bearer
}
auth:bearer {
token: {{BEARER_TOKEN}}
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,39 @@
meta {
name: Upload files
type: http
seq: 5
}
post {
url: {{API_BASE_URL}}/api/{{API_VERSION}}/organizations/f4eb0c54-7c8f-4e60-b71f-1d08b8b6e2d4/upload
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 {
attachments: @file(/Users/murat/Pictures/IMG_0229.jpeg|/Users/murat/Pictures/schnürsenkel technik.gif)
}
body:file {
file: @file(/Users/murat/Pictures/IMG_0229.jpeg) @contentType(image/jpeg)
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Organizations
seq: 8
}
auth {
mode: inherit
}

View File

@@ -1,14 +1,16 @@
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
}
vars:secret [
BEARER_TOKEN
BEARER_TOKEN,
KEYCLOAK_BRUNO_CLIENT_SECRET
]

Binary file not shown.

Binary file not shown.

View File

@@ -110,6 +110,13 @@
<scope>provided</scope>
</dependency>
<!-- EasyExcel for Excel processing -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.4</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,68 @@
package de.iwomm.propify_api.controller;
import de.iwomm.propify_api.service.OrganizationImportService;
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;
private final OrganizationImportService organizationImportService;
public DataImportController(PersonImportService personImportService,
OrganizationImportService organizationImportService) {
this.personImportService = personImportService;
this.organizationImportService = organizationImportService;
}
@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());
}
}
@PostMapping("/organizations")
public ResponseEntity<?> importOrganizations(@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 {
OrganizationImportService.ImportResult result = organizationImportService.importOrganizationsFromExcel(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());
}
}
}

View File

@@ -0,0 +1,134 @@
package de.iwomm.propify_api.controller;
import de.iwomm.propify_api.dto.*;
import de.iwomm.propify_api.entity.Industry;
import de.iwomm.propify_api.mapper.IndustryMapper;
import de.iwomm.propify_api.service.IndustryService;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/industries")
public class IndustryController {
private final IndustryService industryService;
private final IndustryMapper industryMapper;
public IndustryController(IndustryService industryService, IndustryMapper industryMapper) {
this.industryService = industryService;
this.industryMapper = industryMapper;
}
@GetMapping
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> getAll() {
List<IndustryDTO> industryDTOs = industryService.toDTOs(industryService.findAll());
return ResponseEntity
.ok(industryDTOs);
}
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> getById(@PathVariable UUID id) {
try {
Industry industry = industryService.findById(id).orElseThrow(EntityNotFoundException::new);
IndustryDTO projectDTO = industryMapper.toDto(industry);
return ResponseEntity
.ok(projectDTO);
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@PostMapping
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> create(@RequestBody IndustryDTO newIndustryDTO) {
try {
Industry newItem = industryService.save(newIndustryDTO);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(newItem.getId())
.toUri();
return ResponseEntity
.created(location)
.body(newItem);
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@PatchMapping("/{id}")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> update(@PathVariable UUID id, @RequestBody IndustryDTO industryDTO) {
try {
return ResponseEntity
.ok(industryService.update(id, industryDTO));
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> delete(@PathVariable UUID id) {
try {
industryService.deleteById(id);
return ResponseEntity
.noContent()
.build();
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@PostMapping("/bulk-delete")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER', 'ROLE_DEV')")
public ResponseEntity<?> deleteMany(@RequestBody BulkDeleteIdsDTO bulkDeleteIdsDTO) {
try {
industryService.deleteByIds(bulkDeleteIdsDTO);
return ResponseEntity
.noContent()
.build();
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
}

View File

@@ -0,0 +1,102 @@
package de.iwomm.propify_api.controller;
import de.iwomm.propify_api.dto.OrganizationDTO;
import de.iwomm.propify_api.dto.ProjectDetailsDTO;
import de.iwomm.propify_api.entity.Organization;
import de.iwomm.propify_api.entity.Project;
import de.iwomm.propify_api.mapper.OrganizationMapper;
import de.iwomm.propify_api.service.OrganisationService;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/organizations")
public class OrganizationController {
private final OrganizationMapper organizationMapper;
private OrganisationService organisationService;
public OrganizationController(OrganisationService organisationService, OrganizationMapper organizationMapper) {
this.organisationService = organisationService;
this.organizationMapper = organizationMapper;
}
@GetMapping
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER')")
public ResponseEntity<?> getAll() {
List<OrganizationDTO> organizationDTOs = organisationService.toDTOs(organisationService.findAll());
return ResponseEntity
.ok(organizationDTOs);
}
@GetMapping("/{id}")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER')")
public ResponseEntity<?> getById(@PathVariable UUID id) {
try {
Organization organization = organisationService.findById(id).orElseThrow(EntityNotFoundException::new);
OrganizationDTO organizationDTO = organizationMapper.toDto(organization);
return ResponseEntity
.ok(organizationDTO);
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_USER')")
public ResponseEntity<?> deleteById(@PathVariable UUID id) {
try {
Organization organization = organisationService.findById(id).orElseThrow(EntityNotFoundException::new);
organisationService.delete(organization);
return ResponseEntity
.ok()
.build();
} catch (EntityNotFoundException e) {
return ResponseEntity
.notFound()
.build();
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
@PostMapping
@PreAuthorize("hasAnyRole('ROLE_ADMIN', 'ROLE_DEV')")
public ResponseEntity<?> create(@RequestBody OrganizationDTO organizationDTO) {
try {
Organization newItem = organisationService.save(organizationDTO);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(newItem.getId())
.toUri();
return ResponseEntity
.created(location)
.body(newItem);
} catch (Exception e) {
return ResponseEntity
.internalServerError()
.build();
}
}
}

View File

@@ -0,0 +1,9 @@
package de.iwomm.propify_api.dto;
import java.util.List;
import java.util.UUID;
public record BulkDeleteIdsDTO(
List<UUID> ids
) {
}

View File

@@ -0,0 +1,9 @@
package de.iwomm.propify_api.dto;
import java.util.UUID;
public record IndustryDTO(
UUID id,
String name
) {
}

View File

@@ -0,0 +1,10 @@
package de.iwomm.propify_api.dto;
import java.util.UUID;
public record NewOrganizationDTO(
String name,
String owner,
UUID industryId
) {
}

View File

@@ -0,0 +1,14 @@
package de.iwomm.propify_api.dto;
import de.iwomm.propify_api.entity.Industry;
import java.util.Set;
import java.util.UUID;
public record OrganizationDTO(
UUID id,
String name,
IndustryDTO industry,
String owner,
Set<OrganizationLabelDTO> labels
) { }

View File

@@ -0,0 +1,84 @@
package de.iwomm.propify_api.dto;
import com.alibaba.excel.annotation.ExcelProperty;
public class OrganizationImportDTO {
@ExcelProperty(index = 0, value = "Organisation - Name")
private String name;
@ExcelProperty(index = 1, value = "Organisation - Branche Intern")
private String industry;
@ExcelProperty(index = 2, value = "Organisation - Label")
private String labels;
@ExcelProperty(index = 3, value = "Organisation - Abgeschlossene Deals")
private String completedDeals;
@ExcelProperty(index = 4, value = "Organisation - Offene Deals")
private String openDeals;
@ExcelProperty(index = 5, value = "Organisation - Datum nächste Aktivität")
private String nextActivityDate;
@ExcelProperty(index = 6, value = "Organisation - Besitzer")
private String owner;
// Getters and Setters
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getIndustry() {
return industry;
}
public void setIndustry(String industry) {
this.industry = industry;
}
public String getLabels() {
return labels;
}
public void setLabels(String labels) {
this.labels = labels;
}
public String getCompletedDeals() {
return completedDeals;
}
public void setCompletedDeals(String completedDeals) {
this.completedDeals = completedDeals;
}
public String getOpenDeals() {
return openDeals;
}
public void setOpenDeals(String openDeals) {
this.openDeals = openDeals;
}
public String getNextActivityDate() {
return nextActivityDate;
}
public void setNextActivityDate(String nextActivityDate) {
this.nextActivityDate = nextActivityDate;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
}

View File

@@ -0,0 +1,9 @@
package de.iwomm.propify_api.dto;
import java.util.UUID;
public record OrganizationLabelDTO(
UUID id,
String name
) {
}

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
package de.iwomm.propify_api.entity;
import de.iwomm.propify_api.entitylistener.TenantEntityListener;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
@MappedSuperclass
@EntityListeners(TenantEntityListener.class)
public abstract class MultiTenantEntity {
@Column(name = "tenant_id")
private String tenantId;
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
}

View File

@@ -0,0 +1,90 @@
package de.iwomm.propify_api.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
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
private String owner;
@Column(nullable = false)
private LocalDateTime createdAt;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "organization_labels",
joinColumns = @JoinColumn(name = "organization_id"),
inverseJoinColumns = @JoinColumn(name = "label_id"),
foreignKey = @ForeignKey(name = "fk_organization_labels_organization", foreignKeyDefinition = "FOREIGN KEY (organization_id) REFERENCES organization(id) ON DELETE CASCADE")
)
private Set<OrganizationLabel> labels = new HashSet<>();
@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 String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Set<OrganizationLabel> getLabels() {
return labels;
}
public void setLabels(Set<OrganizationLabel> labels) {
this.labels = labels;
}
}

View File

@@ -0,0 +1,31 @@
package de.iwomm.propify_api.entity;
import jakarta.persistence.*;
import java.util.UUID;
@Entity
public class OrganizationLabel {
@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;
}
}

View File

@@ -1,4 +1,199 @@
package de.iwomm.propify_api.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(nullable = false)
private UUID id;
@Column
private String firstName;
@Column
private String lastName;
@Column
private String emailOffice;
@Column
private String emailPrivate;
@Column
private String emailOther;
@Column
private String phoneOfficeCountryCode;
@Column
private String phoneOfficeAreaCode;
@Column
private String phoneOfficeNumber;
@Column
private String phonePrivateCountryCode;
@Column
private String phonePrivateAreaCode;
@Column
private String phonePrivateNumber;
@Column
private String phoneMobile;
@Column(unique = true)
private String keycloakId;
@Column(nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "person_labels",
joinColumns = @JoinColumn(name = "person_id"),
inverseJoinColumns = @JoinColumn(name = "label_id"),
foreignKey = @ForeignKey(name = "fk_person_labels_person", foreignKeyDefinition = "FOREIGN KEY (person_id) REFERENCES person(id) ON DELETE CASCADE")
)
private Set<PersonLabel> labels = new HashSet<>();
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
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 getPhoneOfficeCountryCode() {
return phoneOfficeCountryCode;
}
public void setPhoneOfficeCountryCode(String phoneOfficeCountryCode) {
this.phoneOfficeCountryCode = phoneOfficeCountryCode;
}
public String getPhoneOfficeAreaCode() {
return phoneOfficeAreaCode;
}
public void setPhoneOfficeAreaCode(String phoneOfficeAreaCode) {
this.phoneOfficeAreaCode = phoneOfficeAreaCode;
}
public String getPhoneOfficeNumber() {
return phoneOfficeNumber;
}
public void setPhoneOfficeNumber(String phoneOfficeNumber) {
this.phoneOfficeNumber = phoneOfficeNumber;
}
public String getPhonePrivateCountryCode() {
return phonePrivateCountryCode;
}
public void setPhonePrivateCountryCode(String phonePrivateCountryCode) {
this.phonePrivateCountryCode = phonePrivateCountryCode;
}
public String getPhonePrivateAreaCode() {
return phonePrivateAreaCode;
}
public void setPhonePrivateAreaCode(String phonePrivateAreaCode) {
this.phonePrivateAreaCode = phonePrivateAreaCode;
}
public String getPhonePrivateNumber() {
return phonePrivateNumber;
}
public void setPhonePrivateNumber(String phonePrivateNumber) {
this.phonePrivateNumber = phonePrivateNumber;
}
public String getPhoneMobile() {
return phoneMobile;
}
public void setPhoneMobile(String phoneMobile) {
this.phoneMobile = phoneMobile;
}
public String getKeycloakId() {
return keycloakId;
}
public void setKeycloakId(String keycloakId) {
this.keycloakId = keycloakId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public Set<PersonLabel> getLabels() {
return labels;
}
public void setLabels(Set<PersonLabel> labels) {
this.labels = labels;
}
}

View File

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

View File

@@ -2,6 +2,9 @@ package de.iwomm.propify_api.entity;
import com.fasterxml.jackson.annotation.JsonManagedReference;
import jakarta.persistence.*;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import java.time.LocalDate;
import java.time.LocalDateTime;
@@ -10,7 +13,9 @@ import java.util.List;
import java.util.UUID;
@Entity
public class Project {
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Project extends MultiTenantEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(nullable = false)

View File

@@ -0,0 +1,18 @@
package de.iwomm.propify_api.entitylistener;
import de.iwomm.propify_api.entity.MultiTenantEntity;
import de.iwomm.propify_api.security.TenantContext;
import jakarta.persistence.PrePersist;
public class TenantEntityListener {
@PrePersist
public void setTenant(Object entity) {
if (entity instanceof MultiTenantEntity mte) {
String tenantId = TenantContext.getTenantId();
if (tenantId != null && mte.getTenantId() == null) {
mte.setTenantId(tenantId);
}
}
}
}

View File

@@ -0,0 +1,11 @@
package de.iwomm.propify_api.mapper;
import de.iwomm.propify_api.dto.IndustryDTO;
import de.iwomm.propify_api.entity.Industry;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface IndustryMapper {
IndustryDTO toDto(Industry industry);
Industry fromDto(IndustryDTO industryDTO);
}

View File

@@ -0,0 +1,11 @@
package de.iwomm.propify_api.mapper;
import de.iwomm.propify_api.dto.OrganizationLabelDTO;
import de.iwomm.propify_api.entity.OrganizationLabel;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface OrganizationLabelMapper {
OrganizationLabelDTO toDto(OrganizationLabel organizationLabel);
OrganizationLabel fromDto(OrganizationLabelDTO organizationLabelDTO);
}

View File

@@ -0,0 +1,12 @@
package de.iwomm.propify_api.mapper;
import de.iwomm.propify_api.dto.NewOrganizationDTO;
import de.iwomm.propify_api.dto.OrganizationDTO;
import de.iwomm.propify_api.entity.Organization;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring", uses = { OrganizationLabelMapper.class, IndustryMapper.class })
public interface OrganizationMapper {
OrganizationDTO toDto(Organization organization);
Organization fromDto(OrganizationDTO organizationDTO);
}

View File

@@ -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<Industry, UUID> {
Optional<Industry> findByName(String name);
}

View File

@@ -0,0 +1,9 @@
package de.iwomm.propify_api.repository;
import de.iwomm.propify_api.entity.OrganizationLabel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface OrganizationLabelRepository extends JpaRepository<OrganizationLabel, UUID> {
}

View File

@@ -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<Organization, UUID> {
Optional<Organization> findByName(String name);
}

View File

@@ -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<PersonLabel, UUID> {
Optional<PersonLabel> findByName(String name);
}

View File

@@ -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<Person, UUID> {
Optional<Person> findByKeycloakId(String keycloakId);
}

View File

@@ -0,0 +1,17 @@
package de.iwomm.propify_api.security;
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}

View File

@@ -0,0 +1,75 @@
package de.iwomm.propify_api.security;
import jakarta.persistence.EntityManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.hibernate.Session;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@Order(1)
public class TenantFilter extends OncePerRequestFilter {
private final EntityManager entityManager;
public TenantFilter(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String tenantId = null;
// 1⃣ Tenant-ID aus JWT lesen
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
// tenant_id Claim
tenantId = jwt.getClaimAsString("tenant_id");
// Rollen prüfen, Superadmin darf alles sehen
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null && roles.contains("ROLE_SUPERADMIN")) {
tenantId = null; // kein Filter für Superadmin
}
}
// 2⃣ Fallback: Header (optional, falls JWT fehlt)
if (tenantId == null) {
tenantId = request.getHeader("X-Tenant-ID");
}
// 3⃣ TenantContext für diesen Thread setzen
TenantContext.setTenantId(tenantId);
// 4⃣ Hibernate Filter aktivieren (falls Tenant-ID vorhanden)
if (tenantId != null) {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
}
try {
filterChain.doFilter(request, response);
} finally {
// 5⃣ Aufräumen nach Request
TenantContext.clear();
}
}
}

View File

@@ -0,0 +1,69 @@
package de.iwomm.propify_api.service;
import de.iwomm.propify_api.dto.*;
import de.iwomm.propify_api.entity.Industry;
import de.iwomm.propify_api.entity.Organization;
import de.iwomm.propify_api.entity.Project;
import de.iwomm.propify_api.entity.Property;
import de.iwomm.propify_api.mapper.IndustryMapper;
import de.iwomm.propify_api.repository.IndustryRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class IndustryService {
private IndustryRepository industryRepository;
private IndustryMapper industryMapper;
public IndustryService(IndustryRepository industryRepository, IndustryMapper industryMapper) {
this.industryRepository = industryRepository;
this.industryMapper = industryMapper;
}
public List<Industry> findAll() {
return industryRepository.findAll();
}
public Optional<Industry> findById(UUID id) {
return industryRepository.findById(id);
}
public Industry save(IndustryDTO dto) {
return industryRepository.save(industryMapper.fromDto(dto));
}
public void deleteByIds(BulkDeleteIdsDTO bulkDeleteIdsDTO) {
industryRepository.deleteAllById(bulkDeleteIdsDTO.ids());
}
public List<IndustryDTO> toDTOs(List<Industry> industries) {
List<IndustryDTO> dtos = new ArrayList<>();
industries.forEach(industry -> {
dtos.add(this.industryMapper.toDto(industry));
});
return dtos;
}
public Industry update(UUID id, IndustryDTO industryDTO) {
Industry updated = industryRepository.findById(id).orElseThrow(EntityNotFoundException::new);
updated.setName(industryDTO.name());
industryRepository.save(updated);
return updated;
}
public void deleteById(UUID id) {
Industry industry = industryRepository.findById(id).orElseThrow(EntityNotFoundException::new);
industryRepository.delete(industry);
}
}

View File

@@ -0,0 +1,59 @@
package de.iwomm.propify_api.service;
import de.iwomm.propify_api.dto.NewOrganizationDTO;
import de.iwomm.propify_api.dto.OrganizationDTO;
import de.iwomm.propify_api.dto.ProjectDTO;
import de.iwomm.propify_api.entity.Organization;
import de.iwomm.propify_api.entity.Project;
import de.iwomm.propify_api.mapper.OrganizationMapper;
import de.iwomm.propify_api.repository.OrganizationRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class OrganisationService {
private final OrganizationRepository organizationRepository;
private final OrganizationMapper organizationMapper;
public OrganisationService(
OrganizationRepository organizationRepository,
OrganizationMapper organizationMapper
) {
this.organizationRepository = organizationRepository;
this.organizationMapper = organizationMapper;
}
public List<Organization> findAll() {
return organizationRepository.findAll();
}
public Optional<Organization> findById(UUID id) {
return organizationRepository.findById(id);
}
public void deleteBbyId(UUID id) {
organizationRepository.deleteById(id);
}
public void delete(Organization organization) {
organizationRepository.delete(organization);
}
public List<OrganizationDTO> toDTOs(List<Organization> organizations) {
List<OrganizationDTO> dtos = new ArrayList<>();
organizations.forEach(organization -> {
dtos.add(this.organizationMapper.toDto(organization));
});
return dtos;
}
public Organization save(OrganizationDTO dto) {
return organizationRepository.save(organizationMapper.fromDto(dto));
}
}

View File

@@ -0,0 +1,137 @@
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.OrganizationImportDTO;
import de.iwomm.propify_api.entity.Industry;
import de.iwomm.propify_api.entity.Organization;
import de.iwomm.propify_api.repository.IndustryRepository;
import de.iwomm.propify_api.repository.OrganizationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.*;
@Service
public class OrganizationImportService {
private final OrganizationRepository organizationRepository;
private final IndustryRepository industryRepository;
public OrganizationImportService(OrganizationRepository organizationRepository,
IndustryRepository industryRepository) {
this.organizationRepository = organizationRepository;
this.industryRepository = industryRepository;
}
@Transactional
public ImportResult importOrganizationsFromExcel(MultipartFile file) throws IOException {
ImportResult result = new ImportResult();
EasyExcel.read(file.getInputStream(), OrganizationImportDTO.class, new ReadListener<OrganizationImportDTO>() {
private int currentRow = 1; // Start at 1 to account for header
@Override
public void invoke(OrganizationImportDTO data, AnalysisContext context) {
currentRow++;
try {
Organization organization = convertToOrganization(data);
organizationRepository.save(organization);
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 Organization convertToOrganization(OrganizationImportDTO dto) {
Organization organization = new Organization();
// Name
if (dto.getName() != null && !dto.getName().trim().isEmpty()) {
organization.setName(dto.getName().trim());
} else {
throw new IllegalArgumentException("Name is required");
}
// Industry (Branche) - create or lookup
if (dto.getIndustry() != null && !dto.getIndustry().trim().isEmpty()) {
String industryName = dto.getIndustry().trim();
Industry industry = industryRepository.findByName(industryName)
.orElseGet(() -> {
Industry newIndustry = new Industry();
newIndustry.setName(industryName);
return industryRepository.save(newIndustry);
});
organization.setIndustry(industry);
}
// Owner
organization.setOwner(trimOrNull(dto.getOwner()));
return organization;
}
private String trimOrNull(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
public static class ImportResult {
private int successCount = 0;
private int errorCount = 0;
private List<ImportError> 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<ImportError> 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;
}
}
}

View File

@@ -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<PersonImportDTO>() {
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<PersonLabel> 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<ImportError> 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<ImportError> 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;
}
}
}

View File

@@ -1,5 +1,5 @@
spring:
spring:
servlet:
multipart:
max-file-size: 1000MB # Maximal zulässige Größe pro Datei (z.B. 10 MB)
@@ -38,7 +38,8 @@ spring:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8280/realms/skamp}
issuer-uri: ${KEYCLOAK_ISSUER_URI:https://kc.dev.localhost/realms/skamp}
#issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8888/realms/skamp}
jwk-set-uri: ${spring.security.oauth2.resourceserver.jwt.issuer-uri}/protocol/openid-connect/certs
logging:
@@ -58,4 +59,6 @@ cors:
s3:
access-key: ${MINIO_ACCESS_KEY:dev}
secret-key: ${MINIO_SECRET_KEY:dev123456}
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
endpoint: ${MINIO_ENDPOINT:http://localhost:9100}
server:
port: ${SERVER_PORT:8180}