Compare commits

...

16 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
cba9aef518 init 2025-11-08 18:22:15 +01:00
7b7ca33781 init 2025-11-08 18:22:00 +01:00
90 changed files with 17499 additions and 42 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[{compose.yaml,compose.*.yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

40
.env Normal file
View File

@@ -0,0 +1,40 @@
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
# https://symfony.com/doc/current/configuration/secrets.html
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
###< symfony/framework-bundle ###
###> symfony/routing ###
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
DEFAULT_URI=http://localhost
###< symfony/routing ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="mysql://immorechner_user:immorechner_pass@db:3306/immorechner?serverVersion=mariadb-11.7.1&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

View File

@@ -1,11 +1,12 @@
# Database Configuration # Symfony Application Configuration
DB_HOST=db APP_ENV=dev
DB_PORT=3306 APP_SECRET=change_this_to_a_random_secret_key
DB_DATABASE=immorechner
DB_USERNAME=immorechner_user
DB_PASSWORD=immorechner_pass
DB_ROOT_PASSWORD=root
# Application Configuration # Database Configuration (MariaDB)
APP_ENV=local DATABASE_URL="mysql://immorechner_user:immorechner_pass@db:3306/immorechner?serverVersion=mariadb-11.7.1&charset=utf8mb4"
APP_DEBUG=true
# CORS Configuration
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
# Routing Configuration
DEFAULT_URI=http://localhost

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

29
.gitignore vendored
View File

@@ -6,9 +6,11 @@
.idea_modules/ .idea_modules/
out/ out/
# Claude AI
.claude/
# Composer # Composer
/vendor/ /vendor/
composer.lock
composer.phar composer.phar
# PHP # PHP
@@ -18,14 +20,17 @@ composer.phar
php_errors.log php_errors.log
# Environment files # Environment files
.env
.env.local .env.local
.env.*.local .env.*.local
.env.dev
.env.test
.env.prod
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
desktop.ini desktop.ini
nul
# Temporary files # Temporary files
*.tmp *.tmp
@@ -69,3 +74,23 @@ clover.xml
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
.dockerignore .dockerignore
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
/.env.*.local
/config/secrets/prod/prod.decrypt.private.php
/public/bundles/
/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,38 +1,57 @@
FROM php:8.3-apache # Production-ready Symfony Dockerfile
FROM php:8.4-apache
# Install system dependencies # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
libpng-dev \ libpng-dev \
libonig-dev \ libonig-dev \
libxml2-dev \ libxml2-dev \
zip \ libicu-dev \
unzip \
libzip-dev \ libzip-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/* git \
curl \
unzip \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions # Install PHP extensions needed by Symfony
RUN docker-php-ext-install pdo_mysql mysqli mbstring exif pcntl bcmath gd zip RUN docker-php-ext-configure intl \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
intl \
opcache
# Enable Apache mod_rewrite # Enable Apache modules
RUN a2enmod rewrite RUN a2enmod rewrite headers
# Enable Apache headers module # Copy Apache configuration
RUN a2enmod headers
# Copy custom Apache configuration
COPY ./docker/apache/000-default.conf /etc/apache2/sites-available/000-default.conf COPY ./docker/apache/000-default.conf /etc/apache2/sites-available/000-default.conf
# Install Composer # Copy Composer from official image
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Set working directory # Set working directory
WORKDIR /var/www/html 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 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 port 80
EXPOSE 80 EXPOSE 80

154
README.md Normal file
View File

@@ -0,0 +1,154 @@
# Immorechner
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
### 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)
### 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
### 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
## Dokumentation
📚 **Detaillierte Dokumentation:**
- **[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/
├── config/ # Symfony Konfiguration
├── docs/ # Dokumentation
├── migrations/ # Datenbank-Migrationen
├── public/ # Web-Root, CSS, JS, Assets
├── src/ # PHP-Quellcode
│ ├── Controller/ # Controller (Frontend & API)
│ ├── Entity/ # Doctrine Entities
│ ├── Repository/ # Doctrine Repositories
│ └── Security/ # Authentifizierung
├── templates/ # Twig-Templates
├── docker-compose.yml # Docker Compose-Konfiguration
└── Dockerfile # Docker-Image für PHP/Apache
```
## Verwendung
### Frontend
**Startseite (Rechner):** http://localhost:8080
- Immobiliendaten eingeben
- Live-Berechnungen ansehen
- Link zum Teilen generieren
- Bei Anmeldung: Immobilie speichern
**Registrierung:** http://localhost:8080/register
**Login:** http://localhost:8080/login
**Meine Immobilien:** http://localhost:8080/meine-immobilien (nach Login)
### API
**API-Dokumentation (Swagger UI):** http://localhost:8080/api/docs.html
**API Entrypoint:** http://localhost:8080/api
**Öffentliche Endpunkte (ohne Authentifizierung):**
- `GET /api/bundeslands` - Alle Bundesländer
- `GET /api/heizungstyps` - Alle Heizungstypen
**Geschützte Endpunkte (API-Key erforderlich):**
- `/api/immobilies` - Immobilien-Management
- `/api/users` - Benutzer-Management
## Schnelle Befehle
```bash
# Container starten
docker-compose up -d
# Container stoppen
docker-compose down
# Logs anzeigen
docker-compose logs -f
# In Container einloggen
docker-compose exec web bash
# Cache leeren
docker-compose exec web php bin/console cache:clear
# Tests ausführen
docker-compose exec web php bin/phpunit
```
## Sicherheitshinweise
**⚠️ Wichtig für Produktion:**
1. Ändern Sie alle Standardpasswörter in `docker-compose.yml` und `.env`
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]
## Support
Bei Problemen siehe [Fehlerbehebung](docs/troubleshooting.md) oder öffnen Sie ein Issue.

21
bin/console Normal file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) {
throw new LogicException('Dependencies are missing. Try running "composer install".');
}
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
}
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};

4
bin/phpunit Normal file
View File

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

55
composer.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "immorechner/app",
"type": "project",
"minimum-stability": "stable",
"require": {
"php": ">=8.4",
"symfony/runtime": "^7.3",
"symfony/dotenv": "^7.3",
"symfony/flex": "^2.9",
"symfony/framework-bundle": "^7.3",
"symfony/yaml": "^7.3",
"doctrine/orm": "^3.5",
"doctrine/doctrine-bundle": "^3.0",
"doctrine/doctrine-migrations-bundle": "^3.6",
"api-platform/doctrine-orm": "^4.2",
"api-platform/symfony": "^4.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"symfony/asset": "^7.3",
"symfony/expression-language": "^7.3",
"symfony/security-bundle": "^7.3",
"phpstan/phpdoc-parser": "^2.3",
"symfony/property-access": "^7.3",
"symfony/property-info": "^7.3",
"symfony/serializer": "^7.3",
"symfony/twig-bundle": "^7.3",
"symfony/validator": "^7.3"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"config": {
"allow-plugins": {
"symfony/runtime": true,
"symfony/flex": true
},
"platform": {
"php": "8.4"
}
},
"scripts": {
"auto-scripts": {
"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"
}
}

10720
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

11
config/bundles.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
];

View File

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,19 @@
framework:
cache:
# Unique name of your app: used to compute stable namespaces for cache keys.
#prefix_seed: your_vendor_name/app_name
# The "app" cache stores to the filesystem by default.
# The data in this cache should persist between deploys.
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default
#pools:
#my.dedicated.cache: null

View File

@@ -0,0 +1,48 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,15 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
#esi: true
#fragments: true
when@test:
framework:
test: true
session:
storage_factory_id: session.storage.factory.mock_file

View File

@@ -0,0 +1,10 @@
nelmio_cors:
defaults:
origin_regex: true
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null

View File

@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -0,0 +1,10 @@
framework:
router:
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
default_uri: '%env(DEFAULT_URI)%'
when@prod:
framework:
router:
strict_requirements: null

View File

@@ -0,0 +1,71 @@
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:
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: 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:
# 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:
password_hashers:
# By default, password hashers are resource intensive and take time. This is
# important to generate secure password hashes. In tests however, secure hashes
# are not important, waste resources and increase test times. The following
# reduces the work factor to the lowest possible values.
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: auto
cost: 4 # Lowest possible value for bcrypt
time_cost: 3 # Lowest possible value for argon
memory_cost: 10 # Lowest possible value for argon

View File

@@ -0,0 +1,6 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -0,0 +1,11 @@
framework:
validation:
# Enables validator auto-mapping support.
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
#auto_mapping:
# App\Entity\: []
when@test:
framework:
validation:
not_compromised_password: false

5
config/preload.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}

5
config/routes.yaml Normal file
View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -0,0 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@@ -0,0 +1,4 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View File

@@ -0,0 +1,3 @@
_security_logout:
resource: security.route_loader.logout
type: service

34
config/services.yaml Normal file
View File

@@ -0,0 +1,34 @@
# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as 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

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
web: web:
build: build:

View File

@@ -1,22 +1,20 @@
<VirtualHost *:80> <VirtualHost *:80>
ServerAdmin webmaster@localhost ServerAdmin webmaster@localhost
DocumentRoot /var/www/html DocumentRoot /var/www/html/public
<Directory /var/www/html> <Directory /var/www/html/public>
Options Indexes FollowSymLinks Options -Indexes +FollowSymLinks
AllowOverride All AllowOverride All
Require all granted Require all granted
FallbackResource /index.php
</Directory> </Directory>
# Enable mod_rewrite # Symfony recommended: disable .htaccess if using FallbackResource
RewriteEngine On <Directory /var/www/html/public/bundles>
FallbackResource disabled
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined CustomLog ${APACHE_LOG_DIR}/access.log combined
# Optional: Disable directory listing
<Directory /var/www/html>
Options -Indexes +FollowSymLinks
</Directory>
</VirtualHost> </VirtualHost>

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)

0
migrations/.gitignore vendored Normal file
View File

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>

66
public/.htaccess Normal file
View File

@@ -0,0 +1,66 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeeScript assets.
# Options +FollowSymlinks
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

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

9
public/index.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

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();
});

0
src/ApiResource/.gitignore vendored Normal file
View File

0
src/Controller/.gitignore vendored Normal file
View File

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

@@ -0,0 +1,46 @@
<?php
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(
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', [
'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);
}
}

0
src/Entity/.gitignore vendored Normal file
View File

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,
];
}
}

210
src/Entity/User.php Normal file
View File

@@ -0,0 +1,210 @@
<?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\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)]
#[ORM\Table(name: 'users')]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Delete(),
]
)]
class User implements UserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
private string $name;
#[ORM\Column(type: 'string', length: 255, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email;
#[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
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getRole(): UserRole
{
return $this->role;
}
public function setRole(UserRole $role): self
{
$this->role = $role;
return $this;
}
public function getCreatedAt(): \DateTimeInterface
{
return $this->createdAt;
}
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',
};
}
}

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

@@ -0,0 +1,21 @@
<?php
namespace App\Enum;
enum UserRole: string
{
case USER = 'user';
case ADMIN = 'admin';
case MODERATOR = 'moderator';
case TECHNICAL = 'technical';
public function getLabel(): string
{
return match ($this) {
self::USER => 'Benutzer',
self::ADMIN => 'Administrator',
self::MODERATOR => 'Moderator',
self::TECHNICAL => 'Technischer User',
};
}
}

11
src/Kernel.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}

0
src/Repository/.gitignore vendored Normal file
View File

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

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* Find users by role.
*/
public function findByRole(string $role): array
{
return $this->createQueryBuilder('u')
->andWhere('u.role = :role')
->setParameter('role', $role)
->orderBy('u.createdAt', 'DESC')
->getQuery()
->getResult();
}
/**
* Find user by email.
*/
public function findOneByEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->andWhere('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
}
}

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

208
symfony.lock Normal file
View File

@@ -0,0 +1,208 @@
{
"api-platform/symfony": {
"version": "4.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"./config/packages/api_platform.yaml",
"./config/routes/api_platform.yaml",
"./src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "3.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.0",
"ref": "86b1fbac469b8b1c05e5ff28a7a2cffcaacf5068"
},
"files": [
"./config/packages/doctrine.yaml",
"./src/Entity/.gitignore",
"./src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.6",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"./config/packages/doctrine_migrations.yaml",
"./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": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.5",
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
},
"files": [
"./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": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.3",
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
},
"files": [
"./bin/console"
]
},
"symfony/flex": {
"version": "2.9",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
"./.env",
"./.env.dev"
]
},
"symfony/framework-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5a1497d539f691b96afd45ae397ce5fe30beb4b9"
},
"files": [
"./config/packages/cache.yaml",
"./config/packages/framework.yaml",
"./config/preload.php",
"./config/routes/framework.yaml",
"./config/services.yaml",
"./public/index.php",
"./src/Controller/.gitignore",
"./src/Kernel.php",
"./.editorconfig"
]
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"./config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "ab1e60e2afd5c6f4a6795908f646e235f2564eb2"
},
"files": [
"./config/packages/routing.yaml",
"./config/routes.yaml"
]
},
"symfony/security-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "2ae08430db28c8eb4476605894296c82a642028f"
},
"files": [
"./config/packages/security.yaml",
"./config/routes/security.yaml"
]
},
"symfony/twig-bundle": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
},
"files": [
"./config/packages/twig.yaml",
"./templates/base.html.twig"
]
},
"symfony/uid": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
}
},
"symfony/validator": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.0",
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
},
"files": [
"./config/packages/validator.yaml"
]
}
}

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 %}

81
templates/base.html.twig Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Immorechner{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block stylesheets %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background-color: #4CAF50;
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
header h1 {
margin: 0;
padding: 0 20px;
}
main {
margin-top: 20px;
}
footer {
margin-top: 50px;
padding: 20px;
text-align: center;
color: #666;
border-top: 1px solid #ddd;
}
</style>
{% endblock %}
</head>
<body>
<header>
<div class="container">
<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>
<main>
<div class="container">
{% block body %}{% endblock %}
</div>
</main>
<footer>
<div class="container">
<p>&copy; {{ "now"|date("Y") }} Immorechner - Powered by Symfony</p>
</div>
</footer>
{% block javascripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,167 @@
{% extends 'base.html.twig' %}
{% block title %}Immobilienwert berechnen - {{ parent() }}{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="{{ asset('css/calculator.css') }}">
{% endblock %}
{% block body %}
<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="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>
<div class="results-section">
<h2>Berechnungsergebnisse</h2>
<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);
}