Compare commits

...

14 Commits

Author SHA1 Message Date
52b3a23b46 Insert GITEA action
Some checks failed
PHPUnit & CS-Fixer / test (push) Has been cancelled
2025-11-09 13:26:59 +01:00
05fe1fedf0 Trigger pipeline
Some checks failed
PHPUnit & CS-Fixer / test (push) Has been cancelled
2025-11-09 13:00:55 +01:00
75612064d9 Insert GITEA action
Some checks failed
PHPUnit & CS-Fixer / test (push) Has been cancelled
2025-11-09 12:20:03 +01:00
14c83635f9 Trigger Gitea pipeline
Some checks failed
PHPUnit & CS-Fixer / test (push) Has been cancelled
2025-11-09 12:08:51 +01:00
5f216f1317 Insert GITEA action
Some checks failed
PHPUnit & CS-Fixer / test (push) Has been cancelled
2025-11-09 11:40:18 +01:00
4953d192c0 API updadte 2025-11-09 11:27:21 +01:00
77206224a2 API updadte 2025-11-09 11:12:48 +01:00
7548e241be API updadte 2025-11-09 10:15:40 +01:00
5835eb15ed API updadte 2025-11-08 23:44:33 +01:00
81374cc659 API updadte 2025-11-08 23:21:26 +01:00
c0c346a9ed API updadte 2025-11-08 22:51:35 +01:00
0fb028d19a API updadte 2025-11-08 22:19:33 +01:00
58c7907915 API updadte 2025-11-08 19:02:36 +01:00
320f2f30af Immobilien & User hinzugefügt 2025-11-08 18:56:59 +01:00
62 changed files with 9095 additions and 439 deletions

17
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,17 @@
name: PHPUnit & CS-Fixer
on:
push:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run php-cs-fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Run PHPUnit
run: vendor/bin/phpunit

10
.gitignore vendored
View File

@@ -84,3 +84,13 @@ docker-compose.override.yml
/var/
/vendor/
###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###
###> friendsofphp/php-cs-fixer ###
/.php-cs-fixer.php
/.php-cs-fixer.cache
###< friendsofphp/php-cs-fixer ###

30
.php-cs-fixer.dist.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
$finder = (new PhpCsFixer\Finder())
->in(__DIR__)
->exclude('var')
->exclude('vendor')
->exclude('migrations')
->notPath('bin/console')
->notPath('public/index.php')
;
return (new PhpCsFixer\Config())
->setRules([
'@Symfony' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'not_operator_with_successor_space' => true,
'trailing_comma_in_multiline' => true,
'phpdoc_scalar' => true,
'unary_operator_spaces' => true,
'binary_operator_spaces' => true,
'blank_line_before_statement' => [
'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'],
],
'phpdoc_single_line_var_spacing' => true,
'phpdoc_var_without_name' => true,
])
->setFinder($finder)
;

View File

@@ -1,46 +1,57 @@
# Production-ready Symfony Dockerfile
FROM php:8.4-apache
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
RUN apt-get update && apt-get install -y --no-install-recommends \
libpng-dev \
libonig-dev \
libxml2-dev \
libicu-dev \
zip \
unzip \
libzip-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
git \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions for Symfony
# Install PHP extensions needed by Symfony
RUN docker-php-ext-configure intl \
&& docker-php-ext-install pdo_mysql mysqli mbstring exif pcntl bcmath gd zip intl opcache
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
intl \
opcache
# Configure opcache for Symfony
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini \
&& echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/docker-php-ext-opcache.ini
# Enable Apache modules
RUN a2enmod rewrite headers
# Enable Apache mod_rewrite
RUN a2enmod rewrite
# Enable Apache headers module
RUN a2enmod headers
# Copy custom Apache configuration
# Copy Apache configuration
COPY ./docker/apache/000-default.conf /etc/apache2/sites-available/000-default.conf
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy Composer from official image
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Change ownership
# Copy Symfony app
COPY . /var/www/html
# Optimize permissions
RUN chown -R www-data:www-data /var/www/html
# Configure opcache for production
RUN { \
echo "opcache.enable=1"; \
echo "opcache.memory_consumption=256"; \
echo "opcache.max_accelerated_files=20000"; \
echo "opcache.validate_timestamps=0"; \
} > /usr/local/etc/php/conf.d/opcache.ini
# Expose port 80
EXPOSE 80

407
README.md
View File

@@ -1,367 +1,154 @@
# Immorechner
Eine moderne Webanwendung zur Immobilienberechnung, entwickelt mit Symfony 7.3, PHP 8.4 und MariaDB.
Eine moderne Webanwendung zur Immobilienberechnung mit interaktivem Frontend und vollständiger REST-API.
## Schnellstart
```bash
# Repository klonen
git clone <repository-url>
cd immorechner
# Docker-Container starten
docker-compose up -d --build
# Dependencies installieren
docker-compose exec web composer install
# Datenbank initialisieren
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
# Anwendung öffnen
# Frontend: http://localhost:8080
# API-Docs: http://localhost:8080/api/docs.html
# phpMyAdmin: http://localhost:8081
```
## Features
- **REST-API**: Vollständige REST-API mit automatischer Dokumentation über API Platform
- **Web-Interface**: Benutzerfreundliche Weboberfläche mit Twig-Templates
- **User Management**: Benutzerverwaltung mit Rollenbasierter Zugriffskontrolle (USER, ADMIN, MODERATOR)
- **Docker-Setup**: Vollständig containerisierte Entwicklungsumgebung
- **Datenbank-Migrationen**: Versionskontrollierte Datenbankschema-Verwaltung mit Doctrine
- **CORS-Unterstützung**: Konfigurierbare CORS-Einstellungen für API-Zugriffe
- **phpMyAdmin**: Integriertes Datenbank-Verwaltungstool
### Frontend (Web-Interface)
- 🧮 **Interaktiver Immobilienrechner** mit Live-Berechnungen
- 🔗 **URL-Sharing** für anonyme Nutzer (keine Registrierung nötig)
- 👤 **Benutzer-Registrierung & Login**
- 💾 **Immobilien speichern** (nur für registrierte Nutzer)
- 📊 **Automatische Berechnungen**: Grunderwerbsteuer, Gesamtkosten, Abschreibung, etc.
- 📱 **Responsive Design** (Mobile-optimiert)
## Technologie-Stack
### Backend (REST-API)
- 🔌 **Vollständige REST-API** mit API Platform 4.2
- 🔐 **Dual-Authentifizierung**: Session-basiert (Frontend) & API-Key (API)
- 🔑 **Rollenbasierte Zugriffskontrolle**: USER, ADMIN, MODERATOR, TECHNICAL
- 📖 **Swagger/OpenAPI** Dokumentation
- 🏢 **Mandantenfähigkeit**: Jeder User sieht nur seine eigenen Immobilien
- 🌍 **Öffentliche Ressourcen**: Bundesländer und Heizungstypen ohne Authentifizierung
- **Backend**: PHP 8.4
- **Framework**: Symfony 7.3
- **Datenbank**: MariaDB (Latest)
### Technologie
- **Backend**: PHP 8.4, Symfony 7.3
- **Frontend**: Twig, jQuery, separates CSS/JS
- **Datenbank**: MariaDB
- **Container**: Docker & Docker Compose
- **ORM**: Doctrine 3.0
- **API**: API Platform 4.2
- **Template Engine**: Twig 3.22
- **Webserver**: Apache 2.4 mit mod_rewrite
- **Container**: Docker & Docker Compose
- **Datenbank-Tool**: phpMyAdmin
## Voraussetzungen
## Dokumentation
- Docker Desktop (Windows/Mac) oder Docker Engine + Docker Compose (Linux)
- Git
📚 **Detaillierte Dokumentation:**
## Installation
### 1. Repository klonen
```bash
git clone <repository-url>
cd immorechner
```
### 2. Docker-Container starten
```bash
docker-compose up -d --build
```
Dieser Befehl:
- Baut das PHP 8.4 Image mit allen benötigten Extensions
- Startet MariaDB
- Startet phpMyAdmin
- Startet den Apache-Webserver
### 3. Dependencies installieren
```bash
docker-compose exec web composer install
```
### 4. Datenbank-Schema erstellen
```bash
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
```
### 5. Cache leeren
```bash
docker-compose exec web php bin/console cache:clear
```
## Verwendung
### Anwendung aufrufen
- **Web-Interface**: http://localhost:8080
- **API-Dokumentation**: http://localhost:8080/api
- **phpMyAdmin**: http://localhost:8081
- Server: `db`
- Benutzer: `root`
- Passwort: `root`
### REST-API Endpoints
#### User-API
| Methode | Endpoint | Beschreibung |
|---------|----------|--------------|
| GET | `/api/users` | Alle User abrufen |
| GET | `/api/users/{id}` | Einzelnen User abrufen |
| POST | `/api/users` | Neuen User erstellen |
| PUT | `/api/users/{id}` | User aktualisieren |
| DELETE | `/api/users/{id}` | User löschen |
#### Beispiel: User erstellen
```bash
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "Max Mustermann",
"email": "max@example.com",
"role": "user"
}'
```
#### Beispiel: Alle User abrufen
```bash
curl http://localhost:8080/api/users
```
## Datenbank-Schema
### User-Tabelle
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INT | Primärschlüssel (Auto-Increment) |
| name | VARCHAR(255) | Benutzername |
| email | VARCHAR(255) | E-Mail-Adresse (Unique) |
| role | ENUM | Benutzerrolle (user, admin, moderator) |
| created_at | DATETIME | Erstellungsdatum |
## Entwicklung
### Neue Entity erstellen
```bash
docker-compose exec web php bin/console make:entity
```
### Migration erstellen
```bash
docker-compose exec web php bin/console doctrine:migrations:diff
```
### Migration ausführen
```bash
docker-compose exec web php bin/console doctrine:migrations:migrate
```
### Controller erstellen
```bash
docker-compose exec web php bin/console make:controller
```
### Cache leeren
```bash
docker-compose exec web php bin/console cache:clear
```
### Symfony Console aufrufen
```bash
docker-compose exec web php bin/console
```
## Docker-Befehle
### Container starten
```bash
docker-compose up -d
```
### Container stoppen
```bash
docker-compose down
```
### Container neu bauen
```bash
docker-compose up -d --build
```
### Container-Logs anzeigen
```bash
# Alle Container
docker-compose logs -f
# Nur Web-Container
docker-compose logs -f web
# Nur Datenbank-Container
docker-compose logs -f db
```
### In Container einloggen
```bash
# Web-Container (PHP/Apache)
docker-compose exec web bash
# Datenbank-Container
docker-compose exec db bash
```
### Composer-Befehle ausführen
```bash
docker-compose exec web composer require <paket-name>
docker-compose exec web composer update
docker-compose exec web composer dump-autoload
```
- **[Installation & Setup](docs/installation.md)** - Schritt-für-Schritt Installationsanleitung
- **[Features & Funktionalität](docs/features.md)** - Übersicht aller Funktionen
- **[Technische Dokumentation](docs/technical.md)** - Architektur, Datenbank-Schema, Konfiguration
- **[API-Dokumentation](docs/api.md)** - REST-API Endpunkte, Authentifizierung, Beispiele
- **[Entwicklung](docs/development.md)** - Testing, Code Quality, Entwickler-Workflow
- **[Docker](docs/docker.md)** - Docker-Befehle und Container-Management
- **[Fehlerbehebung](docs/troubleshooting.md)** - Lösungen für häufige Probleme
## Projekt-Struktur
```
immorechner/
├── bin/ # Symfony Console und andere Binaries
├── config/ # Symfony Konfigurationsdateien
├── docker/ # Docker-Konfigurationsdateien
│ └── apache/ # Apache VirtualHost Konfiguration
├── migrations/ # Doctrine Datenbank-Migrationen
├── public/ # Web-Root (index.php, Assets)
├── config/ # Symfony Konfiguration
├── docs/ # Dokumentation
├── migrations/ # Datenbank-Migrationen
├── public/ # Web-Root, CSS, JS, Assets
├── src/ # PHP-Quellcode
│ ├── Controller/ # Controller
│ ├── Controller/ # Controller (Frontend & API)
│ ├── Entity/ # Doctrine Entities
│ ├── Enum/ # PHP Enums
│ ├── Repository/ # Doctrine Repositories
│ └── Kernel.php # Symfony Kernel
│ └── Security/ # Authentifizierung
├── templates/ # Twig-Templates
│ ├── base.html.twig # Basis-Template
│ └── home/ # Homepage-Templates
├── var/ # Cache, Logs
├── vendor/ # Composer Dependencies
├── .env # Umgebungsvariablen
├── .env.example # Beispiel-Umgebungsvariablen
├── .gitignore # Git Ignore-Datei
├── composer.json # Composer-Konfiguration
├── docker-compose.yml # Docker Compose-Konfiguration
── Dockerfile # Docker-Image für PHP/Apache
└── README.md # Diese Datei
── Dockerfile # Docker-Image für PHP/Apache
```
## Umgebungsvariablen
## Verwendung
Die Anwendung verwendet folgende Umgebungsvariablen (definiert in `.env`):
### Frontend
```env
# Symfony
APP_ENV=dev
APP_SECRET=<generierter-secret-key>
**Startseite (Rechner):** http://localhost:8080
- Immobiliendaten eingeben
- Live-Berechnungen ansehen
- Link zum Teilen generieren
- Bei Anmeldung: Immobilie speichern
# Datenbank
DATABASE_URL="mysql://immorechner_user:immorechner_pass@db:3306/immorechner?serverVersion=mariadb-11.7.1&charset=utf8mb4"
**Registrierung:** http://localhost:8080/register
# CORS
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
**Login:** http://localhost:8080/login
# Routing
DEFAULT_URI=http://localhost
```
**Meine Immobilien:** http://localhost:8080/meine-immobilien (nach Login)
## Konfiguration
### API
### CORS anpassen
**API-Dokumentation (Swagger UI):** http://localhost:8080/api/docs.html
CORS-Einstellungen können in `config/packages/nelmio_cors.yaml` angepasst werden.
**API Entrypoint:** http://localhost:8080/api
### Datenbank-Verbindung ändern
**Öffentliche Endpunkte (ohne Authentifizierung):**
- `GET /api/bundeslands` - Alle Bundesländer
- `GET /api/heizungstyps` - Alle Heizungstypen
Datenbank-Verbindung in `.env` anpassen:
**Geschützte Endpunkte (API-Key erforderlich):**
- `/api/immobilies` - Immobilien-Management
- `/api/users` - Benutzer-Management
```env
DATABASE_URL="mysql://user:password@host:port/database?serverVersion=mariadb-11.7.1"
```
### Apache-Konfiguration
Apache-VirtualHost-Konfiguration befindet sich in `docker/apache/000-default.conf`.
## Fehlerbehebung
### Container starten nicht
## Schnelle Befehle
```bash
# Container-Logs prüfen
docker-compose logs
# Container starten
docker-compose up -d
# Container-Status prüfen
docker ps -a
# Container neu bauen
# Container stoppen
docker-compose down
docker-compose up -d --build
```
### "Class not found" Fehler
# Logs anzeigen
docker-compose logs -f
```bash
# Autoloader neu generieren
docker-compose exec web composer dump-autoload
# In Container einloggen
docker-compose exec web bash
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Container neu starten
docker-compose restart web
```
### Datenbank-Verbindungsfehler
```bash
# Prüfen ob DB-Container läuft
docker ps | grep db
# DB-Logs prüfen
docker-compose logs db
# In DB-Container einloggen und testen
docker-compose exec db mysql -u root -proot
```
### Port bereits belegt
Wenn Port 8080, 8081 oder 3306 bereits belegt ist, können die Ports in `docker-compose.yml` angepasst werden:
```yaml
services:
web:
ports:
- "8090:80" # Statt 8080:80
```
## API-Dokumentation
Die vollständige API-Dokumentation ist verfügbar unter:
- **Swagger UI**: http://localhost:8080/api
Die API unterstützt mehrere Formate:
- JSON-LD (Standard)
- JSON
- HTML
Beispiel für JSON-Format:
```bash
curl -H "Accept: application/json" http://localhost:8080/api/users
# Tests ausführen
docker-compose exec web php bin/phpunit
```
## Sicherheitshinweise
**Wichtig für Produktion:**
**⚠️ Wichtig für Produktion:**
1. Ändern Sie alle Standardpasswörter in `docker-compose.yml` und `.env`
2. Generieren Sie einen neuen `APP_SECRET` für `.env`
3. Setzen Sie `APP_ENV=prod` in der Produktion
4. Konfigurieren Sie CORS entsprechend Ihrer Domain
5. Aktivieren Sie HTTPS
6. Verwenden Sie sichere Datenbank-Credentials
7. Entfernen Sie phpMyAdmin aus der Produktion
2. Generieren Sie einen neuen `APP_SECRET`
3. Setzen Sie `APP_ENV=prod`
4. Aktivieren Sie HTTPS
5. Entfernen Sie phpMyAdmin
6. Konfigurieren Sie CORS für Ihre Domain
Details siehe [Technische Dokumentation](docs/technical.md#sicherheit).
## Lizenz
[Lizenz hier einfügen]
## Kontakt
## Support
[Kontaktinformationen hier einfügen]
Bei Problemen siehe [Fehlerbehebung](docs/troubleshooting.md) oder öffnen Sie ein Issue.

4
bin/phpunit Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env php
<?php
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';

View File

@@ -45,5 +45,11 @@
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
}
},
"require-dev": {
"phpunit/phpunit": "^12.4",
"symfony/browser-kit": "^7.3",
"symfony/css-selector": "^7.3",
"friendsofphp/php-cs-fixer": "^3.89"
}
}

3281
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,40 @@
api_platform:
title: Hello API Platform
title: 'Immorechner API'
description: |
# Immobilien-Verwaltungs-API
Diese REST-API bietet umfassende Funktionen zur Verwaltung von Immobilien und Benutzern.
## Hauptfunktionen
- Verwaltung von Immobilien (CRUD-Operationen)
- Benutzerverwaltung mit Rollensystem
- Beziehungsverwaltung zwischen Usern und Immobilien
- Automatische Berechnungen (Preis pro m², Gesamtfläche)
## Datenformate
- JSON-LD (Hydra)
- JSON
- HAL+JSON
## Authentifizierung
Derzeit keine Authentifizierung erforderlich (Entwicklungsversion)
version: 1.0.0
mapping:
paths: ['%kernel.project_dir%/src/Entity']
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
formats:
jsonld: ['application/ld+json']
json: ['application/json']
docs_formats:
jsonld: ['application/ld+json']
jsonopenapi: ['application/vnd.openapi+json']
html: ['text/html']
swagger:
versions: [3]
api_keys:
apiKey:
name: Authorization
type: header

View File

@@ -2,28 +2,60 @@ security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
# API-Dokumentation (öffentlich zugänglich)
api_docs:
pattern: ^/api/docs
stateless: true
security: false
# Öffentliche Endpunkte (Bundesländer, Heizungstypen)
public_api:
pattern: ^/api/(bundeslands|heizungstyps)
stateless: true
security: false
# API mit API-Key-Authentifizierung
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiKeyAuthenticator
main:
lazy: true
provider: users_in_memory
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: app_home
logout:
path: app_logout
target: app_home
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
# API-Dokumentation ist öffentlich
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
# Öffentlicher Zugriff auf Bundesländer und Heizungstypen (GET)
- { path: ^/api/(bundeslands|heizungstyps), methods: [GET], roles: PUBLIC_ACCESS }
# Admin und Technical User Zugriff für Änderungen an Bundesländern und Heizungstypen
- { path: ^/api/(bundeslands|heizungstyps), methods: [POST, PUT, DELETE, PATCH], roles: [ROLE_ADMIN, ROLE_TECHNICAL] }
# Alle anderen API-Endpunkte erfordern Authentifizierung
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test:
security:

View File

@@ -15,6 +15,20 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Repository/'
- '../src/Kernel.php'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller/'
tags: ['controller.service_arguments']
# Explicitly register repositories
App\Repository\:
resource: '../src/Repository/'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

185
docs/api.md Normal file
View File

@@ -0,0 +1,185 @@
# API-Dokumentation
[← Zurück zur Hauptseite](../README.md)
## Übersicht
Die Immorechner-API ist eine vollständige REST-API basierend auf API Platform 4.2 mit OpenAPI/Swagger-Dokumentation.
**Basis-URL:** `http://localhost:8080/api`
**Formate:**
- `application/ld+json` (Standard, JSON-LD mit Hydra)
- `application/json` (Einfaches JSON)
## Interaktive Dokumentation
**Swagger UI:** http://localhost:8080/api/docs.html
Hier können Sie alle Endpunkte direkt im Browser testen.
## Authentifizierung
### API-Key (für API-Zugriff)
Fügen Sie den API-Key im Header hinzu:
```bash
curl -H "X-API-KEY: ihr-api-key-hier" http://localhost:8080/api/immobilies
```
### API-Key erhalten
```bash
# User erstellen
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "Max Mustermann",
"email": "max@example.com",
"role": "user"
}'
# Response enthält apiKey
{
"apiKey": "a1b2c3d4e5f6...", # Diesen Key verwenden
...
}
```
## Endpunkte
### Bundesländer (Öffentlich)
| Methode | Endpoint | Beschreibung | Auth |
|---------|----------|--------------|------|
| GET | `/api/bundeslands` | Alle Bundesländer | Nein |
| GET | `/api/bundeslands/{id}` | Einzelnes Bundesland | Nein |
| POST | `/api/bundeslands` | Neues Bundesland erstellen | ADMIN/TECHNICAL |
| PUT | `/api/bundeslands/{id}` | Bundesland aktualisieren | ADMIN/TECHNICAL |
| DELETE | `/api/bundeslands/{id}` | Bundesland löschen | ADMIN/TECHNICAL |
**Beispiel:**
```bash
curl http://localhost:8080/api/bundeslands
```
### Heizungstypen (Öffentlich)
| Methode | Endpoint | Beschreibung | Auth |
|---------|----------|--------------|------|
| GET | `/api/heizungstyps` | Alle Heizungstypen | Nein |
| GET | `/api/heizungstyps/{id}` | Einzelner Heizungstyp | Nein |
| POST | `/api/heizungstyps` | Neuen Typ erstellen | ADMIN/TECHNICAL |
| PUT | `/api/heizungstyps/{id}` | Typ aktualisieren | ADMIN/TECHNICAL |
| DELETE | `/api/heizungstyps/{id}` | Typ löschen | ADMIN/TECHNICAL |
### Immobilien (Geschützt)
| Methode | Endpoint | Beschreibung | Auth |
|---------|----------|--------------|------|
| GET | `/api/immobilies` | Eigene Immobilien | API-Key |
| GET | `/api/immobilies/{id}` | Einzelne Immobilie | API-Key |
| POST | `/api/immobilies` | Neue Immobilie | API-Key |
| PATCH | `/api/immobilies/{id}` | Immobilie aktualisieren | API-Key |
| DELETE | `/api/immobilies/{id}` | Immobilie löschen | API-Key |
**Beispiel - Immobilie erstellen:**
```bash
curl -X POST http://localhost:8080/api/immobilies \
-H "Content-Type: application/json" \
-H "X-API-KEY: your-api-key" \
-d '{
"verwalter": "/api/users/1",
"adresse": "Hauptstraße 123, 12345 Musterstadt",
"wohnflaeche": 85,
"nutzflaeche": 15,
"zimmer": 3,
"typ": "wohnung",
"kaufpreis": 300000,
"bundesland": "/api/bundeslands/1",
"heizungstyp": "/api/heizungstyps/1"
}'
```
### User (Geschützt)
| Methode | Endpoint | Beschreibung | Auth |
|---------|----------|--------------|------|
| GET | `/api/users` | Alle User | API-Key |
| GET | `/api/users/{id}` | Einzelner User | API-Key |
| POST | `/api/users` | Neuen User erstellen | Nein* |
| PUT | `/api/users/{id}` | User aktualisieren | API-Key |
| DELETE | `/api/users/{id}` | User löschen | API-Key |
*POST ohne Auth zum Registrieren neuer User
## Rollen & Berechtigungen
| Rolle | Rechte |
|-------|--------|
| `user` | Eigene Immobilien verwalten |
| `admin` | Alle Ressourcen, alle Immobilien |
| `technical` | Bundesländer & Heizungstypen verwalten |
| `moderator` | Erweiterte Rechte |
## Enums
### ImmobilienTyp
- `wohnung` - Wohnung
- `haus` - Haus
- `grundstueck` - Grundstück
- `gewerbe` - Gewerbe
- `buero` - Büro
### UserRole
- `user` - Benutzer
- `admin` - Administrator
- `moderator` - Moderator
- `technical` - Technischer User
## Pagination
Alle Collection-Endpunkte unterstützen Pagination:
```bash
# Erste Seite (Standard: 30 Items)
curl http://localhost:8080/api/immobilies
# Zweite Seite
curl http://localhost:8080/api/immobilies?page=2
# Custom Page Size
curl "http://localhost:8080/api/immobilies?itemsPerPage=10"
```
## Filter
```bash
# Nach Typ filtern
curl "http://localhost:8080/api/immobilies?typ=wohnung"
# Nach Bundesland filtern
curl "http://localhost:8080/api/immobilies?bundesland=/api/bundeslands/1"
```
## Fehler-Codes
| Status Code | Bedeutung |
|-------------|-----------|
| 200 | OK - Erfolgreiche Anfrage |
| 201 | Created - Ressource erstellt |
| 204 | No Content - Erfolgreich gelöscht |
| 400 | Bad Request - Ungültige Daten |
| 401 | Unauthorized - Authentifizierung fehlgeschlagen |
| 403 | Forbidden - Keine Berechtigung |
| 404 | Not Found - Ressource nicht gefunden |
| 500 | Internal Server Error - Server-Fehler |
---
**Weitere Informationen:**
- [Features](features.md) - Funktionsübersicht
- [Technical](technical.md) - Datenbank-Schema & Architektur
[← Zurück zur Hauptseite](../README.md)

302
docs/development.md Normal file
View File

@@ -0,0 +1,302 @@
# Entwicklung
[← Zurück zur Hauptseite](../README.md)
## Entwickler-Workflow
### Entwicklungsumgebung einrichten
Siehe [Installation](installation.md)
### Code-Änderungen testen
```bash
# Container starten
docker-compose up -d
# In Web-Container einloggen
docker-compose exec web bash
# Cache leeren nach Änderungen
php bin/console cache:clear
```
## Testing
### PHPUnit Tests
Das Projekt verwendet PHPUnit 12.4 für Tests.
#### Alle Tests ausführen
```bash
docker-compose exec web php bin/phpunit
```
#### Spezifische Tests
```bash
# Nur Entity-Tests
docker-compose exec web php bin/phpunit tests/Entity
# Nur API-Tests
docker-compose exec web php bin/phpunit tests/Api
# Einzelne Testklasse
docker-compose exec web php bin/phpunit tests/Entity/UserTest.php
```
#### Mit Details
```bash
docker-compose exec web php bin/phpunit --verbose
```
#### Code Coverage (optional)
```bash
docker-compose exec web php bin/phpunit --coverage-text
```
### Test-Struktur
```
tests/
├── Entity/
│ ├── UserTest.php # User-Entity Tests
│ ├── ImmobilieTest.php # Immobilie-Entity Tests
│ ├── BundeslandTest.php # Bundesland-Entity Tests
│ └── HeizungstypTest.php # Heizungstyp-Entity Tests
└── Api/
├── BundeslandApiTest.php # Bundesländer-API Tests
├── HeizungstypApiTest.php # Heizungstypen-API Tests
└── ApiDocumentationTest.php # API-Docs Tests
```
## Code Quality
### PHP-CS-Fixer
Für konsistenten Code-Style nach Symfony Standards.
#### Code-Style prüfen (Dry-Run)
```bash
docker-compose exec web vendor/bin/php-cs-fixer fix --dry-run --diff
```
#### Code automatisch formatieren
```bash
docker-compose exec web vendor/bin/php-cs-fixer fix
```
#### Bestimmte Verzeichnisse
```bash
# Nur src/
docker-compose exec web vendor/bin/php-cs-fixer fix src
# Nur tests/
docker-compose exec web vendor/bin/php-cs-fixer fix tests
```
### Code-Style Regeln
Konfiguration in `.php-cs-fixer.dist.php`:
- Symfony Coding Standards
- PSR-12 kompatibel
- Short Array Syntax
- Sortierte Imports
- Trailing Commas in Arrays
## Datenbank
### Neue Entity erstellen
```bash
docker-compose exec web php bin/console make:entity
```
### Migration erstellen
Nach Änderungen an Entities:
```bash
docker-compose exec web php bin/console doctrine:migrations:diff
```
### Migration ausführen
```bash
docker-compose exec web php bin/console doctrine:migrations:migrate
```
### Migration rückgängig machen
```bash
# Letzte Migration
docker-compose exec web php bin/console doctrine:migrations:migrate prev
# Zu spezifischer Version
docker-compose exec web php bin/console doctrine:migrations:migrate DoctrineMigrations\\Version20251109100000
```
### Migration-Status
```bash
docker-compose exec web php bin/console doctrine:migrations:status
```
### Datenbank-Schema validieren
```bash
docker-compose exec web php bin/console doctrine:schema:validate
```
## Controller & Routes
### Controller erstellen
```bash
docker-compose exec web php bin/console make:controller
```
### Routes anzeigen
```bash
# Alle Routes
docker-compose exec web php bin/console debug:router
# Spezifische Route
docker-compose exec web php bin/console debug:router app_home
```
## Symfony Console
### Cache
```bash
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Cache warmup
docker-compose exec web php bin/console cache:warmup
```
### Services debuggen
```bash
# Alle Services
docker-compose exec web php bin/console debug:container
# Spezifischer Service
docker-compose exec web php bin/console debug:container UserRepository
```
### Ereignisse anzeigen
```bash
docker-compose exec web php bin/console debug:event-dispatcher
```
### Konfiguration anzeigen
```bash
docker-compose exec web php bin/console debug:config framework
```
## Git Workflow
### Feature-Branch erstellen
```bash
git checkout -b feature/neue-funktion
```
### Änderungen committen
```bash
git add .
git commit -m "Feature: Beschreibung der Änderung"
```
### Pull Request erstellen
1. Push auf Remote Branch
2. Pull Request in GitHub/GitLab erstellen
3. Code Review abwarten
4. Nach Approval mergen
## Best Practices
### Code-Organisation
1. **Controller:** Dünn halten, Logik in Services auslagern
2. **Entities:** Nur Datenmodell, keine Business-Logik
3. **Services:** Wiederverwendbare Business-Logik
4. **Repositories:** Nur Datenbank-Queries
### Namenskonventionen
- **Controller:** `XyzController.php`
- **Entity:** `Xyz.php`
- **Repository:** `XyzRepository.php`
- **Service:** `XyzService.php`
- **Test:** `XyzTest.php`
### Security
- Nie Passwörter im Klartext speichern
- Immer UserPasswordHasher verwenden
- CSRF-Tokens bei allen Forms
- Input-Validierung auf Server-Seite
- Output-Escaping (Twig macht automatisch)
### Performance
- Doctrine Query Cache nutzen
- Eager Loading für Relationen
- Opcache in Produktion aktivieren
- Assets kompilieren für Produktion
## Deployment
### Vorbereitung
```bash
# .env.local für Produktion erstellen
cp .env .env.local
# Produktions-Werte setzen
APP_ENV=prod
APP_SECRET=<neues-secret-generieren>
```
### Build für Produktion
```bash
# Composer Dependencies ohne Dev
composer install --no-dev --optimize-autoloader
# Cache warmup
php bin/console cache:warmup --env=prod
# Assets installieren
php bin/console assets:install public --symlink --relative --env=prod
```
### Database Migration
```bash
php bin/console doctrine:migrations:migrate --no-interaction --env=prod
```
---
**Siehe auch:**
- [Technical](technical.md) - Architektur & Konfiguration
- [Docker](docker.md) - Container-Management
- [Troubleshooting](troubleshooting.md) - Fehler beheben
[← Zurück zur Hauptseite](../README.md)

337
docs/docker.md Normal file
View File

@@ -0,0 +1,337 @@
# Docker
[← Zurück zur Hauptseite](../README.md)
## Container-Übersicht
Das Projekt verwendet 3 Docker-Container:
| Container | Image | Port | Beschreibung |
|-----------|-------|------|--------------|
| `immorechner_web` | Custom (PHP 8.4 + Apache) | 8080 | Web-Server & PHP |
| `immorechner_db` | mariadb:latest | 3306 | Datenbank |
| `immorechner_phpmyadmin` | phpmyadmin:latest | 8081 | DB-Verwaltung |
## Container-Management
### Starten
```bash
# Alle Container starten
docker-compose up -d
# Mit Build (nach Dockerfile-Änderungen)
docker-compose up -d --build
# Im Vordergrund (mit Logs)
docker-compose up
```
### Stoppen
```bash
# Container stoppen
docker-compose stop
# Container stoppen und entfernen
docker-compose down
# Container stoppen, entfernen + Volumes löschen
docker-compose down -v
```
### Neustarten
```bash
# Alle Container
docker-compose restart
# Einzelner Container
docker-compose restart web
docker-compose restart db
```
### Status prüfen
```bash
# Container-Status
docker-compose ps
# Alle Container (auch gestoppte)
docker ps -a
# Resource-Usage
docker stats
```
## Logs
### Container-Logs anzeigen
```bash
# Alle Container (Live)
docker-compose logs -f
# Nur Web-Container
docker-compose logs -f web
# Nur Datenbank
docker-compose logs -f db
# Nur phpMyAdmin
docker-compose logs -f phpmyadmin
# Letzte 100 Zeilen
docker-compose logs --tail=100 web
```
### Apache-Logs
```bash
# Access Log
docker-compose exec web tail -f /var/log/apache2/access.log
# Error Log
docker-compose exec web tail -f /var/log/apache2/error.log
```
### Symfony-Logs
```bash
# Dev Log
docker-compose exec web tail -f /var/www/html/var/log/dev.log
# Prod Log
docker-compose exec web tail -f /var/www/html/var/log/prod.log
```
## Container-Zugriff
### In Container einloggen
```bash
# Web-Container (Bash)
docker-compose exec web bash
# Datenbank-Container
docker-compose exec db bash
# phpMyAdmin-Container
docker-compose exec phpmyadmin bash
```
### Als Root einloggen
```bash
docker-compose exec -u root web bash
```
## Befehle im Container ausführen
### PHP/Symfony
```bash
# Symfony Console
docker-compose exec web php bin/console
# PHP-Version prüfen
docker-compose exec web php -v
# PHP-Module anzeigen
docker-compose exec web php -m
# PHP-Konfiguration
docker-compose exec web php -i
```
### Composer
```bash
# Install
docker-compose exec web composer install
# Update
docker-compose exec web composer update
# Paket hinzufügen
docker-compose exec web composer require vendor/package
# Paket entfernen
docker-compose exec web composer remove vendor/package
# Autoloader neu generieren
docker-compose exec web composer dump-autoload
```
### Datenbank
```bash
# MariaDB-Client öffnen
docker-compose exec db mariadb -u root -proot immorechner
# SQL-Datei importieren
docker-compose exec -T db mariadb -u root -proot immorechner < backup.sql
# Datenbank exportieren
docker-compose exec db mariadb-dump -u root -proot immorechner > backup.sql
# Tabellen anzeigen
docker-compose exec db mariadb -u root -proot -e "SHOW TABLES" immorechner
```
## Volumes
### Volumes anzeigen
```bash
docker volume ls
```
### Volume-Details
```bash
docker volume inspect immorechner_db_data
```
### Volume löschen
```bash
# ACHTUNG: Löscht alle Daten!
docker volume rm immorechner_db_data
```
### Backup erstellen
```bash
# Datenbank-Backup
docker-compose exec db mariadb-dump -u root -proot immorechner > backup_$(date +%Y%m%d).sql
# Volume-Backup
docker run --rm -v immorechner_db_data:/data -v $(pwd):/backup alpine tar czf /backup/db_backup.tar.gz /data
```
## Images
### Images anzeigen
```bash
docker images
```
### Image neu bauen
```bash
docker-compose build web
```
### Image mit Tag
```bash
docker build -t immorechner:latest .
```
### Image löschen
```bash
docker rmi immorechner-web
```
## Netzwerk
### Netzwerke anzeigen
```bash
docker network ls
```
### Netzwerk-Details
```bash
docker network inspect immorechner_immorechner_network
```
### Container im Netzwerk
```bash
docker network inspect immorechner_immorechner_network | grep Name
```
## Cleanup
### Gestoppte Container entfernen
```bash
docker container prune
```
### Ungenutzte Images entfernen
```bash
docker image prune
```
### Ungenutzte Volumes entfernen
```bash
docker volume prune
```
### Alles aufräumen
```bash
# ACHTUNG: Löscht alles Ungenutzte!
docker system prune -a --volumes
```
## Troubleshooting
### Container startet nicht
```bash
# Logs prüfen
docker-compose logs web
# Container im Debug-Modus starten
docker-compose up web
```
### Port bereits belegt
```bash
# Ports prüfen
netstat -ano | findstr :8080 # Windows
lsof -i :8080 # Mac/Linux
# In docker-compose.yml ändern:
ports:
- "8090:80" # Statt 8080:80
```
### Berechtigungsprobleme
```bash
# Berechtigungen setzen
docker-compose exec -u root web chown -R www-data:www-data /var/www/html
# Schreibrechte für var/
docker-compose exec -u root web chmod -R 777 /var/www/html/var
```
### Container-Reset
```bash
# Komplett neu starten
docker-compose down -v
docker-compose up -d --build
docker-compose exec web composer install
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
```
---
**Siehe auch:**
- [Installation](installation.md) - Setup-Anleitung
- [Development](development.md) - Entwickler-Workflow
- [Troubleshooting](troubleshooting.md) - Fehler beheben
[← Zurück zur Hauptseite](../README.md)

275
docs/features.md Normal file
View File

@@ -0,0 +1,275 @@
# Features & Funktionalität
[← Zurück zur Hauptseite](../README.md)
## Übersicht
Der Immorechner bietet zwei Hauptkomponenten:
1. **Web-Frontend** - Interaktive Benutzeroberfläche für Endanwender
2. **REST-API** - Programmgesteuerter Zugriff für Integrationen
---
## Frontend-Features
### 1. Immobilienrechner (Startseite)
**URL:** http://localhost:8080/
Der Immobilienrechner ermöglicht die schnelle Berechnung wichtiger Kennzahlen einer Immobilie.
#### Eingabefelder
| Feld | Typ | Beschreibung | Pflicht |
|------|-----|--------------|---------|
| Adresse | Text | Vollständige Adresse der Immobilie | Nein |
| Kaufpreis | Zahl | Kaufpreis in Euro | Empfohlen |
| Wohnfläche | Zahl | Wohnfläche in m² | Ja |
| Nutzfläche | Zahl | Nutzfläche in m² | Nein |
| Zimmer | Zahl | Anzahl der Zimmer | Empfohlen |
| Baujahr | Zahl | Baujahr der Immobilie | Nein |
| Etage | Zahl | Stockwerk | Nein |
| Immobilientyp | Auswahl | Wohnung, Haus oder Gewerbe | Empfohlen |
| Bundesland | Auswahl | Deutsches Bundesland | Empfohlen |
| Heizungstyp | Auswahl | Ölheizung, Gasheizung, Wärmepumpe, Pelletheizung | Nein |
| Abschreibungszeit | Zahl | Jahre für Abschreibung (Standard: 50) | Nein |
| Garage | Checkbox | Garage vorhanden? | Nein |
#### Live-Berechnungen
Alle Werte werden **in Echtzeit** berechnet (ohne Seitenreload):
1. **Gesamtfläche**
- Formel: Wohnfläche + Nutzfläche
- Einheit: m²
2. **Preis pro m² Wohnfläche**
- Formel: Kaufpreis / Wohnfläche
- Einheit: €/m²
3. **Grunderwerbsteuer**
- Formel: Kaufpreis × Grunderwerbsteuersatz des Bundeslandes
- Sätze: 3,5% (Bayern) bis 6,5% (Brandenburg, NRW, Saarland, SH)
- Einheit: €
4. **Gesamtkosten**
- Formel: Kaufpreis + Grunderwerbsteuer
- Einheit: €
5. **Jährliche Abschreibung**
- Formel: Kaufpreis / Abschreibungszeit
- Standard-Abschreibungszeit: 50 Jahre
- Einheit: €/Jahr
6. **Alter der Immobilie**
- Formel: Aktuelles Jahr - Baujahr
- Einheit: Jahre
### 2. URL-Sharing für anonyme Nutzer
**Funktion:** "Link teilen" Button
- Kodiert alle Formulardaten in URL-Parameter
- Ermöglicht Teilen der Berechnung ohne Registrierung
- Link funktioniert auch nach Tagen/Wochen
- Daten werden **nicht** in der Datenbank gespeichert
- Ideal für schnelle Berechnungen und Vergleiche
**Beispiel-URL:**
```
http://localhost:8080/?adresse=Teststr+123&kaufpreis=300000&wohnflaeche=85&...
```
### 3. Benutzer-Authentifizierung
#### Registrierung
**URL:** http://localhost:8080/register
- Name (Pflicht, mind. 2 Zeichen)
- E-Mail (Pflicht, muss gültig und eindeutig sein)
- Passwort (Pflicht, mind. 6 Zeichen)
- Passwort bestätigen
**Sicherheit:**
- Passwort wird mit bcrypt gehasht
- E-Mail muss eindeutig sein
- Validierung auf Client- und Server-Seite
#### Login
**URL:** http://localhost:8080/login
- E-Mail
- Passwort
**Session-basiert:**
- Bleibt aktiv bis zum Logout
- Cookie-basierte Session
- CSRF-Schutz integriert
#### Logout
- Link im Header "Abmelden"
- Beendet Session
- Redirect zur Startseite
### 4. Immobilien speichern (nur eingeloggte Nutzer)
**Funktion:** "Speichern" Button (erscheint nur nach Login)
- Speichert alle eingegebenen Daten in der Datenbank
- Immobilie wird dem eingeloggten Nutzer zugeordnet
- Nutzer kann nur eigene Immobilien sehen
- AJAX-basiert (kein Seitenreload)
- Erfolgs-/Fehlermeldung als Alert
### 5. Meine Immobilien
**URL:** http://localhost:8080/meine-immobilien (nur nach Login)
**Funktionen:**
- Übersicht aller gespeicherten Immobilien
- Anzeige aller Details pro Immobilie
- Berechnete Werte werden angezeigt
- Sortierung nach Erstellungsdatum (neueste zuerst)
**Angezeigt werden:**
- Typ, Kaufpreis, Flächen
- Zimmer, Baujahr, Etage
- Bundesland, Heizungstyp
- Garage (Ja/Nein)
- Erstellungsdatum
---
## API-Features
Vollständige Details siehe [API-Dokumentation](api.md)
### 1. Dual-Authentifizierung
**Session-basiert (Frontend):**
- Form-Login mit E-Mail/Passwort
- Cookie-basierte Sessions
- Für Web-Interface
**API-Key-basiert (API):**
- Header: `X-API-KEY`
- Automatisch generiert bei User-Erstellung
- Für programmatischen Zugriff
### 2. Rollenbasierte Zugriffskontrolle
| Rolle | Rechte |
|-------|--------|
| **USER** | Eigene Immobilien verwalten |
| **ADMIN** | Alle Immobilien verwalten, alle User sehen |
| **MODERATOR** | Erweiterte Rechte |
| **TECHNICAL** | Bundesländer & Heizungstypen verwalten |
### 3. Mandantenfähigkeit
- Jeder User sieht nur eigene Immobilien
- Admins sehen alle Immobilien
- Automatische Filterung auf Basis des eingeloggten Users
- Keine manuelle Filterung nötig
### 4. Öffentliche Ressourcen
**Ohne Authentifizierung verfügbar:**
- `GET /api/bundeslands` - Alle Bundesländer mit Grunderwerbsteuersätzen
- `GET /api/heizungstyps` - Alle Heizungstypen
**Vorbefüllt:**
- 16 deutsche Bundesländer mit aktuellen Steuersätzen
- 4 Heizungstypen (Öl, Gas, Wärmepumpe, Pellet)
### 5. Swagger/OpenAPI-Dokumentation
**URL:** http://localhost:8080/api/docs.html
**Features:**
- Interaktive API-Dokumentation
- "Try it out" Funktion für alle Endpunkte
- Request/Response Beispiele
- Schema-Dokumentation
- Export als JSON/YAML
---
## Technische Features
### 1. Responsive Design
- Mobile-optimiert
- Grid-Layout passt sich an Bildschirmgröße an
- Touch-freundliche Buttons und Formulare
### 2. Performance
- **Live-Berechnungen** ohne Server-Anfragen
- **AJAX** für Speichern-Funktion (kein Reload)
- **Opcache** aktiviert in Produktion
- **Doctrine Query Cache**
### 3. Datenvalidierung
**Frontend:**
- HTML5-Validierung
- Required-Felder
- Type-Checking (number, email, etc.)
**Backend:**
- Symfony Validator
- Doctrine Constraints
- Custom Validation Rules
### 4. Sicherheit
- **CSRF-Protection** für alle Forms
- **Passwort-Hashing** mit bcrypt
- **SQL-Injection-Schutz** durch Doctrine ORM
- **XSS-Schutz** durch Twig Auto-Escaping
- **CORS-Konfiguration** für API
### 5. Separation of Concerns
- **CSS** in separaten Dateien (`public/css/`)
- **JavaScript** in separaten Dateien (`public/js/`)
- **Templates** mit Twig
- **Controller** für Logik
- **Entities** für Datenmodell
---
## Berechnungslogik
### Grunderwerbsteuer
Bundesland-spezifisch (Stand 2025):
| Bundesland | Steuersatz |
|------------|-----------|
| Bayern | 3,5% |
| Baden-Württemberg, Bremen, Niedersachsen, Rheinland-Pfalz, Sachsen-Anhalt, Thüringen | 5,0% |
| Hamburg, Sachsen | 5,5% |
| Berlin, Hessen, Mecklenburg-Vorpommern | 6,0% |
| Brandenburg, NRW, Saarland, Schleswig-Holstein | 6,5% |
### Kaufnebenkosten (nur API)
Bei API-Anfragen werden zusätzlich berechnet:
- **Notarkosten:** ca. 1,5% des Kaufpreises
- **Grundbuchkosten:** ca. 0,5% des Kaufpreises
- **Grunderwerbsteuer:** siehe oben
- **Gesamt:** Summe aller Nebenkosten
---
**Siehe auch:**
- [Technische Dokumentation](technical.md) - Architektur & Datenbank
- [API-Dokumentation](api.md) - REST-API Details
- [Installation](installation.md) - Setup-Anleitung
[← Zurück zur Hauptseite](../README.md)

291
docs/installation.md Normal file
View File

@@ -0,0 +1,291 @@
# Installation & Setup
[← Zurück zur Hauptseite](../README.md)
## Voraussetzungen
- **Docker Desktop** (Windows/Mac) oder **Docker Engine + Docker Compose** (Linux)
- **Git**
- Mindestens 2GB freier RAM für Docker
- Ports 8080, 8081 und 3306 verfügbar
## Installations-Schritte
### 1. Repository klonen
```bash
git clone <repository-url>
cd immorechner
```
### 2. Docker-Container starten
```bash
docker-compose up -d --build
```
Dieser Befehl:
- Baut das PHP 8.4 Image mit allen benötigten Extensions
- Startet MariaDB Container
- Startet phpMyAdmin Container
- Startet den Apache-Webserver
**Hinweis:** Der erste Build kann 5-10 Minuten dauern.
### 3. Dependencies installieren
```bash
docker-compose exec web composer install
```
### 4. Bundle-Assets installieren
```bash
docker-compose exec web php bin/console assets:install public --symlink --relative
```
Dieser Schritt ist wichtig für die Swagger UI (CSS, JS, Bilder).
### 5. Datenbank-Schema erstellen
```bash
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
```
Diese Migration erstellt:
- Users-Tabelle mit Passwort- und API-Key-Feldern
- Bundesländer-Tabelle (vorbefüllt mit allen 16 deutschen Bundesländern)
- Heizungstypen-Tabelle (vorbefüllt mit Ölheizung, Gasheizung, Wärmepumpe, Pelletheizung)
- Immobilien-Tabelle mit allen Relationen
### 6. Cache leeren
```bash
docker-compose exec web php bin/console cache:clear
```
## Überprüfung der Installation
### Anwendung testen
Öffnen Sie die folgenden URLs in Ihrem Browser:
1. **Frontend (Rechner):** http://localhost:8080
- Sie sollten den Immobilienrechner sehen
- Bundesländer-Dropdown sollte befüllt sein
- Heizungstypen-Dropdown sollte befüllt sein
2. **API-Dokumentation:** http://localhost:8080/api/docs.html
- Swagger UI sollte mit CSS/Styling laden
- Alle API-Endpunkte sollten sichtbar sein
3. **phpMyAdmin:** http://localhost:8081
- Server: `db`
- Benutzer: `root`
- Passwort: `root`
- Datenbank `immorechner` sollte existieren
### Datenbank überprüfen
```bash
# In Container einloggen
docker-compose exec db bash
# MariaDB-Client starten
mariadb -u root -proot immorechner
# Tabellen anzeigen
SHOW TABLES;
# Bundesländer überprüfen (sollte 16 Einträge haben)
SELECT COUNT(*) FROM bundeslaender;
# Heizungstypen überprüfen (sollte 4 Einträge haben)
SELECT COUNT(*) FROM heizungstypen;
# Beenden
EXIT;
```
## Erste Schritte nach der Installation
### 1. Testbenutzer anlegen
#### Via Frontend (Empfohlen)
1. Öffnen Sie http://localhost:8080/register
2. Registrieren Sie sich mit:
- Name: "Test User"
- E-Mail: "test@example.com"
- Passwort: "test123" (min. 6 Zeichen)
3. Nach der Registrierung werden Sie zum Login weitergeleitet
4. Melden Sie sich an
#### Via API
```bash
curl -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{
"name": "Test User",
"email": "test@example.com",
"role": "user"
}'
```
**Hinweis:** Die API-Response enthält den `apiKey`, den Sie für API-Anfragen benötigen.
### 2. Erste Immobilie berechnen
#### Via Frontend
1. Gehen Sie zu http://localhost:8080/
2. Füllen Sie das Formular aus (mindestens: Adresse, Kaufpreis, Wohnfläche)
3. Sehen Sie die Live-Berechnungen rechts
4. Optional: Klicken Sie auf "Link teilen" zum Teilen
5. Wenn angemeldet: Klicken Sie auf "Speichern"
#### Via API
```bash
# Ersetzen Sie YOUR_API_KEY mit dem API-Key aus Schritt 1
curl -X POST http://localhost:8080/api/immobilies \
-H "Content-Type: application/json" \
-H "X-API-KEY: YOUR_API_KEY" \
-d '{
"verwalter": "/api/users/1",
"adresse": "Teststraße 123, 12345 Teststadt",
"wohnflaeche": 85,
"nutzflaeche": 15,
"zimmer": 3,
"typ": "wohnung",
"kaufpreis": 300000,
"bundesland": "/api/bundeslands/1"
}'
```
## Umgebungsvariablen anpassen
Die Datei `.env` enthält alle wichtigen Konfigurationen:
```env
# Symfony
APP_ENV=dev # Für Produktion: prod
APP_SECRET=<generiert> # Für Produktion: neu generieren
# Datenbank
DATABASE_URL="mysql://immorechner_user:immorechner_pass@db:3306/immorechner?serverVersion=mariadb-11.7.1&charset=utf8mb4"
# CORS (nur für API relevant)
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
```
### Für Produktion
Erstellen Sie eine `.env.local` Datei (wird von Git ignoriert):
```env
APP_ENV=prod
APP_SECRET=<neue-generierte-secret-hier>
DATABASE_URL="mysql://prod_user:sichere_passwort@db-host:3306/prod_db?serverVersion=mariadb-11.7.1&charset=utf8mb4"
CORS_ALLOW_ORIGIN='^https?://(ihre-domain\.com)(:[0-9]+)?$'
```
## Ports anpassen
Falls die Standard-Ports bereits belegt sind, können Sie diese in `docker-compose.yml` ändern:
```yaml
services:
web:
ports:
- "8090:80" # Statt 8080:80
db:
ports:
- "3307:3306" # Statt 3306:3306
phpmyadmin:
ports:
- "8091:80" # Statt 8081:80
```
## Häufige Probleme bei der Installation
### Container starten nicht
```bash
# Container-Logs prüfen
docker-compose logs
# Spezifischen Container-Log prüfen
docker-compose logs web
docker-compose logs db
# Container-Status prüfen
docker ps -a
```
### "Permission Denied" Fehler
```bash
# Container neu bauen und Berechtigungen setzen
docker-compose down
docker-compose up -d --build
# Berechtigungen im Container setzen
docker-compose exec web chown -R www-data:www-data /var/www/html/var
```
### Datenbank-Verbindungsfehler
```bash
# Prüfen ob DB-Container läuft
docker ps | grep db
# DB-Logs prüfen
docker-compose logs db
# Warten bis DB bereit ist (kann 30-60 Sekunden dauern)
docker-compose exec web php bin/console doctrine:query:sql "SELECT 1"
```
### Composer-Fehler
```bash
# Composer Cache leeren
docker-compose exec web composer clear-cache
# Dependencies neu installieren
docker-compose exec web rm -rf vendor
docker-compose exec web composer install
```
## Deinstallation
### Nur Container stoppen
```bash
docker-compose down
```
### Container und Volumes löschen (Datenbank wird gelöscht!)
```bash
docker-compose down -v
```
### Alles entfernen (inkl. Images)
```bash
docker-compose down -v --rmi all
```
---
**Nächste Schritte:**
- [Features & Funktionalität](features.md) - Übersicht aller Funktionen
- [API-Dokumentation](api.md) - REST-API verwenden
- [Entwicklung](development.md) - Development-Workflow
[← Zurück zur Hauptseite](../README.md)

424
docs/technical.md Normal file
View File

@@ -0,0 +1,424 @@
# Technische Dokumentation
[← Zurück zur Hauptseite](../README.md)
## Technologie-Stack
- **Backend**: PHP 8.4
- **Framework**: Symfony 7.3
- **Datenbank**: MariaDB 11.7
- **ORM**: Doctrine 3.0
- **API**: API Platform 4.2
- **Template Engine**: Twig 3.22
- **Webserver**: Apache 2.4 mit mod_rewrite
- **Container**: Docker & Docker Compose
- **Frontend**: jQuery 3.7.1, separates CSS/JS
## Architektur
### Schichtenmodell
```
┌─────────────────────────────────────┐
│ Presentation Layer │
│ (Twig Templates, CSS, JavaScript) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Controller Layer │
│ (HomeController, AuthController, │
│ ImmobilienSaveController) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Service Layer │
│ (Security, Validation, Business │
│ Logic) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Persistence Layer │
│ (Doctrine ORM, Repositories) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Database Layer │
│ (MariaDB) │
└─────────────────────────────────────┘
```
### Komponenten
#### Frontend
- **Templates:** `templates/` (Twig)
- **CSS:** `public/css/` (calculator.css, auth.css)
- **JavaScript:** `public/js/` (calculator.js mit jQuery)
#### Backend
- **Controller:** `src/Controller/` (Request Handling)
- **Entity:** `src/Entity/` (Datenmodelle)
- **Repository:** `src/Repository/` (Datenbank-Queries)
- **Security:** `src/Security/` (Authentifizierung)
- **Enum:** `src/Enum/` (PHP Enums)
## Datenbank-Schema
### Entity-Relationship-Diagram
```
┌──────────────┐ ┌────────────────┐
│ User │ 1 * │ Immobilie │
│──────────────│◄────────│────────────────│
│ id │ │ id │
│ name │ │ verwalter_id │
│ email │ │ adresse │
│ password │ │ wohnflaeche │
│ role │ │ nutzflaeche │
│ api_key │ │ zimmer │
│ created_at │ │ kaufpreis │
└──────────────┘ │ typ │
│ ... │
└────────────────┘
┌──────────────┴──────────────┐
│ │
▼ ▼
┌───────────────┐ ┌─────────────────┐
│ Bundesland │ │ Heizungstyp │
│───────────────│ │─────────────────│
│ id │ │ id │
│ name │ │ name │
│ grunderwerbst.│ └─────────────────┘
└───────────────┘
```
### Tabellen-Details
#### User-Tabelle
| Feld | Typ | Constraints | Beschreibung |
|------|-----|-------------|--------------|
| id | INT | PK, AUTO_INCREMENT | Eindeutige ID |
| name | VARCHAR(255) | NOT NULL | Benutzername |
| email | VARCHAR(255) | NOT NULL, UNIQUE | E-Mail-Adresse |
| password | VARCHAR(255) | NULL | Bcrypt-Hash des Passworts |
| role | VARCHAR(255) | NOT NULL | Benutzerrolle (Enum) |
| api_key | VARCHAR(64) | NOT NULL, UNIQUE | SHA256 API-Key |
| created_at | DATETIME | NOT NULL | Erstellungsdatum |
**Benutzerrollen (Enum):**
- `user` - Normaler Benutzer
- `admin` - Administrator
- `moderator` - Moderator
- `technical` - Technischer User
#### Bundesland-Tabelle
| Feld | Typ | Constraints | Beschreibung |
|------|-----|-------------|--------------|
| id | INT | PK, AUTO_INCREMENT | Eindeutige ID |
| name | VARCHAR(100) | NOT NULL, UNIQUE | Name des Bundeslandes |
| grunderwerbsteuer | DECIMAL(4,2) | NOT NULL | Steuersatz in % |
**Vorbefüllt mit 16 deutschen Bundesländern**
#### Heizungstyp-Tabelle
| Feld | Typ | Constraints | Beschreibung |
|------|-----|-------------|--------------|
| id | INT | PK, AUTO_INCREMENT | Eindeutige ID |
| name | VARCHAR(100) | NOT NULL, UNIQUE | Name des Heizungstyps |
**Vorbefüllt mit:** Ölheizung, Gasheizung, Wärmepumpe, Pelletheizung
#### Immobilien-Tabelle
| Feld | Typ | Constraints | Beschreibung |
|------|-----|-------------|--------------|
| id | INT | PK, AUTO_INCREMENT | Eindeutige ID |
| verwalter_id | INT | FK → users.id, NOT NULL | Besitzer der Immobilie |
| bundesland_id | INT | FK → bundeslaender.id, NULL | Bundesland |
| heizungstyp_id | INT | FK → heizungstypen.id, NULL | Heizungstyp |
| adresse | VARCHAR(255) | NOT NULL | Vollständige Adresse |
| wohnflaeche | INT | NOT NULL | Wohnfläche in m² |
| nutzflaeche | INT | NOT NULL | Nutzfläche in m² |
| garage | BOOLEAN | NOT NULL, DEFAULT false | Garage vorhanden? |
| zimmer | INT | NOT NULL | Anzahl Zimmer |
| baujahr | INT | NULL | Baujahr (1800-2100) |
| typ | VARCHAR(255) | NOT NULL | Immobilientyp (Enum) |
| beschreibung | TEXT | NULL | Freitextbeschreibung |
| etage | INT | NULL | Stockwerk (0-10) |
| kaufpreis | INT | NULL | Kaufpreis in Euro |
| abschreibungszeit | INT | NULL | Abschreibungszeit in Jahren |
| created_at | DATETIME | NOT NULL | Erstellungsdatum |
| updated_at | DATETIME | NOT NULL | Letzte Änderung |
**Immobilientyp (Enum):**
- `wohnung` - Wohnung
- `haus` - Haus
- `grundstueck` - Grundstück
- `gewerbe` - Gewerbe
- `buero` - Büro
### Indizes
```sql
-- User
CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email);
CREATE UNIQUE INDEX UNIQ_1483A5E9C912ED9D ON users (api_key);
-- Bundesland
CREATE UNIQUE INDEX UNIQ_DF7DFAB25E237E06 ON bundeslaender (name);
-- Heizungstyp
CREATE UNIQUE INDEX UNIQ_6161C2A65E237E06 ON heizungstypen (name);
-- Immobilie
CREATE INDEX IDX_2C789D3E5F66D3 ON immobilien (verwalter_id);
CREATE INDEX IDX_2C789DC1B4DB52 ON immobilien (heizungstyp_id);
CREATE INDEX IDX_2C789DB74FDBEB ON immobilien (bundesland_id);
```
## Konfiguration
### Umgebungsvariablen (.env)
```env
# Symfony
APP_ENV=dev # Umgebung: dev | prod | test
APP_SECRET=<32-zeichen-hex> # Symfony Secret für Verschlüsselung
# Datenbank
DATABASE_URL="mysql://immorechner_user:immorechner_pass@db:3306/immorechner?serverVersion=mariadb-11.7.1&charset=utf8mb4"
# Routing
DEFAULT_URI=http://localhost
# CORS (für API)
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
```
### Services (config/services.yaml)
```yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/Repository/'
- '../src/Kernel.php'
App\Controller\:
resource: '../src/Controller/'
tags: ['controller.service_arguments']
App\Repository\:
resource: '../src/Repository/'
```
### Security (config/packages/security.yaml)
```yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
# API mit API-Key
api:
pattern: ^/api
stateless: true
custom_authenticators:
- App\Security\ApiKeyAuthenticator
# Frontend mit Form-Login
main:
lazy: true
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
default_target_path: app_home
logout:
path: app_logout
target: app_home
```
### API Platform (config/packages/api_platform.yaml)
```yaml
api_platform:
title: 'Immorechner API'
version: 1.0.0
mapping:
paths: ['%kernel.project_dir%/src/Entity']
defaults:
stateless: true
formats:
jsonld: ['application/ld+json']
json: ['application/json']
```
## Docker-Konfiguration
### docker-compose.yml
```yaml
services:
web:
build: .
container_name: immorechner_web
ports:
- "8080:80"
volumes:
- .:/var/www/html
depends_on:
- db
db:
image: mariadb:latest
container_name: immorechner_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: immorechner
MYSQL_USER: immorechner_user
MYSQL_PASSWORD: immorechner_pass
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
phpmyadmin:
image: phpmyadmin:latest
container_name: immorechner_phpmyadmin
ports:
- "8081:80"
environment:
PMA_HOST: db
PMA_USER: root
PMA_PASSWORD: root
```
### Dockerfile
```dockerfile
FROM php:8.4-apache
# Install dependencies
RUN apt-get update && apt-get install -y \
libpng-dev libonig-dev libxml2-dev libicu-dev \
libzip-dev git curl unzip
# Install PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif \
pcntl bcmath gd zip intl opcache
# Enable Apache modules
RUN a2enmod rewrite headers
# Copy Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy application
COPY . /var/www/html
# Set permissions
RUN chown -R www-data:www-data /var/www/html
```
## Performance-Optimierungen
### Opcache (Produktion)
In Produktion ist Opcache aktiviert:
```ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
```
### Doctrine Query Cache
Doctrine nutzt automatisch Query-Cache in Produktion.
### Asset Compilation
Assets werden bei Deployment kompiliert:
```bash
php bin/console assets:install public --symlink --relative
```
## Sicherheit
### CSRF-Schutz
Alle Forms verwenden CSRF-Tokens:
```twig
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
```
### Passwort-Hashing
Passwörter werden mit bcrypt gehasht:
```php
$hashedPassword = $passwordHasher->hashPassword($user, $password);
```
### API-Key-Generierung
API-Keys sind SHA256-Hashes:
```php
return hash('sha256', random_bytes(32).microtime(true));
```
### SQL-Injection-Schutz
Doctrine ORM verhindert SQL-Injection durch Prepared Statements.
### XSS-Schutz
Twig escaped automatisch alle Ausgaben.
## Migrations
Migrationen werden mit Doctrine Migrations verwaltet:
```bash
# Neue Migration erstellen
php bin/console doctrine:migrations:diff
# Migrationen ausführen
php bin/console doctrine:migrations:migrate
# Migration-Status anzeigen
php bin/console doctrine:migrations:status
```
---
**Siehe auch:**
- [Installation](installation.md) - Setup-Anleitung
- [Development](development.md) - Entwickler-Workflow
- [Docker](docker.md) - Container-Management
[← Zurück zur Hauptseite](../README.md)

432
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,432 @@
# Fehlerbehebung
[← Zurück zur Hauptseite](../README.md)
## Häufige Probleme
### Frontend-Probleme
#### Seite lädt ohne CSS/JavaScript
**Problem:** Seite wird angezeigt, aber ohne Styling oder JavaScript-Funktionalität.
**Lösung:**
```bash
# Assets installieren
docker-compose exec web php bin/console assets:install public --symlink --relative
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Browser-Cache leeren (Strg+F5)
```
#### Live-Berechnungen funktionieren nicht
**Problem:** Formular-Eingaben triggern keine Berechnungen.
**Lösung:**
```bash
# Browser-Konsole öffnen (F12)
# Nach JavaScript-Fehlern suchen
# Prüfen ob jQuery geladen wurde
# Sollte in Netzwerk-Tab sichtbar sein: jquery-3.7.1.min.js
# calculator.js prüfen
curl http://localhost:8080/js/calculator.js
```
#### Login funktioniert nicht
**Problem:** Login-Formular gibt Fehler oder leitet nicht weiter.
**Lösung:**
```bash
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Prüfen ob User existiert
docker-compose exec db mariadb -u root -proot -e "SELECT email FROM users" immorechner
# Session-Verzeichnis prüfen
docker-compose exec web ls -la /var/www/html/var/cache/dev/sessions
```
---
### API-Probleme
#### Swagger UI lädt ohne CSS
**Problem:** http://localhost:8080/api/docs.html lädt, aber ohne Formatierung.
**Lösung:**
```bash
# Bundle-Assets installieren
docker-compose exec web php bin/console assets:install public --symlink --relative
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Prüfen ob Assets existieren
docker-compose exec web ls -la /var/www/html/public/bundles/apiplatform
```
#### API gibt 401 Unauthorized
**Problem:** API-Anfrage mit API-Key schlägt fehl.
**Lösung:**
```bash
# API-Key prüfen
docker-compose exec db mariadb -u root -proot -e "SELECT id, email, api_key FROM users" immorechner
# Korrekten Header verwenden
curl -H "X-API-KEY: ihr-api-key" http://localhost:8080/api/immobilies
# Nicht "Authorization" Header!
```
#### API gibt 500 Internal Server Error
**Problem:** API-Anfrage schlägt mit Server-Fehler fehl.
**Lösung:**
```bash
# Logs prüfen
docker-compose logs web
# Symfony Logs prüfen
docker-compose exec web tail -f /var/www/html/var/log/dev.log
# Datenbank-Verbindung testen
docker-compose exec web php bin/console doctrine:query:sql "SELECT 1"
```
---
### Datenbank-Probleme
#### Connection refused
**Problem:** Kann nicht zur Datenbank verbinden.
**Lösung:**
```bash
# DB-Container läuft?
docker ps | grep db
# DB-Container-Logs
docker-compose logs db
# Warten (DB braucht ~30-60 Sekunden)
sleep 60
# DB-Verbindung testen
docker-compose exec db mariadb -u root -proot -e "SELECT 1"
# Neustart
docker-compose restart db
```
#### Migrations schlagen fehl
**Problem:** `doctrine:migrations:migrate` gibt Fehler.
**Lösung:**
```bash
# Migration-Status prüfen
docker-compose exec web php bin/console doctrine:migrations:status
# Datenbank droppen und neu erstellen
docker-compose exec web php bin/console doctrine:database:drop --force
docker-compose exec web php bin/console doctrine:database:create
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
# Oder: Container komplett neu aufsetzen
docker-compose down -v
docker-compose up -d --build
docker-compose exec web composer install
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
```
#### Tabellen fehlen
**Problem:** "Table 'immorechner.users' doesn't exist"
**Lösung:**
```bash
# Migrations ausführen
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
# Tabellen prüfen
docker-compose exec db mariadb -u root -proot -e "SHOW TABLES" immorechner
# Falls leer: Migration erneut ausführen
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
```
---
### Docker-Probleme
#### Container starten nicht
**Problem:** `docker-compose up` schlägt fehl.
**Lösung:**
```bash
# Logs prüfen
docker-compose logs
# Alte Container stoppen
docker-compose down
# Neu starten mit Build
docker-compose up -d --build
# Docker neu starten
# Windows: Docker Desktop neu starten
# Linux: sudo systemctl restart docker
```
#### Port bereits belegt
**Problem:** "Port 8080 is already allocated"
**Lösung 1 - Port freigeben:**
```bash
# Windows
netstat -ano | findstr :8080
taskkill /PID <prozess-id> /F
# Linux/Mac
lsof -i :8080
kill -9 <prozess-id>
```
**Lösung 2 - Port ändern:**
```yaml
# In docker-compose.yml
services:
web:
ports:
- "8090:80" # Statt 8080:80
```
#### Volumes löschen nicht möglich
**Problem:** "Volume is in use"
**Lösung:**
```bash
# Alle Container stoppen
docker-compose down
# Volume prüfen
docker volume ls
# Container mit Volume finden
docker ps -a --filter volume=immorechner_db_data
# Container löschen
docker rm <container-id>
# Volume löschen
docker volume rm immorechner_db_data
```
---
### Composer-Probleme
#### "Class not found" Fehler
**Problem:** PHP kann Klasse nicht finden.
**Lösung:**
```bash
# Autoloader neu generieren
docker-compose exec web composer dump-autoload
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Dependencies neu installieren
docker-compose exec web rm -rf vendor
docker-compose exec web composer install
```
#### Composer sehr langsam
**Problem:** `composer install` braucht ewig.
**Lösung:**
```bash
# Im Container ausführen (schneller als via Volume)
docker-compose exec web composer install
# Cache leeren
docker-compose exec web composer clear-cache
# Parallel Downloads aktivieren
docker-compose exec web composer config --global process-timeout 2000
docker-compose exec web composer config --global repos.packagist composer https://packagist.org
```
---
### Berechtigungs-Probleme
#### "Permission denied" in var/
**Problem:** Kann nicht in `var/cache` oder `var/log` schreiben.
**Lösung:**
```bash
# Berechtigungen setzen
docker-compose exec -u root web chown -R www-data:www-data /var/www/html/var
# Oder: 777 Rechte (nur Development!)
docker-compose exec -u root web chmod -R 777 /var/www/html/var
# Cache neu erstellen
docker-compose exec web php bin/console cache:clear
```
---
### Performance-Probleme
#### Seite lädt sehr langsam
**Problem:** Frontend/API antwortet langsam.
**Lösung:**
```bash
# Opcache Status prüfen
docker-compose exec web php -i | grep opcache
# Cache warmup
docker-compose exec web php bin/console cache:warmup
# Für Produktion: APP_ENV=prod setzen in .env
# Opcache aktiviert sich automatisch
# Docker-Resources erhöhen
# Docker Desktop -> Settings -> Resources -> Memory: 4GB+
```
#### Datenbank-Queries langsam
**Problem:** API-Anfragen langsam bei vielen Datensätzen.
**Lösung:**
```bash
# Indizes prüfen
docker-compose exec web php bin/console doctrine:schema:validate
# Query-Log aktivieren (temporär)
# In config/packages/doctrine.yaml:
logging: true
profiling: true
# Slow Query Log in MariaDB aktivieren
docker-compose exec db mariadb -u root -proot -e "SET GLOBAL slow_query_log = 'ON'"
```
---
### Browser-Probleme
#### Alte Daten werden angezeigt
**Problem:** Änderungen werden nicht sichtbar.
**Lösung:**
```bash
# Browser-Cache leeren
# Chrome/Firefox: Strg+Shift+Delete
# Hard Reload
# Chrome/Firefox: Strg+F5
# Mac: Cmd+Shift+R
# Server-Cache leeren
docker-compose exec web php bin/console cache:clear
```
---
## Debug-Modus aktivieren
```bash
# .env
APP_ENV=dev
APP_DEBUG=true
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Symfony Profiler in Browser verwenden
# Unten auf der Seite erscheint Toolbar
```
## Komplett-Reset (Letzter Ausweg)
**WARNUNG:** Löscht alle Daten!
```bash
# Alles stoppen und löschen
docker-compose down -v
# Docker-Images löschen
docker rmi immorechner-web
# Neu aufbauen
docker-compose up -d --build
# Dependencies installieren
docker-compose exec web composer install
# Datenbank initialisieren
docker-compose exec web php bin/console doctrine:migrations:migrate --no-interaction
# Cache leeren
docker-compose exec web php bin/console cache:clear
```
---
## Support
Wenn das Problem weiterhin besteht:
1. **Logs sammeln:**
```bash
docker-compose logs > debug_logs.txt
```
2. **Systeminfo sammeln:**
```bash
docker version > system_info.txt
docker-compose version >> system_info.txt
php -v >> system_info.txt
```
3. **Issue erstellen** mit:
- Problembeschreibung
- Fehlermeldung
- Logs (debug_logs.txt)
- Systeminfo (system_info.txt)
- Schritte zum Reproduzieren
---
**Siehe auch:**
- [Docker](docker.md) - Container-Management
- [Development](development.md) - Entwickler-Workflow
- [Installation](installation.md) - Setup-Anleitung
[← Zurück zur Hauptseite](../README.md)

View File

@@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251108171050 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, role VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_1483A5E9E7927C74 (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE users');
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Initial migration: Creates all tables and populates reference data
*/
final class Version20251109100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Initial database schema with all tables and reference data';
}
public function up(Schema $schema): void
{
// Create users table
$this->addSql('CREATE TABLE users (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
role VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL,
api_key VARCHAR(64) NOT NULL,
UNIQUE INDEX UNIQ_1483A5E9E7927C74 (email),
UNIQUE INDEX UNIQ_1483A5E9C912ED9D (api_key),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
// Create bundeslaender table
$this->addSql('CREATE TABLE bundeslaender (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(100) NOT NULL,
grunderwerbsteuer NUMERIC(4, 2) NOT NULL,
UNIQUE INDEX UNIQ_DF7DFAB25E237E06 (name),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
// Create heizungstypen table
$this->addSql('CREATE TABLE heizungstypen (
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(100) NOT NULL,
UNIQUE INDEX UNIQ_6161C2A65E237E06 (name),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
// Create immobilien table
$this->addSql('CREATE TABLE immobilien (
id INT AUTO_INCREMENT NOT NULL,
verwalter_id INT NOT NULL,
heizungstyp_id INT DEFAULT NULL,
bundesland_id INT DEFAULT NULL,
adresse VARCHAR(255) NOT NULL,
wohnflaeche INT NOT NULL,
nutzflaeche INT NOT NULL,
garage TINYINT(1) NOT NULL,
zimmer INT NOT NULL,
baujahr INT DEFAULT NULL,
typ VARCHAR(255) NOT NULL,
beschreibung LONGTEXT DEFAULT NULL,
etage INT DEFAULT NULL,
kaufpreis INT DEFAULT NULL,
abschreibungszeit INT DEFAULT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX IDX_2C789D3E5F66D3 (verwalter_id),
INDEX IDX_2C789DC1B4DB52 (heizungstyp_id),
INDEX IDX_2C789DB74FDBEB (bundesland_id),
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB');
// Add foreign keys
$this->addSql('ALTER TABLE immobilien ADD CONSTRAINT FK_2C789D3E5F66D3 FOREIGN KEY (verwalter_id) REFERENCES users (id)');
$this->addSql('ALTER TABLE immobilien ADD CONSTRAINT FK_2C789DC1B4DB52 FOREIGN KEY (heizungstyp_id) REFERENCES heizungstypen (id)');
$this->addSql('ALTER TABLE immobilien ADD CONSTRAINT FK_2C789DB74FDBEB FOREIGN KEY (bundesland_id) REFERENCES bundeslaender (id)');
// Populate Bundesländer with Grunderwerbsteuer rates
$this->addSql("INSERT INTO bundeslaender (name, grunderwerbsteuer) VALUES
('Baden-Württemberg', 5.00),
('Bayern', 3.50),
('Berlin', 6.00),
('Brandenburg', 6.50),
('Bremen', 5.00),
('Hamburg', 5.50),
('Hessen', 6.00),
('Mecklenburg-Vorpommern', 6.00),
('Niedersachsen', 5.00),
('Nordrhein-Westfalen', 6.50),
('Rheinland-Pfalz', 5.00),
('Saarland', 6.50),
('Sachsen', 5.50),
('Sachsen-Anhalt', 5.00),
('Schleswig-Holstein', 6.50),
('Thüringen', 5.00)");
// Populate Heizungstypen
$this->addSql("INSERT INTO heizungstypen (name) VALUES
('Ölheizung'),
('Gasheizung'),
('Wärmepumpe'),
('Pelletheizung')");
}
public function down(Schema $schema): void
{
// Drop all tables
$this->addSql('ALTER TABLE immobilien DROP FOREIGN KEY FK_2C789D3E5F66D3');
$this->addSql('ALTER TABLE immobilien DROP FOREIGN KEY FK_2C789DC1B4DB52');
$this->addSql('ALTER TABLE immobilien DROP FOREIGN KEY FK_2C789DB74FDBEB');
$this->addSql('DROP TABLE immobilien');
$this->addSql('DROP TABLE bundeslaender');
$this->addSql('DROP TABLE heizungstypen');
$this->addSql('DROP TABLE users');
}
}

View File

@@ -0,0 +1,22 @@
-- Bundesländer mit Grunderwerbsteuer-Sätzen (Stand 2025)
-- Dieses Script wird nach der Migration automatisch ausgeführt
INSERT INTO bundeslaender (name, grunderwerbsteuer) VALUES
('Baden-Württemberg', 5.00),
('Bayern', 3.50),
('Berlin', 6.00),
('Brandenburg', 6.50),
('Bremen', 5.00),
('Hamburg', 5.50),
('Hessen', 6.00),
('Mecklenburg-Vorpommern', 6.00),
('Niedersachsen', 5.00),
('Nordrhein-Westfalen', 6.50),
('Rheinland-Pfalz', 5.00),
('Saarland', 6.50),
('Sachsen', 5.50),
('Sachsen-Anhalt', 5.00),
('Schleswig-Holstein', 6.50),
('Thüringen', 5.00)
ON DUPLICATE KEY UPDATE
grunderwerbsteuer = VALUES(grunderwerbsteuer);

1
openapi.json Normal file

File diff suppressed because one or more lines are too long

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

96
public/css/auth.css Normal file
View File

@@ -0,0 +1,96 @@
.auth-container {
max-width: 450px;
margin: 50px auto;
}
.auth-box {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.auth-box h2 {
text-align: center;
color: #333;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #4CAF50;
}
.auth-form .form-group {
margin-bottom: 20px;
}
.auth-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.auth-form input[type="text"],
.auth-form input[type="email"],
.auth-form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.auth-form input:focus {
outline: none;
border-color: #4CAF50;
}
.auth-form .btn-submit {
width: 100%;
padding: 14px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s;
}
.auth-form .btn-submit:hover {
background-color: #45a049;
}
.error-message {
background-color: #ffebee;
color: #c62828;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #c62828;
}
.success-message {
background-color: #e8f5e9;
color: #2e7d32;
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #2e7d32;
}
.auth-links {
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.auth-links a {
color: #4CAF50;
text-decoration: none;
}
.auth-links a:hover {
text-decoration: underline;
}

146
public/css/calculator.css Normal file
View File

@@ -0,0 +1,146 @@
.calculator-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}
.form-section, .results-section {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section h2, .results-section h2 {
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #4CAF50;
}
.checkbox-group {
display: flex;
align-items: center;
}
.checkbox-group input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.result-item {
padding: 15px;
margin-bottom: 15px;
background-color: #f8f9fa;
border-left: 4px solid #4CAF50;
border-radius: 4px;
}
.result-item h3 {
margin: 0 0 5px 0;
color: #333;
font-size: 16px;
}
.result-item .value {
font-size: 24px;
font-weight: bold;
color: #4CAF50;
}
.result-item .description {
margin-top: 5px;
font-size: 12px;
color: #666;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #4CAF50;
color: white;
}
.btn-primary:hover {
background-color: #45a049;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.share-link {
margin-top: 15px;
padding: 10px;
background-color: #e8f5e9;
border-radius: 4px;
display: none;
}
.share-link input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 5px;
}
.info-banner {
background-color: #e3f2fd;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
border-left: 4px solid #2196F3;
}
@media (max-width: 768px) {
.calculator-container {
grid-template-columns: 1fr;
}
}

112
public/js/calculator.js Normal file
View File

@@ -0,0 +1,112 @@
$(document).ready(function() {
// Live calculation function
function calculate() {
const kaufpreis = parseFloat($('#kaufpreis').val()) || 0;
const wohnflaeche = parseFloat($('#wohnflaeche').val()) || 0;
const nutzflaeche = parseFloat($('#nutzflaeche').val()) || 0;
const baujahr = parseInt($('#baujahr').val()) || 0;
const abschreibungszeit = parseFloat($('#abschreibungszeit').val()) || 50;
const bundeslandSteuer = parseFloat($('#bundesland_id option:selected').data('steuer')) || 0;
// Gesamtfläche
const gesamtflaeche = wohnflaeche + nutzflaeche;
$('#result-gesamtflaeche').text(gesamtflaeche.toLocaleString('de-DE') + ' m²');
// Preis pro m²
const preisProQm = wohnflaeche > 0 ? kaufpreis / wohnflaeche : 0;
$('#result-preis-pro-qm').text(preisProQm.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €');
// Grunderwerbsteuer
const grunderwerbsteuer = kaufpreis * (bundeslandSteuer / 100);
$('#result-grunderwerbsteuer').text(grunderwerbsteuer.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €');
// Gesamtkosten
const gesamtkosten = kaufpreis + grunderwerbsteuer;
$('#result-gesamtkosten').text(gesamtkosten.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €');
// Jährliche Abschreibung
const abschreibung = abschreibungszeit > 0 ? kaufpreis / abschreibungszeit : 0;
$('#result-abschreibung').text(abschreibung.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €');
// Alter der Immobilie
const currentYear = new Date().getFullYear();
const alter = baujahr > 0 ? currentYear - baujahr : 0;
$('#result-alter').text(alter + ' Jahre');
}
// Trigger calculation on any input change
$('#immo-calculator-form input, #immo-calculator-form select').on('input change', function() {
calculate();
});
// Generate shareable link
$('#share-link-btn').click(function() {
const formData = $('#immo-calculator-form').serializeArray();
const params = new URLSearchParams();
formData.forEach(item => {
if (item.value) {
params.append(item.name, item.value);
}
});
const shareUrl = window.location.origin + window.location.pathname + '?' + params.toString();
$('#share-url').val(shareUrl);
$('#share-link-container').slideDown();
});
// Copy link to clipboard
$('#copy-link-btn').click(function() {
const shareUrl = $('#share-url');
shareUrl.select();
document.execCommand('copy');
alert('Link wurde in die Zwischenablage kopiert!');
});
// Reset form
$('#reset-btn').click(function() {
$('#immo-calculator-form')[0].reset();
$('#share-link-container').slideUp();
calculate();
});
// Save immobilie (for logged in users)
$('#save-immobilie-btn').click(function() {
const formData = $('#immo-calculator-form').serializeArray();
const data = {};
formData.forEach(item => {
if (item.name === 'garage') {
data[item.name] = $('#garage').is(':checked');
} else {
data[item.name] = item.value;
}
});
$.ajax({
url: '/immobilie/save',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
if (response.success) {
alert(response.message + '\n\nSie können Ihre gespeicherten Immobilien unter "Meine Immobilien" einsehen.');
} else {
alert('Fehler: ' + response.message);
}
},
error: function(xhr) {
if (xhr.status === 401) {
alert('Sie müssen angemeldet sein, um Immobilien zu speichern.');
window.location.href = '/login';
} else {
const response = xhr.responseJSON;
alert('Fehler: ' + (response ? response.message : 'Unbekannter Fehler'));
}
}
});
});
// Initial calculation on page load
calculate();
});

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class AuthController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// Redirect if already logged in
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
// Get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// Last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('auth/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $passwordHasher,
EntityManagerInterface $entityManager,
UserRepository $userRepository
): Response {
// Redirect if already logged in
if ($this->getUser()) {
return $this->redirectToRoute('app_home');
}
$error = null;
if ($request->isMethod('POST')) {
$name = $request->request->get('name');
$email = $request->request->get('email');
$password = $request->request->get('password');
$passwordConfirm = $request->request->get('password_confirm');
// Validation
if (empty($name) || empty($email) || empty($password)) {
$error = 'Bitte füllen Sie alle Felder aus.';
} elseif ($password !== $passwordConfirm) {
$error = 'Die Passwörter stimmen nicht überein.';
} elseif (strlen($password) < 6) {
$error = 'Das Passwort muss mindestens 6 Zeichen lang sein.';
} elseif ($userRepository->findOneBy(['email' => $email])) {
$error = 'Diese E-Mail-Adresse ist bereits registriert.';
} else {
// Create new user
$user = new User();
$user->setName($name);
$user->setEmail($email);
// Hash the password
$hashedPassword = $passwordHasher->hashPassword($user, $password);
$user->setPassword($hashedPassword);
$entityManager->persist($user);
$entityManager->flush();
$this->addFlash('success', 'Registrierung erfolgreich! Sie können sich jetzt anmelden.');
return $this->redirectToRoute('app_login');
}
}
return $this->render('auth/register.html.twig', [
'error' => $error,
]);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// This method can be blank - it will be intercepted by the logout key on your firewall
throw new \Exception('This should never be reached!');
}
}

View File

@@ -2,17 +2,45 @@
namespace App\Controller;
use App\Repository\BundeslandRepository;
use App\Repository\HeizungstypRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(): Response
public function index(
Request $request,
BundeslandRepository $bundeslandRepository,
HeizungstypRepository $heizungstypRepository
): Response
{
$bundeslaender = $bundeslandRepository->findAll();
$heizungstypen = $heizungstypRepository->findAll();
// Load data from URL parameters if present
$immobilienData = [
'adresse' => $request->query->get('adresse', ''),
'kaufpreis' => $request->query->get('kaufpreis', ''),
'wohnflaeche' => $request->query->get('wohnflaeche', ''),
'nutzflaeche' => $request->query->get('nutzflaeche', ''),
'zimmer' => $request->query->get('zimmer', ''),
'baujahr' => $request->query->get('baujahr', ''),
'garage' => $request->query->get('garage', false),
'etage' => $request->query->get('etage', ''),
'typ' => $request->query->get('typ', ''),
'bundesland_id' => $request->query->get('bundesland_id', ''),
'heizungstyp_id' => $request->query->get('heizungstyp_id', ''),
'abschreibungszeit' => $request->query->get('abschreibungszeit', '50'),
];
return $this->render('home/index.html.twig', [
'controller_name' => 'HomeController',
'bundeslaender' => $bundeslaender,
'heizungstypen' => $heizungstypen,
'immobilienData' => $immobilienData,
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Controller;
use App\Entity\Immobilie;
use App\Repository\ImmobilieRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/immobilien')]
class ImmobilieController extends AbstractController
{
#[Route('/', name: 'app_immobilie_index')]
public function index(ImmobilieRepository $repository): Response
{
$immobilien = $repository->findVerfuegbare();
return $this->render('immobilie/index.html.twig', [
'immobilien' => $immobilien,
]);
}
#[Route('/{id}', name: 'app_immobilie_show', requirements: ['id' => '\d+'])]
public function show(Immobilie $immobilie): Response
{
return $this->render('immobilie/show.html.twig', [
'immobilie' => $immobilie,
]);
}
#[Route('/suche', name: 'app_immobilie_suche')]
public function suche(ImmobilieRepository $repository): Response
{
return $this->render('immobilie/suche.html.twig');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controller;
use App\Entity\Immobilie;
use App\Enum\ImmobilienTyp;
use App\Repository\BundeslandRepository;
use App\Repository\HeizungstypRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ImmobilienSaveController extends AbstractController
{
#[Route('/immobilie/save', name: 'app_immobilie_save', methods: ['POST'])]
public function save(
Request $request,
EntityManagerInterface $entityManager,
BundeslandRepository $bundeslandRepository,
HeizungstypRepository $heizungstypRepository
): JsonResponse {
// Check if user is logged in
if (!$this->getUser()) {
return new JsonResponse([
'success' => false,
'message' => 'Sie müssen angemeldet sein, um Immobilien zu speichern.',
], 401);
}
$data = json_decode($request->getContent(), true);
// Validation
if (empty($data['adresse']) || empty($data['kaufpreis']) || empty($data['wohnflaeche'])) {
return new JsonResponse([
'success' => false,
'message' => 'Bitte füllen Sie mindestens Adresse, Kaufpreis und Wohnfläche aus.',
], 400);
}
// Create new Immobilie
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->getUser());
$immobilie->setAdresse($data['adresse']);
$immobilie->setKaufpreis((int) $data['kaufpreis']);
$immobilie->setWohnflaeche((int) $data['wohnflaeche']);
$immobilie->setNutzflaeche((int) ($data['nutzflaeche'] ?? 0));
$immobilie->setZimmer((int) ($data['zimmer'] ?? 0));
// Set Typ from string to enum
$typ = ImmobilienTyp::WOHNUNG; // default
if (!empty($data['typ'])) {
$typ = match(strtolower($data['typ'])) {
'haus' => ImmobilienTyp::HAUS,
'gewerbe' => ImmobilienTyp::GEWERBE,
'grundstück', 'grundstueck' => ImmobilienTyp::GRUNDSTUECK,
'büro', 'buero' => ImmobilienTyp::BUERO,
default => ImmobilienTyp::WOHNUNG,
};
}
$immobilie->setTyp($typ);
$immobilie->setGarage((bool) ($data['garage'] ?? false));
if (!empty($data['baujahr'])) {
$immobilie->setBaujahr((int) $data['baujahr']);
}
if (!empty($data['etage'])) {
$immobilie->setEtage((int) $data['etage']);
}
if (!empty($data['abschreibungszeit'])) {
$immobilie->setAbschreibungszeit((int) $data['abschreibungszeit']);
}
if (!empty($data['beschreibung'])) {
$immobilie->setBeschreibung($data['beschreibung']);
}
// Set Bundesland
if (!empty($data['bundesland_id'])) {
$bundesland = $bundeslandRepository->find($data['bundesland_id']);
if ($bundesland) {
$immobilie->setBundesland($bundesland);
}
}
// Set Heizungstyp
if (!empty($data['heizungstyp_id'])) {
$heizungstyp = $heizungstypRepository->find($data['heizungstyp_id']);
if ($heizungstyp) {
$immobilie->setHeizungstyp($heizungstyp);
}
}
$entityManager->persist($immobilie);
$entityManager->flush();
return new JsonResponse([
'success' => true,
'message' => 'Immobilie erfolgreich gespeichert!',
'id' => $immobilie->getId(),
]);
}
#[Route('/meine-immobilien', name: 'app_my_immobilien')]
public function myImmobilien(): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$immobilien = $this->getUser()->getImmobilien();
return $this->render('immobilie/my_immobilien.html.twig', [
'immobilien' => $immobilien,
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Immobilie;
use App\Entity\User;
use App\Enum\UserRole;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private Security $security
) {
}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, ?Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
// Nur für Immobilie-Entity
if (Immobilie::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
// Wenn nicht eingeloggt, keine Ergebnisse
if (! $user instanceof User) {
$queryBuilder->andWhere('1 = 0');
return;
}
// Admin sieht alles
if (UserRole::ADMIN === $user->getRole()) {
return;
}
// Normale User sehen nur eigene Immobilien
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.verwalter = :current_user', $rootAlias))
->setParameter('current_user', $user);
}
}

71
src/Entity/Bundesland.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\BundeslandRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BundeslandRepository::class)]
#[ORM\Table(name: 'bundeslaender')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("PUBLIC_ACCESS")'),
new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'),
new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
]
)]
class Bundesland
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100, unique: true)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
private string $name;
#[ORM\Column(type: 'decimal', precision: 4, scale: 2)]
#[Assert\NotBlank]
#[Assert\Range(min: 0, max: 100)]
private float $grunderwerbsteuer;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getGrunderwerbsteuer(): float
{
return $this->grunderwerbsteuer;
}
public function setGrunderwerbsteuer(float $grunderwerbsteuer): self
{
$this->grunderwerbsteuer = $grunderwerbsteuer;
return $this;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\HeizungstypRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: HeizungstypRepository::class)]
#[ORM\Table(name: 'heizungstypen')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("PUBLIC_ACCESS")'),
new GetCollection(security: 'is_granted("PUBLIC_ACCESS")'),
new Post(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Put(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
new Delete(security: 'is_granted("ROLE_ADMIN") or is_granted("ROLE_TECHNICAL")'),
]
)]
class Heizungstyp
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100, unique: true)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
private string $name;
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}

359
src/Entity/Immobilie.php Normal file
View File

@@ -0,0 +1,359 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ImmobilienTyp;
use App\Repository\ImmobilieRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ImmobilieRepository::class)]
#[ORM\Table(name: 'immobilien')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(security: 'is_granted("view", object)'),
new GetCollection(),
new Post(),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
security: 'is_granted("ROLE_USER")'
)]
class Immobilie
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'immobilien')]
#[ORM\JoinColumn(nullable: false)]
#[Assert\NotNull]
private User $verwalter;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 5, max: 255)]
private string $adresse;
#[ORM\Column(type: 'integer')]
#[Assert\NotBlank]
#[Assert\Positive]
private int $wohnflaeche;
#[ORM\Column(type: 'integer')]
#[Assert\NotBlank]
#[Assert\PositiveOrZero]
private int $nutzflaeche;
#[ORM\Column(type: 'boolean')]
private bool $garage = false;
#[ORM\Column(type: 'integer')]
#[Assert\NotBlank]
#[Assert\Positive]
private int $zimmer;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Range(min: 1800, max: 2100)]
private ?int $baujahr = null;
#[ORM\Column(type: 'string', enumType: ImmobilienTyp::class)]
private ImmobilienTyp $typ;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $beschreibung = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Min(0)]
#[Assert\Max(10)]
private ?int $etage = null;
#[ORM\ManyToOne(targetEntity: Heizungstyp::class, inversedBy: 'immobilien')]
#[ORM\JoinColumn(nullable: true)]
private ?Heizungstyp $heizungstyp = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Range(min: 0, max: 100)]
private ?int $abschreibungszeit = null;
#[ORM\ManyToOne(targetEntity: Bundesland::class, inversedBy: 'immobilien')]
#[ORM\JoinColumn(nullable: true)]
private ?Bundesland $bundesland = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\PositiveOrZero]
private ?int $kaufpreis = null;
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $createdAt;
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $updatedAt;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->updatedAt = new \DateTime();
$this->typ = ImmobilienTyp::WOHNUNG;
$this->nutzflaeche = 0;
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getAdresse(): string
{
return $this->adresse;
}
public function setAdresse(string $adresse): self
{
$this->adresse = $adresse;
return $this;
}
public function getWohnflaeche(): int
{
return $this->wohnflaeche;
}
public function setWohnflaeche(int $wohnflaeche): self
{
$this->wohnflaeche = $wohnflaeche;
return $this;
}
public function getNutzflaeche(): int
{
return $this->nutzflaeche;
}
public function setNutzflaeche(int $nutzflaeche): self
{
$this->nutzflaeche = $nutzflaeche;
return $this;
}
public function getGarage(): bool
{
return $this->garage;
}
public function isGarage(): bool
{
return $this->garage;
}
public function setGarage(bool $garage): self
{
$this->garage = $garage;
return $this;
}
public function getZimmer(): int
{
return $this->zimmer;
}
public function setZimmer(int $zimmer): self
{
$this->zimmer = $zimmer;
return $this;
}
public function getBaujahr(): ?int
{
return $this->baujahr;
}
public function setBaujahr(?int $baujahr): self
{
$this->baujahr = $baujahr;
return $this;
}
public function getTyp(): ImmobilienTyp
{
return $this->typ;
}
public function setTyp(ImmobilienTyp $typ): self
{
$this->typ = $typ;
return $this;
}
public function getBeschreibung(): ?string
{
return $this->beschreibung;
}
public function setBeschreibung(?string $beschreibung): self
{
$this->beschreibung = $beschreibung;
return $this;
}
public function getEtage(): ?int
{
return $this->etage;
}
public function setEtage(?int $etage): self
{
$this->etage = $etage;
return $this;
}
public function getHeizungstyp(): ?Heizungstyp
{
return $this->heizungstyp;
}
public function setHeizungstyp(?Heizungstyp $heizungstyp): self
{
$this->heizungstyp = $heizungstyp;
return $this;
}
public function getCreatedAt(): \DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): \DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeInterface $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getAbschreibungszeit(): ?int
{
return $this->abschreibungszeit;
}
public function setAbschreibungszeit(?int $abschreibungszeit): self
{
$this->abschreibungszeit = $abschreibungszeit;
return $this;
}
public function getBundesland(): ?Bundesland
{
return $this->bundesland;
}
public function setBundesland(?Bundesland $bundesland): self
{
$this->bundesland = $bundesland;
return $this;
}
public function getKaufpreis(): ?int
{
return $this->kaufpreis;
}
public function setKaufpreis(?int $kaufpreis): self
{
$this->kaufpreis = $kaufpreis;
return $this;
}
public function getVerwalter(): User
{
return $this->verwalter;
}
public function setVerwalter(User $verwalter): self
{
$this->verwalter = $verwalter;
return $this;
}
/**
* Berechnet die Gesamtfläche (Wohnfläche + Nutzfläche).
*/
public function getGesamtflaeche(): int
{
return $this->wohnflaeche + $this->nutzflaeche;
}
/**
* Berechnet die Kaufnebenkosten basierend auf dem Bundesland
* Rückgabe: Array mit Notar, Grundbuch, Grunderwerbsteuer und Gesamt.
*/
public function getKaufnebenkosten(): array
{
if (! $this->getKaufpreis() || ! $this->bundesland) {
return [
'notar' => 0,
'grundbuch' => 0,
'grunderwerbsteuer' => 0,
'gesamt' => 0,
];
}
// Notarkosten: ca. 1,5% des Kaufpreises
$notar = $this->getKaufpreis() * 0.015;
// Grundbuchkosten: ca. 0,5% des Kaufpreises
$grundbuch = $this->getKaufpreis() * 0.005;
// Grunderwerbsteuer: abhängig vom Bundesland
$grunderwerbsteuerSatz = $this->bundesland->getGrunderwerbsteuer() / 100;
$grunderwerbsteuer = $this->getKaufpreis() * $grunderwerbsteuerSatz;
$gesamt = $notar + $grundbuch + $grunderwerbsteuer;
return [
'notar' => $notar,
'grundbuch' => $grundbuch,
'grunderwerbsteuer' => $grunderwerbsteuer,
'gesamt' => $gesamt,
];
}
}

View File

@@ -3,14 +3,17 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use App\Enum\UserRole;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
@@ -21,10 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
new GetCollection(),
new Post(),
new Put(),
new Delete()
new Delete(),
]
)]
class User
class User implements UserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -44,13 +47,32 @@ class User
#[ORM\Column(type: 'string', enumType: UserRole::class)]
private UserRole $role;
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $apiKey;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $password = null;
#[ORM\Column(type: 'datetime')]
private \DateTimeInterface $createdAt;
#[ORM\OneToMany(targetEntity: Immobilie::class, mappedBy: 'verwalter', orphanRemoval: true)]
private Collection $immobilien;
public function __construct()
{
$this->createdAt = new \DateTime();
$this->role = UserRole::USER;
$this->immobilien = new ArrayCollection();
$this->apiKey = $this->generateApiKey();
}
/**
* Generiert einen eindeutigen API-Key.
*/
private function generateApiKey(): string
{
return hash('sha256', random_bytes(32).microtime(true));
}
public function getId(): ?int
@@ -66,6 +88,7 @@ class User
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
@@ -77,6 +100,7 @@ class User
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
@@ -88,6 +112,7 @@ class User
public function setRole(UserRole $role): self
{
$this->role = $role;
return $this;
}
@@ -99,6 +124,87 @@ class User
public function setCreatedAt(\DateTimeInterface $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
/**
* @return Collection<int, Immobilie>
*/
public function getImmobilien(): Collection
{
return $this->immobilien;
}
public function addImmobilie(Immobilie $immobilie): self
{
if (! $this->immobilien->contains($immobilie)) {
$this->immobilien->add($immobilie);
$immobilie->setVerwalter($this);
}
return $this;
}
public function removeImmobilie(Immobilie $immobilie): self
{
$this->immobilien->removeElement($immobilie);
return $this;
}
public function getApiKey(): string
{
return $this->apiKey;
}
/**
* Generiert einen neuen API-Key.
*/
public function regenerateApiKey(): self
{
$this->apiKey = $this->generateApiKey();
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(?string $password): self
{
$this->password = $password;
return $this;
}
/**
* UserInterface Methods.
*/
public function getUserIdentifier(): string
{
return $this->email;
}
public function getRoles(): array
{
$roles = ['ROLE_USER'];
if (UserRole::ADMIN === $this->role) {
$roles[] = 'ROLE_ADMIN';
} elseif (UserRole::MODERATOR === $this->role) {
$roles[] = 'ROLE_MODERATOR';
} elseif (UserRole::TECHNICAL === $this->role) {
$roles[] = 'ROLE_TECHNICAL';
}
return array_unique($roles);
}
public function eraseCredentials(): void
{
// Nothing to erase
}
}

71
src/Enum/Bundesland.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace App\Enum;
enum Bundesland: string
{
case BADEN_WUERTTEMBERG = 'baden_wuerttemberg';
case BAYERN = 'bayern';
case BERLIN = 'berlin';
case BRANDENBURG = 'brandenburg';
case BREMEN = 'bremen';
case HAMBURG = 'hamburg';
case HESSEN = 'hessen';
case MECKLENBURG_VORPOMMERN = 'mecklenburg_vorpommern';
case NIEDERSACHSEN = 'niedersachsen';
case NORDRHEIN_WESTFALEN = 'nordrhein_westfalen';
case RHEINLAND_PFALZ = 'rheinland_pfalz';
case SAARLAND = 'saarland';
case SACHSEN = 'sachsen';
case SACHSEN_ANHALT = 'sachsen_anhalt';
case SCHLESWIG_HOLSTEIN = 'schleswig_holstein';
case THUERINGEN = 'thueringen';
public function getLabel(): string
{
return match ($this) {
self::BADEN_WUERTTEMBERG => 'Baden-Württemberg',
self::BAYERN => 'Bayern',
self::BERLIN => 'Berlin',
self::BRANDENBURG => 'Brandenburg',
self::BREMEN => 'Bremen',
self::HAMBURG => 'Hamburg',
self::HESSEN => 'Hessen',
self::MECKLENBURG_VORPOMMERN => 'Mecklenburg-Vorpommern',
self::NIEDERSACHSEN => 'Niedersachsen',
self::NORDRHEIN_WESTFALEN => 'Nordrhein-Westfalen',
self::RHEINLAND_PFALZ => 'Rheinland-Pfalz',
self::SAARLAND => 'Saarland',
self::SACHSEN => 'Sachsen',
self::SACHSEN_ANHALT => 'Sachsen-Anhalt',
self::SCHLESWIG_HOLSTEIN => 'Schleswig-Holstein',
self::THUERINGEN => 'Thüringen',
};
}
/**
* Gibt die Grunderwerbsteuer in Prozent für das Bundesland zurück
* Stand: 2025.
*/
public function getGrunderwerbsteuer(): float
{
return match ($this) {
self::BADEN_WUERTTEMBERG => 5.0,
self::BAYERN => 3.5,
self::BERLIN => 6.0,
self::BRANDENBURG => 6.5,
self::BREMEN => 5.0,
self::HAMBURG => 5.5,
self::HESSEN => 6.0,
self::MECKLENBURG_VORPOMMERN => 6.0,
self::NIEDERSACHSEN => 5.0,
self::NORDRHEIN_WESTFALEN => 6.5,
self::RHEINLAND_PFALZ => 5.0,
self::SAARLAND => 6.5,
self::SACHSEN => 5.5,
self::SACHSEN_ANHALT => 5.0,
self::SCHLESWIG_HOLSTEIN => 6.5,
self::THUERINGEN => 5.0,
};
}
}

21
src/Enum/Heizungstyp.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enum;
enum Heizungstyp: string
{
case GASHEIZUNG = 'gasheizung';
case WAERMEPUMPE = 'waermepumpe';
case OELHEIZUNG = 'oelheizung';
case PELLETHEIZUNG = 'pelletheizung';
public function getLabel(): string
{
return match ($this) {
self::GASHEIZUNG => 'Gasheizung',
self::WAERMEPUMPE => 'Wärmepumpe',
self::OELHEIZUNG => 'Ölheizung',
self::PELLETHEIZUNG => 'Pelletheizung',
};
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Enum;
enum ImmobilienTyp: string
{
case WOHNUNG = 'wohnung';
case HAUS = 'haus';
case GRUNDSTUECK = 'grundstueck';
case GEWERBE = 'gewerbe';
case BUERO = 'buero';
public function getLabel(): string
{
return match ($this) {
self::WOHNUNG => 'Wohnung',
self::HAUS => 'Haus',
self::GRUNDSTUECK => 'Grundstück',
self::GEWERBE => 'Gewerbe',
self::BUERO => 'Büro',
};
}
}

View File

@@ -7,13 +7,15 @@ enum UserRole: string
case USER = 'user';
case ADMIN = 'admin';
case MODERATOR = 'moderator';
case TECHNICAL = 'technical';
public function getLabel(): string
{
return match($this) {
return match ($this) {
self::USER => 'Benutzer',
self::ADMIN => 'Administrator',
self::MODERATOR => 'Moderator',
self::TECHNICAL => 'Technischer User',
};
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\Bundesland;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BundeslandRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Bundesland::class);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Repository;
use App\Entity\Heizungstyp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class HeizungstypRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Heizungstyp::class);
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Repository;
use App\Entity\Immobilie;
use App\Enum\ImmobilienTyp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Immobilie>
*/
class ImmobilieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Immobilie::class);
}
/**
* Find available properties.
*/
public function findVerfuegbare(): array
{
return $this->createQueryBuilder('i')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('verfuegbar', true)
->orderBy('i.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find properties by type.
*/
public function findByTyp(ImmobilienTyp $typ): array
{
return $this->createQueryBuilder('i')
->andWhere('i.typ = :typ')
->setParameter('typ', $typ)
->orderBy('i.preis', 'ASC')
->getQuery()
->getResult();
}
/**
* Find properties within price range.
*/
public function findByPreisRange(float $minPreis, float $maxPreis): array
{
return $this->createQueryBuilder('i')
->andWhere('i.preis BETWEEN :minPreis AND :maxPreis')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('minPreis', $minPreis)
->setParameter('maxPreis', $maxPreis)
->setParameter('verfuegbar', true)
->orderBy('i.preis', 'ASC')
->getQuery()
->getResult();
}
/**
* Find properties within area range.
*/
public function findByFlaecheRange(float $minFlaeche, float $maxFlaeche): array
{
return $this->createQueryBuilder('i')
->andWhere('i.flaeche BETWEEN :minFlaeche AND :maxFlaeche')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('minFlaeche', $minFlaeche)
->setParameter('maxFlaeche', $maxFlaeche)
->setParameter('verfuegbar', true)
->orderBy('i.flaeche', 'ASC')
->getQuery()
->getResult();
}
/**
* Find properties with garage.
*/
public function findMitGarage(): array
{
return $this->createQueryBuilder('i')
->andWhere('i.garage = :garage')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('garage', true)
->setParameter('verfuegbar', true)
->orderBy('i.preis', 'ASC')
->getQuery()
->getResult();
}
/**
* Find properties by minimum number of rooms.
*/
public function findByMinZimmer(int $minZimmer): array
{
return $this->createQueryBuilder('i')
->andWhere('i.zimmer >= :minZimmer')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('minZimmer', $minZimmer)
->setParameter('verfuegbar', true)
->orderBy('i.zimmer', 'ASC')
->getQuery()
->getResult();
}
/**
* Get average price per sqm by type.
*/
public function getAveragePreisProQmByTyp(ImmobilienTyp $typ): float
{
$result = $this->createQueryBuilder('i')
->select('AVG(i.preis / i.flaeche) as avgPreisProQm')
->andWhere('i.typ = :typ')
->andWhere('i.verfuegbar = :verfuegbar')
->setParameter('typ', $typ)
->setParameter('verfuegbar', true)
->getQuery()
->getSingleScalarResult();
return round((float) $result, 2);
}
/**
* Search properties by address.
*/
public function searchByAdresse(string $search): array
{
return $this->createQueryBuilder('i')
->andWhere('i.adresse LIKE :search')
->setParameter('search', '%'.$search.'%')
->orderBy('i.createdAt', 'DESC')
->getQuery()
->getResult();
}
}

View File

@@ -17,7 +17,7 @@ class UserRepository extends ServiceEntityRepository
}
/**
* Find users by role
* Find users by role.
*/
public function findByRole(string $role): array
{
@@ -30,7 +30,7 @@ class UserRepository extends ServiceEntityRepository
}
/**
* Find user by email
* Find user by email.
*/
public function findOneByEmail(string $email): ?User
{

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Security;
use App\Repository\UserRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function __construct(
private UserRepository $userRepository
) {
}
public function supports(Request $request): ?bool
{
return $request->headers->has('X-API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('X-API-KEY');
if (null === $apiKey) {
throw new CustomUserMessageAuthenticationException('No API key provided');
}
return new SelfValidatingPassport(
new UserBadge($apiKey, function ($apiKey) {
$user = $this->userRepository->findOneBy(['apiKey' => $apiKey]);
if (! $user) {
throw new CustomUserMessageAuthenticationException('Invalid API key');
}
return $user;
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// On success, let the request continue
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
], Response::HTTP_UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Security;
use App\Entity\Immobilie;
use App\Entity\User;
use App\Enum\UserRole;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ImmobilieVoter extends Voter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
protected function supports(string $attribute, mixed $subject): bool
{
// Voter unterstützt nur diese Attribute und nur Immobilie-Objekte
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof Immobilie;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// User muss eingeloggt sein
if (! $user instanceof User) {
return false;
}
/** @var Immobilie $immobilie */
$immobilie = $subject;
// Admin hat uneingeschränkten Zugriff
if (UserRole::ADMIN === $user->getRole()) {
return true;
}
// Prüfe je nach Attribut
return match ($attribute) {
self::VIEW => $this->canView($immobilie, $user),
self::EDIT => $this->canEdit($immobilie, $user),
self::DELETE => $this->canDelete($immobilie, $user),
default => false
};
}
private function canView(Immobilie $immobilie, User $user): bool
{
// User kann nur eigene Immobilien sehen
return $immobilie->getVerwalter() === $user;
}
private function canEdit(Immobilie $immobilie, User $user): bool
{
// User kann nur eigene Immobilien bearbeiten
return $immobilie->getVerwalter() === $user;
}
private function canDelete(Immobilie $immobilie, User $user): bool
{
// User kann nur eigene Immobilien löschen
return $immobilie->getVerwalter() === $user;
}
}

View File

@@ -49,6 +49,18 @@
"./migrations/.gitignore"
]
},
"friendsofphp/php-cs-fixer": {
"version": "3.89",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "be2103eb4a20942e28a6dd87736669b757132435"
},
"files": [
".php-cs-fixer.dist.php"
]
},
"nelmio/cors-bundle": {
"version": "2.6",
"recipe": {
@@ -61,6 +73,21 @@
"./config/packages/nelmio_cors.yaml"
]
},
"phpunit/phpunit": {
"version": "12.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "1117deb12541f35793eec9fff7494d7aa12283fc"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"symfony/console": {
"version": "7.3",
"recipe": {

View File

@@ -0,0 +1,46 @@
{% extends 'base.html.twig' %}
{% block title %}Login - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('css/auth.css') }}">
{% endblock %}
{% block body %}
<div class="auth-container">
<div class="auth-box">
<h2>Anmelden</h2>
{% for message in app.flashes('success') %}
<div class="success-message">{{ message }}</div>
{% endfor %}
{% if error %}
<div class="error-message">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form class="auth-form" method="post" action="{{ path('app_login') }}">
<div class="form-group">
<label for="username">E-Mail</label>
<input type="email" id="username" name="_username" value="{{ last_username }}" required autofocus>
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" name="_password" required>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
<button class="btn-submit" type="submit">Anmelden</button>
</form>
<div class="auth-links">
<p>Noch kein Konto? <a href="{{ path('app_register') }}">Jetzt registrieren</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'base.html.twig' %}
{% block title %}Registrierung - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('css/auth.css') }}">
{% endblock %}
{% block body %}
<div class="auth-container">
<div class="auth-box">
<h2>Registrieren</h2>
{% if error %}
<div class="error-message">{{ error }}</div>
{% endif %}
<form class="auth-form" method="post" action="{{ path('app_register') }}">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required autofocus>
</div>
<div class="form-group">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-group">
<label for="password">Passwort (min. 6 Zeichen)</label>
<input type="password" id="password" name="password" required>
</div>
<div class="form-group">
<label for="password_confirm">Passwort bestätigen</label>
<input type="password" id="password_confirm" name="password_confirm" required>
</div>
<button class="btn-submit" type="submit">Registrieren</button>
</form>
<div class="auth-links">
<p>Bereits registriert? <a href="{{ path('app_login') }}">Jetzt anmelden</a></p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -48,7 +48,19 @@
<body>
<header>
<div class="container">
<h1>Immorechner</h1>
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1><a href="{{ path('app_home') }}" style="color: white; text-decoration: none;">Immorechner</a></h1>
<nav>
{% if app.user %}
<span style="color: white; margin-right: 15px;">Hallo, {{ app.user.name }}!</span>
<a href="{{ path('app_my_immobilien') }}" style="color: white; text-decoration: none; margin-right: 10px;">Meine Immobilien</a>
<a href="{{ path('app_logout') }}" style="color: white; text-decoration: none; padding: 8px 16px; background-color: rgba(255,255,255,0.2); border-radius: 4px;">Abmelden</a>
{% else %}
<a href="{{ path('app_login') }}" style="color: white; text-decoration: none; margin-right: 10px;">Anmelden</a>
<a href="{{ path('app_register') }}" style="color: white; text-decoration: none; padding: 8px 16px; background-color: rgba(255,255,255,0.2); border-radius: 4px;">Registrieren</a>
{% endif %}
</nav>
</div>
</div>
</header>

View File

@@ -1,67 +1,167 @@
{% extends 'base.html.twig' %}
{% block title %}Willkommen - {{ parent() }}{% endblock %}
{% block title %}Immobilienwert berechnen - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.welcome-box {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h2 {
color: #333;
border-bottom: 2px solid #4CAF50;
padding-bottom: 10px;
margin-bottom: 20px;
}
.info {
margin: 20px 0;
padding: 15px;
background-color: #e8f5e9;
border-left: 4px solid #4CAF50;
}
.api-link {
display: inline-block;
margin-top: 20px;
padding: 10px 20px;
background-color: #4CAF50;
color: white;
text-decoration: none;
border-radius: 4px;
}
.api-link:hover {
background-color: #45a049;
}
ul {
margin-left: 20px;
}
ul li {
margin: 8px 0;
}
</style>
<link rel="stylesheet" href="{{ asset('css/calculator.css') }}">
{% endblock %}
{% block body %}
<div class="welcome-box">
<h2>Willkommen bei Immorechner</h2>
<div class="info-banner">
<h2 style="margin-bottom: 10px; border: none;">Immobilienwert-Rechner</h2>
<p>Berechnen Sie die Werthaltigkeit Ihrer Immobilie. Alle Berechnungen erfolgen in Echtzeit.</p>
<p><strong>Ohne Registrierung:</strong> Teilen Sie Ihre Berechnung über einen Link.</p>
<p><strong>Mit Registrierung:</strong> Speichern Sie Ihre Immobilien dauerhaft.</p>
</div>
<div class="info">
<p><strong>Symfony-Anwendung erfolgreich installiert!</strong></p>
<p>Diese Anwendung verfügt über:</p>
<ul>
<li>Symfony 7.3 Framework</li>
<li>MariaDB Datenbank (mit Docker)</li>
<li>Doctrine ORM</li>
<li>API Platform für REST-API</li>
<li>Twig Template Engine für UI</li>
</ul>
<div class="calculator-container">
<div class="form-section">
<h2>Immobiliendaten</h2>
<form id="immo-calculator-form">
<div class="form-group">
<label for="adresse">Adresse</label>
<input type="text" id="adresse" name="adresse" value="{{ immobilienData.adresse }}" placeholder="z.B. Musterstraße 123, 12345 Berlin">
</div>
<div class="form-group">
<label for="kaufpreis">Kaufpreis (€)</label>
<input type="number" id="kaufpreis" name="kaufpreis" value="{{ immobilienData.kaufpreis }}" placeholder="z.B. 250000">
</div>
<div class="form-group">
<label for="wohnflaeche">Wohnfläche (m²)</label>
<input type="number" id="wohnflaeche" name="wohnflaeche" value="{{ immobilienData.wohnflaeche }}" placeholder="z.B. 80">
</div>
<div class="form-group">
<label for="nutzflaeche">Nutzfläche (m²)</label>
<input type="number" id="nutzflaeche" name="nutzflaeche" value="{{ immobilienData.nutzflaeche }}" placeholder="z.B. 100">
</div>
<div class="form-group">
<label for="zimmer">Anzahl Zimmer</label>
<input type="number" id="zimmer" name="zimmer" value="{{ immobilienData.zimmer }}" placeholder="z.B. 3">
</div>
<div class="form-group">
<label for="baujahr">Baujahr</label>
<input type="number" id="baujahr" name="baujahr" value="{{ immobilienData.baujahr }}" placeholder="z.B. 2015">
</div>
<div class="form-group">
<label for="etage">Etage</label>
<input type="number" id="etage" name="etage" value="{{ immobilienData.etage }}" placeholder="z.B. 2">
</div>
<div class="form-group">
<label for="typ">Immobilientyp</label>
<select id="typ" name="typ">
<option value="">-- Bitte wählen --</option>
<option value="Wohnung" {% if immobilienData.typ == 'Wohnung' %}selected{% endif %}>Wohnung</option>
<option value="Haus" {% if immobilienData.typ == 'Haus' %}selected{% endif %}>Haus</option>
<option value="Gewerbe" {% if immobilienData.typ == 'Gewerbe' %}selected{% endif %}>Gewerbe</option>
</select>
</div>
<div class="form-group">
<label for="bundesland_id">Bundesland</label>
<select id="bundesland_id" name="bundesland_id">
<option value="">-- Bitte wählen --</option>
{% for bundesland in bundeslaender %}
<option value="{{ bundesland.id }}"
data-steuer="{{ bundesland.grunderwerbsteuer }}"
{% if immobilienData.bundesland_id == bundesland.id %}selected{% endif %}>
{{ bundesland.name }} ({{ bundesland.grunderwerbsteuer }}%)
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="heizungstyp_id">Heizungstyp</label>
<select id="heizungstyp_id" name="heizungstyp_id">
<option value="">-- Bitte wählen --</option>
{% for heizungstyp in heizungstypen %}
<option value="{{ heizungstyp.id }}"
{% if immobilienData.heizungstyp_id == heizungstyp.id %}selected{% endif %}>
{{ heizungstyp.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="abschreibungszeit">Abschreibungszeit (Jahre)</label>
<input type="number" id="abschreibungszeit" name="abschreibungszeit" value="{{ immobilienData.abschreibungszeit }}" placeholder="z.B. 50">
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="garage" name="garage" {% if immobilienData.garage %}checked{% endif %}>
<label for="garage">Garage vorhanden</label>
</div>
<div class="button-group">
{% if app.user %}
<button type="button" class="btn btn-primary" id="save-immobilie-btn">Speichern</button>
{% endif %}
<button type="button" class="btn btn-primary" id="share-link-btn">Link teilen</button>
<button type="button" class="btn btn-secondary" id="reset-btn">Zurücksetzen</button>
</div>
<div class="share-link" id="share-link-container">
<strong>Teilen Sie diese Berechnung:</strong>
<input type="text" id="share-url" readonly>
<button type="button" class="btn btn-secondary" style="margin-top: 10px;" id="copy-link-btn">Link kopieren</button>
</div>
</form>
</div>
<p>Die REST-API ist über API Platform verfügbar und bietet automatische Dokumentation.</p>
<div class="results-section">
<h2>Berechnungsergebnisse</h2>
<a href="/api" class="api-link">Zur API-Dokumentation</a>
<div class="result-item">
<h3>Gesamtfläche</h3>
<div class="value" id="result-gesamtflaeche">0 m²</div>
<div class="description">Wohnfläche + Nutzfläche</div>
</div>
<div class="result-item">
<h3>Preis pro m² Wohnfläche</h3>
<div class="value" id="result-preis-pro-qm">0,00 €</div>
<div class="description">Kaufpreis / Wohnfläche</div>
</div>
<div class="result-item">
<h3>Grunderwerbsteuer</h3>
<div class="value" id="result-grunderwerbsteuer">0,00 €</div>
<div class="description">Abhängig vom Bundesland</div>
</div>
<div class="result-item">
<h3>Gesamtkosten</h3>
<div class="value" id="result-gesamtkosten">0,00 €</div>
<div class="description">Kaufpreis + Grunderwerbsteuer</div>
</div>
<div class="result-item">
<h3>Jährliche Abschreibung</h3>
<div class="value" id="result-abschreibung">0,00 €</div>
<div class="description">Kaufpreis / Abschreibungszeit</div>
</div>
<div class="result-item">
<h3>Alter der Immobilie</h3>
<div class="value" id="result-alter">0 Jahre</div>
<div class="description">Aktuelles Jahr - Baujahr</div>
</div>
</div>
</div>
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="{{ asset('js/calculator.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends 'base.html.twig' %}
{% block title %}Immobilien - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.immobilien-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.immobilie-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.immobilie-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.immobilie-header {
background: #4CAF50;
color: white;
padding: 15px;
}
.immobilie-typ {
font-size: 12px;
text-transform: uppercase;
opacity: 0.9;
}
.immobilie-body {
padding: 20px;
}
.immobilie-preis {
font-size: 24px;
font-weight: bold;
color: #4CAF50;
margin: 10px 0;
}
.immobilie-details {
list-style: none;
padding: 0;
margin: 15px 0;
}
.immobilie-details li {
padding: 5px 0;
border-bottom: 1px solid #eee;
}
.immobilie-details li:last-child {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.badge-success {
background: #e8f5e9;
color: #4CAF50;
}
.btn-details {
display: block;
width: 100%;
padding: 10px;
background: #4CAF50;
color: white;
text-align: center;
text-decoration: none;
border-radius: 4px;
margin-top: 15px;
}
.btn-details:hover {
background: #45a049;
color: white;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.btn-primary {
display: inline-block;
padding: 10px 20px;
background: #4CAF50;
color: white;
text-decoration: none;
border-radius: 4px;
}
.btn-primary:hover {
background: #45a049;
color: white;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
}
</style>
{% endblock %}
{% block body %}
<div class="page-header">
<h2>Verfügbare Immobilien</h2>
<div>
<a href="{{ path('app_immobilie_suche') }}" class="btn-primary">Erweiterte Suche</a>
<a href="/api" class="btn-primary">API</a>
</div>
</div>
{% if immobilien|length > 0 %}
<div class="immobilien-grid">
{% for immobilie in immobilien %}
<div class="immobilie-card">
<div class="immobilie-header">
<div class="immobilie-typ">{{ immobilie.typ.label }}</div>
<strong>{{ immobilie.adresse }}</strong>
</div>
<div class="immobilie-body">
<div class="immobilie-preis">{{ immobilie.preis|number_format(2, ',', '.') }} €</div>
<ul class="immobilie-details">
<li>📐 Fläche: {{ immobilie.flaeche }} m²</li>
<li>🛏️ Zimmer: {{ immobilie.zimmer }}</li>
<li>💰 Preis/m²: {{ immobilie.preisProQm|number_format(2, ',', '.') }} €</li>
{% if immobilie.baujahr %}
<li>📅 Baujahr: {{ immobilie.baujahr }}</li>
{% endif %}
{% if immobilie.garage %}
<li>🚗 <span class="badge badge-success">Mit Garage</span></li>
{% endif %}
</ul>
<a href="{{ path('app_immobilie_show', {id: immobilie.id}) }}" class="btn-details">
Details ansehen
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-results">
<h3>Keine Immobilien verfügbar</h3>
<p>Aktuell sind keine Immobilien in unserem System verfügbar.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends 'base.html.twig' %}
{% block title %}Meine Immobilien - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.immobilien-list {
margin-top: 20px;
}
.immobilie-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.immobilie-card h3 {
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #4CAF50;
}
.immobilie-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.detail-item {
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.detail-item label {
display: block;
font-weight: 500;
color: #666;
font-size: 12px;
margin-bottom: 5px;
}
.detail-item value {
display: block;
color: #333;
font-size: 16px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.empty-state h2 {
color: #666;
margin-bottom: 20px;
}
.empty-state a {
display: inline-block;
padding: 12px 24px;
background-color: #4CAF50;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s;
}
.empty-state a:hover {
background-color: #45a049;
}
</style>
{% endblock %}
{% block body %}
<h1>Meine Immobilien</h1>
{% if immobilien|length > 0 %}
<div class="immobilien-list">
{% for immobilie in immobilien %}
<div class="immobilie-card">
<h3>{{ immobilie.adresse }}</h3>
<div class="immobilie-details">
<div class="detail-item">
<label>Typ</label>
<value>{{ immobilie.typ.label }}</value>
</div>
<div class="detail-item">
<label>Kaufpreis</label>
<value>{{ immobilie.kaufpreis|number_format(0, ',', '.') }} €</value>
</div>
<div class="detail-item">
<label>Wohnfläche</label>
<value>{{ immobilie.wohnflaeche }} m²</value>
</div>
<div class="detail-item">
<label>Nutzfläche</label>
<value>{{ immobilie.nutzflaeche }} m²</value>
</div>
<div class="detail-item">
<label>Gesamtfläche</label>
<value>{{ immobilie.gesamtflaeche }} m²</value>
</div>
<div class="detail-item">
<label>Zimmer</label>
<value>{{ immobilie.zimmer }}</value>
</div>
{% if immobilie.baujahr %}
<div class="detail-item">
<label>Baujahr</label>
<value>{{ immobilie.baujahr }}</value>
</div>
{% endif %}
{% if immobilie.bundesland %}
<div class="detail-item">
<label>Bundesland</label>
<value>{{ immobilie.bundesland.name }}</value>
</div>
{% endif %}
{% if immobilie.heizungstyp %}
<div class="detail-item">
<label>Heizungstyp</label>
<value>{{ immobilie.heizungstyp.name }}</value>
</div>
{% endif %}
<div class="detail-item">
<label>Garage</label>
<value>{{ immobilie.garage ? 'Ja' : 'Nein' }}</value>
</div>
<div class="detail-item">
<label>Erstellt am</label>
<value>{{ immobilie.createdAt|date('d.m.Y H:i') }}</value>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h2>Sie haben noch keine Immobilien gespeichert</h2>
<p>Nutzen Sie den Immobilienrechner, um Ihre erste Immobilie zu berechnen und zu speichern.</p>
<a href="{{ path('app_home') }}">Zum Rechner</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,211 @@
{% extends 'base.html.twig' %}
{% block title %}{{ immobilie.adresse }} - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.immobilie-detail {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
margin: 20px 0;
}
.immobilie-header {
border-bottom: 2px solid #4CAF50;
padding-bottom: 20px;
margin-bottom: 30px;
}
.immobilie-typ-badge {
display: inline-block;
background: #4CAF50;
color: white;
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
text-transform: uppercase;
margin-bottom: 10px;
}
.immobilie-adresse {
font-size: 28px;
margin: 10px 0;
}
.immobilie-preis-box {
background: #e8f5e9;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.preis-haupt {
font-size: 36px;
font-weight: bold;
color: #4CAF50;
}
.preis-pro-qm {
font-size: 16px;
color: #666;
margin-top: 5px;
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 30px 0;
}
.detail-box {
background: #f9f9f9;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #4CAF50;
}
.detail-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
margin-bottom: 5px;
}
.detail-value {
font-size: 20px;
font-weight: bold;
color: #333;
}
.beschreibung-box {
background: #f9f9f9;
padding: 20px;
border-radius: 6px;
margin: 20px 0;
}
.status-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin: 10px 0;
}
.status-verfuegbar {
background: #4CAF50;
color: white;
}
.status-nicht-verfuegbar {
background: #f44336;
color: white;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4CAF50;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.features-list {
list-style: none;
padding: 0;
margin: 20px 0;
}
.features-list li {
padding: 10px;
border-bottom: 1px solid #eee;
}
.features-list li:last-child {
border-bottom: none;
}
.feature-icon {
font-size: 20px;
margin-right: 10px;
}
</style>
{% endblock %}
{% block body %}
<a href="{{ path('app_immobilie_index') }}" class="back-link">← Zurück zur Übersicht</a>
<div class="immobilie-detail">
<div class="immobilie-header">
<span class="immobilie-typ-badge">{{ immobilie.typ.label }}</span>
<h2 class="immobilie-adresse">{{ immobilie.adresse }}</h2>
<span class="status-badge {{ immobilie.verfuegbar ? 'status-verfuegbar' : 'status-nicht-verfuegbar' }}">
{{ immobilie.verfuegbar ? 'Verfügbar' : 'Nicht verfügbar' }}
</span>
</div>
<div class="immobilie-preis-box">
<div class="preis-haupt">{{ immobilie.preis|number_format(2, ',', '.') }} €</div>
<div class="preis-pro-qm">{{ immobilie.preisProQm|number_format(2, ',', '.') }} € pro m²</div>
</div>
<h3>Hauptmerkmale</h3>
<div class="details-grid">
<div class="detail-box">
<div class="detail-label">Wohnfläche</div>
<div class="detail-value">{{ immobilie.flaeche }} m²</div>
</div>
<div class="detail-box">
<div class="detail-label">Zimmer</div>
<div class="detail-value">{{ immobilie.zimmer }}</div>
</div>
{% if immobilie.baujahr %}
<div class="detail-box">
<div class="detail-label">Baujahr</div>
<div class="detail-value">{{ immobilie.baujahr }}</div>
</div>
{% endif %}
{% if immobilie.etage is not null %}
<div class="detail-box">
<div class="detail-label">Etage</div>
<div class="detail-value">{{ immobilie.etage }}</div>
</div>
{% endif %}
</div>
<h3>Ausstattung & Extras</h3>
<ul class="features-list">
<li>
<span class="feature-icon">🚗</span>
<strong>Garage:</strong> {{ immobilie.garage ? 'Ja' : 'Nein' }}
</li>
{% if immobilie.balkonFlaeche %}
<li>
<span class="feature-icon">🌿</span>
<strong>Balkon:</strong> {{ immobilie.balkonFlaeche }}
</li>
{% endif %}
{% if immobilie.kellerFlaeche %}
<li>
<span class="feature-icon">📦</span>
<strong>Keller:</strong> {{ immobilie.kellerFlaeche }}
</li>
{% endif %}
{% if immobilie.heizungstyp %}
<li>
<span class="feature-icon">🔥</span>
<strong>Heizung:</strong> {{ immobilie.heizungstyp }}
</li>
{% endif %}
{% if immobilie.nebenkosten %}
<li>
<span class="feature-icon">💶</span>
<strong>Nebenkosten:</strong> {{ immobilie.nebenkosten|number_format(2, ',', '.') }} € / Monat
</li>
{% endif %}
<li>
<span class="feature-icon">📊</span>
<strong>Gesamtfläche:</strong> {{ immobilie.gesamtflaeche }}
</li>
</ul>
{% if immobilie.beschreibung %}
<h3>Beschreibung</h3>
<div class="beschreibung-box">
{{ immobilie.beschreibung|nl2br }}
</div>
{% endif %}
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
Erstellt am: {{ immobilie.createdAt|date('d.m.Y H:i') }} Uhr<br>
Zuletzt aktualisiert: {{ immobilie.updatedAt|date('d.m.Y H:i') }} Uhr
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,103 @@
{% extends 'base.html.twig' %}
{% block title %}Immobiliensuche - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<style>
.search-box {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
margin: 20px 0;
}
.api-links {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
margin-top: 20px;
}
.api-link-card {
background: #f9f9f9;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #4CAF50;
}
.api-link-card h4 {
margin: 0 0 10px 0;
color: #4CAF50;
}
.api-link-card a {
color: #4CAF50;
text-decoration: none;
word-break: break-all;
}
.api-link-card a:hover {
text-decoration: underline;
}
.api-link-card p {
color: #666;
font-size: 14px;
margin: 10px 0;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4CAF50;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block body %}
<a href="{{ path('app_immobilie_index') }}" class="back-link">← Zurück zur Übersicht</a>
<div class="search-box">
<h2>Immobiliensuche über API</h2>
<p>Nutzen Sie die REST-API für erweiterte Suchfunktionen. Hier sind einige nützliche Endpoints:</p>
<div class="api-links">
<div class="api-link-card">
<h4>Alle Immobilien</h4>
<a href="/api/immobilies">
/api/immobilies
</a>
<p>Liste aller Immobilien (JSON-LD Format)</p>
</div>
<div class="api-link-card">
<h4>API-Dokumentation</h4>
<a href="/api">/api</a>
<p>Vollständige API-Dokumentation mit allen verfügbaren Operationen</p>
</div>
<div class="api-link-card">
<h4>Einzelne Immobilie</h4>
<a href="/api/immobilies/1">
/api/immobilies/{id}
</a>
<p>Details zu einer bestimmten Immobilie</p>
</div>
</div>
<h3 style="margin-top: 40px;">Beispiel-Abfragen</h3>
<div class="api-link-card" style="margin-top: 20px;">
<h4>Repository-Methoden (Backend)</h4>
<p>Das ImmobilieRepository bietet folgende Suchmethoden:</p>
<ul>
<li><code>findVerfuegbare()</code> - Nur verfügbare Immobilien</li>
<li><code>findByTyp(ImmobilienTyp)</code> - Nach Typ filtern</li>
<li><code>findByPreisRange($min, $max)</code> - Preisspanne</li>
<li><code>findByFlaecheRange($min, $max)</code> - Flächenbereich</li>
<li><code>findMitGarage()</code> - Immobilien mit Garage</li>
<li><code>findByMinZimmer($anzahl)</code> - Mindestanzahl Zimmer</li>
<li><code>searchByAdresse($search)</code> - Adresssuche</li>
</ul>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ApiDocumentationTest extends WebTestCase
{
public function testSwaggerUIAccessible(): void
{
$client = static::createClient();
// Test: Swagger UI ist öffentlich zugänglich
$client->request('GET', '/api/docs.html');
$this->assertResponseIsSuccessful();
}
public function testOpenAPIJsonLdAccessible(): void
{
$client = static::createClient();
// Test: OpenAPI JSON-LD ist öffentlich zugänglich
$client->request('GET', '/api/docs.jsonld');
$this->assertResponseIsSuccessful();
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class BundeslandApiTest extends WebTestCase
{
public function testGetBundeslaenderPublicAccess(): void
{
$client = static::createClient();
// Test: Bundesländer können ohne API-Key abgerufen werden
$client->request('GET', '/api/bundeslands');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
public function testGetSingleBundeslandPublicAccess(): void
{
$client = static::createClient();
// Test: Einzelnes Bundesland kann ohne API-Key abgerufen werden
$client->request('GET', '/api/bundeslands/1');
// Response kann 200 (OK) oder 404 (Not Found) sein, beides ist akzeptabel
$this->assertResponseStatusCodeSame(200);
}
public function testCreateBundeslandRequiresAuthentication(): void
{
$client = static::createClient();
// Test: Bundesland erstellen ohne API-Key sollte fehlschlagen
$client->request(
'POST',
'/api/bundeslands',
[],
[],
['CONTENT_TYPE' => 'application/json'],
json_encode([
'name' => 'Test Bundesland',
'grunderwerbsteuer' => 5.0,
])
);
$this->assertResponseStatusCodeSame(403); // Access Denied (no authentication on this firewall)
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HeizungstypApiTest extends WebTestCase
{
public function testGetHeizungstypenPublicAccess(): void
{
$client = static::createClient();
// Test: Heizungstypen können ohne API-Key abgerufen werden
$client->request('GET', '/api/heizungstyps');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
public function testGetSingleHeizungstypPublicAccess(): void
{
$client = static::createClient();
// Test: Einzelner Heizungstyp kann ohne API-Key abgerufen werden
$client->request('GET', '/api/heizungstyps/1');
// Response kann 200 (OK) oder 404 (Not Found) sein
$this->assertResponseStatusCodeSame(200);
}
public function testCreateHeizungstypRequiresAuthentication(): void
{
$client = static::createClient();
// Test: Heizungstyp erstellen ohne API-Key sollte fehlschlagen
$client->request(
'POST',
'/api/heizungstyps',
[],
[],
['CONTENT_TYPE' => 'application/json'],
json_encode([
'name' => 'Test Heizung',
])
);
$this->assertResponseStatusCodeSame(401); // Unauthorized
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bundesland;
use PHPUnit\Framework\TestCase;
class BundeslandTest extends TestCase
{
public function testBundeslandCreation(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Bayern');
$bundesland->setGrunderwerbsteuer(3.5);
$this->assertEquals('Bayern', $bundesland->getName());
$this->assertEquals(3.5, $bundesland->getGrunderwerbsteuer());
}
public function testGrunderwerbsteuerValues(): void
{
$testCases = [
['Baden-Württemberg', 5.0],
['Bayern', 3.5],
['Berlin', 6.0],
['Brandenburg', 6.5],
['Bremen', 5.0],
['Hamburg', 5.5],
['Hessen', 6.0],
['Mecklenburg-Vorpommern', 6.0],
['Niedersachsen', 5.0],
['Nordrhein-Westfalen', 6.5],
['Rheinland-Pfalz', 5.0],
['Saarland', 6.5],
['Sachsen', 5.5],
['Sachsen-Anhalt', 5.0],
['Schleswig-Holstein', 6.5],
['Thüringen', 5.0],
];
foreach ($testCases as [$name, $steuer]) {
$bundesland = new Bundesland();
$bundesland->setName($name);
$bundesland->setGrunderwerbsteuer($steuer);
$this->assertEquals($name, $bundesland->getName());
$this->assertEquals($steuer, $bundesland->getGrunderwerbsteuer());
}
}
public function testMinMaxGrunderwerbsteuer(): void
{
// Niedrigster Satz: Bayern mit 3.5%
$bayern = new Bundesland();
$bayern->setName('Bayern');
$bayern->setGrunderwerbsteuer(3.5);
$this->assertEquals(3.5, $bayern->getGrunderwerbsteuer());
// Höchster Satz: Brandenburg, NRW, Saarland, SH mit 6.5%
$nrw = new Bundesland();
$nrw->setName('Nordrhein-Westfalen');
$nrw->setGrunderwerbsteuer(6.5);
$this->assertEquals(6.5, $nrw->getGrunderwerbsteuer());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Heizungstyp;
use PHPUnit\Framework\TestCase;
class HeizungstypTest extends TestCase
{
public function testHeizungstypCreation(): void
{
$heizungstyp = new Heizungstyp();
$heizungstyp->setName('Wärmepumpe');
$this->assertEquals('Wärmepumpe', $heizungstyp->getName());
}
public function testCommonHeizungstypen(): void
{
$typen = ['Gasheizung', 'Wärmepumpe', 'Ölheizung', 'Fernwärme', 'Pelletheizung'];
foreach ($typen as $typName) {
$heizungstyp = new Heizungstyp();
$heizungstyp->setName($typName);
$this->assertEquals($typName, $heizungstyp->getName());
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Bundesland;
use App\Entity\Immobilie;
use App\Entity\User;
use App\Enum\ImmobilienTyp;
use PHPUnit\Framework\TestCase;
class ImmobilieTest extends TestCase
{
private User $verwalter;
protected function setUp(): void
{
$this->verwalter = new User();
$this->verwalter->setName('Test Verwalter');
$this->verwalter->setEmail('verwalter@example.com');
}
public function testImmobilieCreation(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Teststraße 123, 12345 Teststadt');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(20);
$immobilie->setZimmer(4);
$immobilie->setTyp(ImmobilienTyp::WOHNUNG);
$this->assertEquals('Teststraße 123, 12345 Teststadt', $immobilie->getAdresse());
$this->assertEquals(100, $immobilie->getWohnflaeche());
$this->assertEquals(20, $immobilie->getNutzflaeche());
$this->assertEquals(4, $immobilie->getZimmer());
$this->assertEquals(ImmobilienTyp::WOHNUNG, $immobilie->getTyp());
$this->assertEquals($this->verwalter, $immobilie->getVerwalter());
}
public function testGesamtflaecheCalculation(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(85);
$immobilie->setNutzflaeche(15);
$immobilie->setZimmer(3);
$this->assertEquals(100, $immobilie->getGesamtflaeche());
}
public function testKaufnebenkostenWithoutBundesland(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(300000);
$kosten = $immobilie->getKaufnebenkosten();
$this->assertEquals(0, $kosten['notar']);
$this->assertEquals(0, $kosten['grundbuch']);
$this->assertEquals(0, $kosten['grunderwerbsteuer']);
$this->assertEquals(0, $kosten['gesamt']);
}
public function testKaufnebenkostenWithBundesland(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Bayern');
$bundesland->setGrunderwerbsteuer(3.5);
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(300000);
$immobilie->setBundesland($bundesland);
$kosten = $immobilie->getKaufnebenkosten();
// Notar: 1.5% von 300000 = 4500
$this->assertEquals(4500, $kosten['notar']);
// Grundbuch: 0.5% von 300000 = 1500
$this->assertEquals(1500, $kosten['grundbuch']);
// Grunderwerbsteuer: 3.5% von 300000 = 10500
$this->assertEqualsWithDelta(10500, $kosten['grunderwerbsteuer'], 0.01);
// Gesamt: 4500 + 1500 + 10500 = 16500
$this->assertEquals(16500, $kosten['gesamt']);
}
public function testKaufnebenkostenWithDifferentBundesland(): void
{
$bundesland = new Bundesland();
$bundesland->setName('Nordrhein-Westfalen');
$bundesland->setGrunderwerbsteuer(6.5);
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setKaufpreis(400000);
$immobilie->setBundesland($bundesland);
$kosten = $immobilie->getKaufnebenkosten();
// Notar: 1.5% von 400000 = 6000
$this->assertEquals(6000, $kosten['notar']);
// Grundbuch: 0.5% von 400000 = 2000
$this->assertEquals(2000, $kosten['grundbuch']);
// Grunderwerbsteuer: 6.5% von 400000 = 26000
$this->assertEquals(26000, $kosten['grunderwerbsteuer']);
// Gesamt: 6000 + 2000 + 26000 = 34000
$this->assertEquals(34000, $kosten['gesamt']);
}
public function testDefaultValues(): void
{
$immobilie = new Immobilie();
$this->assertEquals(ImmobilienTyp::WOHNUNG, $immobilie->getTyp());
$this->assertEquals(0, $immobilie->getNutzflaeche());
$this->assertFalse($immobilie->getGarage());
$this->assertNotNull($immobilie->getCreatedAt());
$this->assertNotNull($immobilie->getUpdatedAt());
}
public function testOptionalFields(): void
{
$immobilie = new Immobilie();
$immobilie->setVerwalter($this->verwalter);
$immobilie->setAdresse('Test');
$immobilie->setWohnflaeche(100);
$immobilie->setNutzflaeche(0);
$immobilie->setZimmer(3);
$immobilie->setBaujahr(2020);
$immobilie->setEtage(3);
$immobilie->setAbschreibungszeit(50);
$immobilie->setBeschreibung('Schöne Wohnung');
$this->assertEquals(2020, $immobilie->getBaujahr());
$this->assertEquals(3, $immobilie->getEtage());
$this->assertEquals(50, $immobilie->getAbschreibungszeit());
$this->assertEquals('Schöne Wohnung', $immobilie->getBeschreibung());
}
}

92
tests/Entity/UserTest.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace App\Tests\Entity;
use App\Entity\User;
use App\Enum\UserRole;
use PHPUnit\Framework\TestCase;
class UserTest extends TestCase
{
public function testUserCreation(): void
{
$user = new User();
$user->setName('Max Mustermann');
$user->setEmail('max@example.com');
$user->setRole(UserRole::USER);
$this->assertEquals('Max Mustermann', $user->getName());
$this->assertEquals('max@example.com', $user->getEmail());
$this->assertEquals(UserRole::USER, $user->getRole());
$this->assertNotNull($user->getApiKey());
$this->assertNotNull($user->getCreatedAt());
}
public function testApiKeyGeneration(): void
{
$user = new User();
$user->setName('Test User');
$user->setEmail('test@example.com');
$apiKey = $user->getApiKey();
$this->assertNotEmpty($apiKey);
$this->assertEquals(64, strlen($apiKey)); // SHA256 hash length
}
public function testRegenerateApiKey(): void
{
$user = new User();
$user->setName('Test User');
$user->setEmail('test@example.com');
$originalKey = $user->getApiKey();
$user->regenerateApiKey();
$newKey = $user->getApiKey();
$this->assertNotEquals($originalKey, $newKey);
$this->assertEquals(64, strlen($newKey));
}
public function testUserRoles(): void
{
// Test USER role
$user = new User();
$user->setRole(UserRole::USER);
$this->assertContains('ROLE_USER', $user->getRoles());
// Test ADMIN role
$admin = new User();
$admin->setRole(UserRole::ADMIN);
$this->assertContains('ROLE_ADMIN', $admin->getRoles());
$this->assertContains('ROLE_USER', $admin->getRoles());
// Test TECHNICAL role
$technical = new User();
$technical->setRole(UserRole::TECHNICAL);
$this->assertContains('ROLE_TECHNICAL', $technical->getRoles());
$this->assertContains('ROLE_USER', $technical->getRoles());
// Test MODERATOR role
$moderator = new User();
$moderator->setRole(UserRole::MODERATOR);
$this->assertContains('ROLE_MODERATOR', $moderator->getRoles());
$this->assertContains('ROLE_USER', $moderator->getRoles());
}
public function testUserIdentifier(): void
{
$user = new User();
$user->setEmail('identifier@example.com');
$this->assertEquals('identifier@example.com', $user->getUserIdentifier());
}
public function testDefaultRole(): void
{
$user = new User();
// Default role should be USER
$this->assertEquals(UserRole::USER, $user->getRole());
}
}

13
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}