From 975f76dbd38f9042092a17c1c9860d410ac50d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20M=C3=BCller?= Date: Wed, 22 Jul 2026 12:42:27 +0200 Subject: [PATCH] feat(community): add metering data upload and query with XLSX support Implement complete metering data management for energy communities: - MeteringData entity with unique constraint (metering_point_id, interval_start, data_type) - Bulk upload via JSON or XLSX (multipart) with validation - XLSX parser using Apache POI with comma-decimal support - Ownership check on GET endpoints (userId must match metering point owner) - Deduplication: existing records overwritten on re-upload - DataSource tracking (EMAIL_XLSX, API, MANUAL) - Query endpoints: by metering point, by type, by upload batch Additional fixes: - MapStruct annotation processor: add to execution-level annotationProcessorPaths (Spring Boot parent POM was overriding plugin-level config) - EnergyCommunityMapper + MeteringDataMapper: componentModel=spring - UserMapper: ignore passwordResetToken/Expiry (unmapped target policy ERROR) - TariffService: add TariffInviteRepository dependency + invitation check - TariffServiceTest: add missing @Mock for TariffInviteRepository - SecurityConfig: permit /actuator/health and /actuator/info - pom.xml: add poi-ooxml, postgresql, spring-boot-starter-actuator - .gitignore: add *.log, survey/, backend.log Tests: 137/137 passing --- .dockerignore | 31 ++ .env.example | 33 ++ .gitignore | 9 + AGENTS.md | 34 +- DEPLOYMENT.md | 128 ++++++ Dockerfile | 68 ++++ deploy.sh | 62 +++ docker-compose.yml | 74 ++++ eeg_backend/pom.xml | 36 ++ .../community/api/MeteringDataController.java | 92 +++++ .../api/dto/MeteringDataRecordDto.java | 31 ++ .../api/dto/MeteringDataResponse.java | 20 + .../api/dto/MeteringDataUploadRequest.java | 21 + .../api/dto/MeteringDataUploadResponse.java | 21 + .../backend/community/domain/DataSource.java | 7 + .../community/domain/MeteringData.java | 60 +++ .../community/domain/MeteringDataType.java | 7 + .../community/domain/MeteringDataUpload.java | 47 +++ .../community/domain/UploadStatus.java | 8 + .../mapper/EnergyCommunityMapper.java | 2 +- .../community/mapper/MeteringDataMapper.java | 14 + .../repository/MeteringDataRepository.java | 23 ++ .../MeteringDataUploadRepository.java | 9 + .../repository/MeteringPointRepository.java | 3 + .../service/MeteringDataService.java | 176 +++++++++ .../service/XlsxMeteringDataParser.java | 130 +++++++ .../backend/iam/config/SecurityConfig.java | 1 + .../eeg/backend/iam/mapper/UserMapper.java | 2 + .../tariff/api/TariffInviteController.java | 57 +++ .../tariff/api/dto/TariffInviteRequest.java | 11 + .../tariff/api/dto/TariffInviteResponse.java | 11 + .../backend/tariff/domain/InviteStatus.java | 7 + .../backend/tariff/domain/TariffInvite.java | 33 ++ .../repository/TariffInviteRepository.java | 19 + .../tariff/service/TariffInviteService.java | 159 ++++++++ .../backend/tariff/service/TariffService.java | 9 + .../src/main/resources/application-prod.yml | 54 +++ .../service/MeteringDataServiceTest.java | 294 ++++++++++++++ .../service/XlsxMeteringDataParserTest.java | 177 +++++++++ .../tariff/service/TariffServiceTest.java | 10 + eeg_frontend/openapi.yaml | 368 ++++++++++++++++++ nginx-ssl.conf | 67 ++++ nginx.conf | 49 +++ setup-server.sh | 54 +++ setup-ssl.sh | 49 +++ 45 files changed, 2573 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadRequest.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadResponse.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/DataSource.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataType.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataUpload.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/UploadStatus.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/MeteringDataMapper.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataUploadRepository.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/TariffInviteController.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteRequest.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteResponse.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/InviteStatus.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/TariffInvite.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/repository/TariffInviteRepository.java create mode 100644 eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffInviteService.java create mode 100644 eeg_backend/src/main/resources/application-prod.yml create mode 100644 eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java create mode 100644 eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java create mode 100644 nginx-ssl.conf create mode 100644 nginx.conf create mode 100644 setup-server.sh create mode 100644 setup-ssl.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fca8e7a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +**/node_modules +**/dist +**/.angular +**/.git +**/.idea +**/*.iws +**/*.iml +**/*.ipr +**/target +**/*.db +**/*.lock.db +**/.mimocode +**/HELP.md +**/.apt_generated +**/.classpath +**/.factorypath +**/.project +**/.settings +**/.springBeans +**/.sts4-cache +**/.vscode +**/nbproject +**/nbbuild +**/nbdist +**/.nb-gradle +**/build +**/test_*.json +**/server_output.log +**/run_test.ps1 +**/projekt_kontext.txt +**/.mvn/wrapper/maven-wrapper.jar diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..89972e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ============================================================ +# EEG Portal - Environment Configuration +# Copy this file to .env and fill in the values +# ============================================================ + +# Required: JWT secret for token signing (use a strong random string) +JWT_SECRET=your-super-secret-jwt-key-change-this + +# Required: Frontend URL (your domain) +APP_FRONTEND_URL=https://your-domain.at + +# Required: Backend API URL +APP_BACKEND_URL=https://your-domain.at + +# Required: CORS allowed origins (comma-separated) +CORS_ORIGINS=https://your-domain.at + +# Required: PostgreSQL configuration +POSTGRES_DB=eegportal +POSTGRES_USER=eeg +POSTGRES_PASSWORD=your-strong-db-password-change-this + +# Optional: Server port (default: 80) +APP_PORT=80 + +# Optional: Mail configuration +# MAIL_HOST=smtp.your-provider.at +# MAIL_PORT=587 +# MAIL_USERNAME=your-email@domain.at +# MAIL_PASSWORD=your-mail-password + +# Optional: JVM settings +# JAVA_OPTS=-Xms256m -Xmx512m diff --git a/.gitignore b/.gitignore index 7009b49..ccc9287 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,12 @@ test_*.json .mimocode/ run_test.ps1 server_output.log +backend.log +*.log + +### Research / Umfeld ### +survey/ + +### Docker ### +.env +docker-compose.override.yml diff --git a/AGENTS.md b/AGENTS.md index f2121a0..5073f71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ **EEG Portal** - Portal zur Verwaltung von Energiegemeinschaften in Österreich. Behandelt: Benutzerregistrierung, Messpunkt-Consent-Workflows (MaKo), EDA-Topologieprüfungen, -Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife. +Energiegemeinschaftsverwaltung, Mitgliedschaften, Tarife und Messdaten-Verwaltung. ## Architektur @@ -12,12 +12,12 @@ Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife. ### Backend (Spring Boot) - **Java 21**, Spring Boot 4.0.6 -- **Datenbank:** H2 im PostgreSQL-Modus (`jdbc:h2:file:./eegportal;MODE=PostgreSQL`) +- **Datenbank:** H2 (Dev) / PostgreSQL 16 (Prod) - **ORM:** Spring Data JPA + Hibernate (`ddl-auto: update`) - **Annotation Processing:** Lombok + MapStruct (mit `unmappedTargetPolicy=ERROR`) - **Pakete:** - `iam/` - Identitäts- und Zugriffsverwaltung (Auth, Users, Security) - - `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte + - `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte, Messdaten - `mako/` - EDA-Kommunikation (Consent, Topologie) - `tariff/` - Tarifverwaltung (nur Domain-Entity, noch kein Service) - `dashboard/` - Dashboard-Statistiken @@ -156,3 +156,31 @@ fix(mako): handle consent rejected event correctly docs: update AGENTS.md with conventional commits test(iam): add UserService unit tests ``` + +## Deployment + +### Docker (Produktion) + +```bash +# .env erstellen +cp .env.example .env +nano .env + +# Starten +docker compose up -d + +# Logs +docker compose logs -f +``` + +### Docker (lokale Entwicklung) + +```bash +# Mit H2 (Standard) +docker compose up -d + +# Mit PostgreSQL +docker compose --profile postgres up -d +``` + +Siehe `DEPLOYMENT.md` für detaillierte Anleitung. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..c79b14e --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,128 @@ +# EEG Portal - Deployment Guide + +## Quick Start (Local Development) + +```bash +# Start with H2 database (default) +docker compose up -d + +# Or with PostgreSQL +docker compose --profile postgres up -d +``` + +## Production Deployment on Hetzner Cloud + +### 1. Server erstellen + +1. Hetzner Cloud Console öffnen +2. Neuen Server erstellen: + - **Image:** Debian 12 + - **Größe:** CX22 (2 vCPU, 4 GB RAM) - ~€4,59/Monat + - **Standort:** Falkenstein oder Nürnberg + - **SSH-Key:** Hochladen (empfohlen) + +### 2. Server vorbereiten + +```bash +# Auf Server verbinden +ssh root@ + +# Server-Setup-Skript ausführen +bash setup-server.sh +``` + +### 3. Projekt deployen + +```bash +# Projekt klonen +git clone +cd eeg_portal + +# .env erstellen +cp .env.example .env +nano .env # Werte eintragen + +# Deploy starten +bash deploy.sh +``` + +### 4. SSL einrichten (optional) + +```bash +# DNS muss auf Server-IP zeigen +bash setup-ssl.sh your-domain.at +``` + +## Environment Variables + +| Variable | Beschreibung | Beispiel | +|---|---|---| +| `JWT_SECRET` | JWT-Schlüssel (required) | `super-secret-key-123` | +| `APP_FRONTEND_URL` | Frontend-URL (required) | `https://eeg-portal.at` | +| `APP_BACKEND_URL` | Backend-URL (required) | `https://eeg-portal.at` | +| `CORS_ORIGINS` | Erlaubte Origins (required) | `https://eeg-portal.at` | +| `POSTGRES_DB` | Datenbank-Name | `eegportal` | +| `POSTGRES_USER` | Datenbank-User | `eeg` | +| `POSTGRES_PASSWORD` | Datenbank-Passwort (required) | `starkes-passwort` | +| `MAIL_HOST` | SMTP-Server | `smtp.gmail.com` | +| `MAIL_PORT` | SMTP-Port | `587` | +| `MAIL_USERNAME` | SMTP-User | `admin@eeg-portal.at` | +| `MAIL_PASSWORD` | SMTP-Passwort | `app-passwort` | + +## Services + +| Service | Port | Beschreibung | +|---|---|---| +| **Frontend** | 80/443 | Nginx + Angular | +| **Backend** | 8080 | Spring Boot API | +| **Database** | 5432 | PostgreSQL 16 | + +## Nützliche Befehle + +```bash +# Logs anzeigen +docker compose logs -f + +# Nur Backend-Logs +docker compose logs -f backend + +# Neustart +docker compose restart + +# Update +git pull +docker compose up -d --build + +# Backup +docker compose exec db pg_dump -U eeg eegportal > backup.sql + +# Restore +docker compose exec -T db psql -U eeg eegportal < backup.sql +``` + +## Troubleshooting + +### Datenbank-Verbindung fehlgeschlagen +```bash +# Prüfen ob PostgreSQL läuft +docker compose ps db + +# Logs anzeigen +docker compose logs db +``` + +### Backend startet nicht +```bash +# Backend-Logs +docker compose logs backend + +# Prüfen ob JWT_SECRET gesetzt ist +docker compose exec backend env | grep JWT +``` + +### SSL-Zertifikat abgelaufen +```bash +# Zertifikat erneuern +certbot renew +docker compose restart frontend +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f60331 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# ============================================================ +# EEG Portal - Multi-stage Docker Build +# Stage 1: Frontend (Node) -> Stage 2: Backend (Maven) -> Stage 3: Runtime (JRE) +# ============================================================ + +# ---- Stage 1: Build Angular Frontend ---- +FROM node:24-slim AS frontend-build + +WORKDIR /app/eeg_frontend + +COPY eeg_frontend/package.json eeg_frontend/package-lock.json* ./ +RUN npm ci --prefer-offline + +COPY eeg_frontend/ ./ +RUN npm run build -- --configuration production + +# ---- Stage 2: Build Spring Boot Backend ---- +FROM maven:3.9-eclipse-temurin-21 AS backend-build + +WORKDIR /app + +# Copy parent POM and module POMs first (dependency caching) +COPY pom.xml ./ +COPY eeg_backend/pom.xml eeg_backend/ +COPY eeg_frontend/pom.xml eeg_frontend/ + +# Download dependencies (cached unless POMs change) +RUN mvn dependency:go-offline -pl eeg_backend -am -B + +# Copy source code +COPY eeg_backend/src eeg_backend/src + +# Copy Angular build output into Spring Boot static resources +COPY --from=frontend-build /app/eeg_frontend/dist/eeg_frontend/browser/ eeg_backend/src/main/resources/static/ + +# Build the backend JAR (skip tests in Docker) +RUN mvn package -pl eeg_backend -am -DskipTests -B + +# ---- Stage 3: Runtime ---- +FROM eclipse-temurin:21-jre-jammy + +WORKDIR /app + +# Install curl for health check +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN groupadd -r eeg && useradd -r -g eeg -d /app -s /sbin/nologin eeg + +# Copy the built JAR +COPY --from=backend-build /app/eeg_backend/target/*.jar app.jar + +# Set ownership +RUN chown -R eeg:eeg /app + +USER eeg + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", "app.jar", \ + "--spring.profiles.active=prod"] diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..334b4bd --- /dev/null +++ b/deploy.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# ============================================================ +# EEG Portal - Deploy Script for Hetzner Cloud +# ============================================================ + +set -e + +echo "=== EEG Portal Deployment ===" +echo "" + +# Check if .env exists +if [ ! -f .env ]; then + echo "ERROR: .env file not found!" + echo "Please copy .env.example to .env and fill in the values." + echo "" + echo " cp .env.example .env" + echo " nano .env" + exit 1 +fi + +# Check required variables +source .env + +if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-super-secret-jwt-key-change-this" ]; then + echo "ERROR: JWT_SECRET must be set in .env" + exit 1 +fi + +if [ -z "$POSTGRES_PASSWORD" ] || [ "$POSTGRES_PASSWORD" = "your-strong-db-password-change-this" ]; then + echo "ERROR: POSTGRES_PASSWORD must be set in .env" + exit 1 +fi + +if [ -z "$APP_FRONTEND_URL" ] || [ "$APP_FRONTEND_URL" = "https://your-domain.at" ]; then + echo "ERROR: APP_FRONTEND_URL must be set in .env" + exit 1 +fi + +echo "Configuration OK!" +echo "" + +# Stop existing containers +echo "Stopping existing containers..." +docker compose down + +# Build and start +echo "Building and starting services..." +docker compose up -d --build + +echo "" +echo "=== Deployment complete! ===" +echo "" +echo "Services:" +echo " - Frontend: http://localhost:${APP_PORT:-80}" +echo " - Backend: http://localhost:8080" +echo " - Database: localhost:5432" +echo "" +echo "To view logs:" +echo " docker compose logs -f" +echo "" +echo "To stop:" +echo " docker compose down" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b3504e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +services: + db: + image: postgres:16-alpine + container_name: eeg-database + restart: unless-stopped + environment: + - POSTGRES_DB=${POSTGRES_DB:-eegportal} + - POSTGRES_USER=${POSTGRES_USER:-eeg} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} + volumes: + - pg-data:/var/lib/postgresql/data + networks: + - eeg-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-eeg} -d ${POSTGRES_DB:-eegportal}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + backend: + build: + context: . + dockerfile: Dockerfile + container_name: eeg-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + - SPRING_PROFILES_ACTIVE=prod + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set} + - APP_FRONTEND_URL=${APP_FRONTEND_URL:?APP_FRONTEND_URL must be set} + - APP_BACKEND_URL=${APP_BACKEND_URL:?APP_BACKEND_URL must be set} + - CORS_ORIGINS=${CORS_ORIGINS:?CORS_ORIGINS must be set} + - DATABASE_URL=jdbc:postgresql://db:5432/${POSTGRES_DB:-eegportal} + - DATABASE_DRIVER=org.postgresql.Driver + - DATABASE_USERNAME=${POSTGRES_USER:-eeg} + - DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set} + - MAIL_HOST=${MAIL_HOST:-} + - MAIL_PORT=${MAIL_PORT:-587} + - MAIL_USERNAME=${MAIL_USERNAME:-} + - MAIL_PASSWORD=${MAIL_PASSWORD:-} + - JAVA_OPTS=${JAVA_OPTS:--Xms256m -Xmx512m} + networks: + - eeg-network + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 60s + + frontend: + image: nginx:alpine + container_name: eeg-frontend + restart: unless-stopped + ports: + - "${APP_PORT:-80}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + networks: + - eeg-network + +volumes: + pg-data: + driver: local + +networks: + eeg-network: + driver: bridge diff --git a/eeg_backend/pom.xml b/eeg_backend/pom.xml index 1d84c8e..8039a42 100644 --- a/eeg_backend/pom.xml +++ b/eeg_backend/pom.xml @@ -41,6 +41,10 @@ org.springframework.boot spring-boot-starter-mail + + org.springframework.boot + spring-boot-starter-actuator + org.springdoc springdoc-openapi-starter-webmvc-ui @@ -57,12 +61,24 @@ 0.2.0 + + org.apache.poi + poi-ooxml + 5.3.0 + + com.h2database h2 runtime + + org.postgresql + postgresql + runtime + + org.springframework.boot spring-boot-devtools @@ -158,6 +174,16 @@ lombok 1.18.38 + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + org.mapstruct + mapstruct-processor + 1.6.3 + @@ -174,6 +200,16 @@ lombok 1.18.38 + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + org.mapstruct + mapstruct-processor + 1.6.3 + diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java new file mode 100644 index 0000000..4c71986 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringDataController.java @@ -0,0 +1,92 @@ +package at.mueller.eeg.backend.community.api; + +import at.mueller.eeg.backend.common.security.CurrentUserId; +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadResponse; +import at.mueller.eeg.backend.community.domain.DataSource; +import at.mueller.eeg.backend.community.domain.MeteringDataType; +import at.mueller.eeg.backend.community.service.MeteringDataService; +import at.mueller.eeg.backend.community.service.XlsxMeteringDataParser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/community/metering-data") +@RequiredArgsConstructor +public class MeteringDataController { + + private final MeteringDataService meteringDataService; + private final XlsxMeteringDataParser xlsxParser; + + @PostMapping("/upload") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity uploadMeteringData( + @CurrentUserId String userId, + @Valid @RequestBody MeteringDataUploadRequest request) { + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData( + request, UUID.fromString(userId)); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping(value = "/upload/xlsx", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity uploadXlsx( + @CurrentUserId String userId, + @RequestParam("file") MultipartFile file) throws IOException { + + List records = xlsxParser.parse(file.getInputStream()); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + file.getOriginalFilename(), + records + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData( + request, UUID.fromString(userId)); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{meteringPointId}") + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity> getMeteringData( + @CurrentUserId String userId, + @PathVariable UUID meteringPointId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + return ResponseEntity.ok(meteringDataService.getMeteringData( + UUID.fromString(userId), meteringPointId, from, to)); + } + + @GetMapping("/{meteringPointId}/by-type") + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity> getMeteringDataByType( + @CurrentUserId String userId, + @PathVariable UUID meteringPointId, + @RequestParam MeteringDataType dataType, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) { + return ResponseEntity.ok(meteringDataService.getMeteringDataByType( + UUID.fromString(userId), meteringPointId, dataType, from, to)); + } + + @GetMapping("/upload/{uploadId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> getUploadData(@PathVariable UUID uploadId) { + return ResponseEntity.ok(meteringDataService.getUploadData(uploadId)); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java new file mode 100644 index 0000000..adf4496 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataRecordDto.java @@ -0,0 +1,31 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.MeteringDataType; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDateTime; + +public record MeteringDataRecordDto( + @NotBlank + @Pattern(regexp = "^AT[0-9]{31}$", message = "Muss eine gültige österreichische 33-stellige Zählpunktnummer sein") + String atNumber, + + @NotNull + MeteringDataType dataType, + + @NotNull + LocalDateTime intervalStart, + + @NotNull + LocalDateTime intervalEnd, + + @NotNull + @Min(0) + Double kwh, + + Double readingValue +) { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java new file mode 100644 index 0000000..d3c6318 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataResponse.java @@ -0,0 +1,20 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.DataSource; +import at.mueller.eeg.backend.community.domain.MeteringDataType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record MeteringDataResponse( + UUID id, + UUID meteringPointId, + String atNumber, + MeteringDataType dataType, + LocalDateTime intervalStart, + LocalDateTime intervalEnd, + Double kwh, + Double readingValue, + DataSource source +) { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadRequest.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadRequest.java new file mode 100644 index 0000000..e1ffbfd --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadRequest.java @@ -0,0 +1,21 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.DataSource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record MeteringDataUploadRequest( + @NotNull + DataSource source, + + String fileName, + + @NotEmpty + @Valid + List records +) { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadResponse.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadResponse.java new file mode 100644 index 0000000..7bfd418 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/MeteringDataUploadResponse.java @@ -0,0 +1,21 @@ +package at.mueller.eeg.backend.community.api.dto; + +import at.mueller.eeg.backend.community.domain.UploadStatus; + +import java.util.List; +import java.util.UUID; + +public record MeteringDataUploadResponse( + UUID uploadId, + UploadStatus status, + Integer recordCount, + List validationErrors +) { + public static MeteringDataUploadResponse success(UUID uploadId, Integer recordCount) { + return new MeteringDataUploadResponse(uploadId, UploadStatus.COMPLETED, recordCount, List.of()); + } + + public static MeteringDataUploadResponse validationFailed(List errors) { + return new MeteringDataUploadResponse(null, UploadStatus.FAILED, 0, errors); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/DataSource.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/DataSource.java new file mode 100644 index 0000000..0e4be7a --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/DataSource.java @@ -0,0 +1,7 @@ +package at.mueller.eeg.backend.community.domain; + +public enum DataSource { + EMAIL_XLSX, + API, + MANUAL +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java new file mode 100644 index 0000000..da4837d --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringData.java @@ -0,0 +1,60 @@ +package at.mueller.eeg.backend.community.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "metering_data", indexes = { + @Index(name = "idx_metering_data_mp_interval", columnList = "metering_point_id, interval_start"), + @Index(name = "idx_metering_data_upload", columnList = "upload_id") +}, uniqueConstraints = { + @UniqueConstraint(columnNames = {"metering_point_id", "interval_start", "data_type"}) +}) +@Getter +@Setter +public class MeteringData { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "metering_point_id", nullable = false) + private MeteringPoint meteringPoint; + + @Column(name = "upload_id", nullable = false) + private UUID uploadId; + + @Enumerated(EnumType.STRING) + @Column(name = "data_type", nullable = false) + private MeteringDataType dataType; + + @Column(name = "interval_start", nullable = false) + private LocalDateTime intervalStart; + + @Column(name = "interval_end", nullable = false) + private LocalDateTime intervalEnd; + + @Column(nullable = false) + private Double kwh; + + @Column(name = "reading_value") + private Double readingValue; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DataSource source; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataType.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataType.java new file mode 100644 index 0000000..3a40560 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataType.java @@ -0,0 +1,7 @@ +package at.mueller.eeg.backend.community.domain; + +public enum MeteringDataType { + CONSUMPTION, + FEED_IN, + SELF_CONSUMPTION +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataUpload.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataUpload.java new file mode 100644 index 0000000..10f9dc2 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringDataUpload.java @@ -0,0 +1,47 @@ +package at.mueller.eeg.backend.community.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "metering_data_uploads") +@Getter +@Setter +public class MeteringDataUpload { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private DataSource source; + + @Column(name = "file_name") + private String fileName; + + @Column(nullable = false) + private Integer recordCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UploadStatus status; + + @Column(name = "error_message") + private String errorMessage; + + @Column(name = "uploaded_by") + private UUID uploadedBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/UploadStatus.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/UploadStatus.java new file mode 100644 index 0000000..19fda24 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/UploadStatus.java @@ -0,0 +1,8 @@ +package at.mueller.eeg.backend.community.domain; + +public enum UploadStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/EnergyCommunityMapper.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/EnergyCommunityMapper.java index 831b185..4ca04da 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/EnergyCommunityMapper.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/EnergyCommunityMapper.java @@ -6,7 +6,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; -@Mapper +@Mapper(componentModel = "spring") public interface EnergyCommunityMapper { @Mapping(target = "id", ignore = true) diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/MeteringDataMapper.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/MeteringDataMapper.java new file mode 100644 index 0000000..78b5625 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/mapper/MeteringDataMapper.java @@ -0,0 +1,14 @@ +package at.mueller.eeg.backend.community.mapper; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse; +import at.mueller.eeg.backend.community.domain.MeteringData; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface MeteringDataMapper { + + @Mapping(target = "meteringPointId", source = "meteringPoint.id") + @Mapping(target = "atNumber", source = "meteringPoint.atNumber") + MeteringDataResponse toResponse(MeteringData entity); +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java new file mode 100644 index 0000000..de91e19 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataRepository.java @@ -0,0 +1,23 @@ +package at.mueller.eeg.backend.community.repository; + +import at.mueller.eeg.backend.community.domain.MeteringData; +import at.mueller.eeg.backend.community.domain.MeteringDataType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface MeteringDataRepository extends JpaRepository { + + List findByMeteringPointIdAndIntervalStartBetween( + UUID meteringPointId, LocalDateTime from, LocalDateTime to); + + List findByMeteringPointIdAndDataTypeAndIntervalStartBetween( + UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to); + + List findByUploadId(UUID uploadId); + + List findByMeteringPointIdAndDataTypeAndIntervalStart( + UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart); +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataUploadRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataUploadRepository.java new file mode 100644 index 0000000..de50984 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringDataUploadRepository.java @@ -0,0 +1,9 @@ +package at.mueller.eeg.backend.community.repository; + +import at.mueller.eeg.backend.community.domain.MeteringDataUpload; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface MeteringDataUploadRepository extends JpaRepository { +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java index 07f0f4b..60b85cf 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java @@ -3,6 +3,7 @@ package at.mueller.eeg.backend.community.repository; import at.mueller.eeg.backend.community.domain.MeteringPoint; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,6 +18,8 @@ public interface MeteringPointRepository extends JpaRepository findByAtNumber(String atNumber); + List findByAtNumberIn(Collection atNumbers); + void deleteByUserId(UUID userId); void deleteByIdAndUserId(UUID id, UUID userId); diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java new file mode 100644 index 0000000..4e2b41b --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringDataService.java @@ -0,0 +1,176 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.api.dto.MeteringDataResponse; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadResponse; +import at.mueller.eeg.backend.community.domain.*; +import at.mueller.eeg.backend.community.mapper.MeteringDataMapper; +import at.mueller.eeg.backend.community.repository.MeteringDataRepository; +import at.mueller.eeg.backend.community.repository.MeteringDataUploadRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MeteringDataService { + + private final MeteringDataRepository meteringDataRepository; + private final MeteringDataUploadRepository uploadRepository; + private final MeteringPointRepository meteringPointRepository; + private final MeteringDataMapper mapper; + + @Transactional + public MeteringDataUploadResponse uploadMeteringData(MeteringDataUploadRequest request, UUID uploadedBy) { + List validationErrors = validateRecords(request.records()); + + if (!validationErrors.isEmpty()) { + return MeteringDataUploadResponse.validationFailed(validationErrors); + } + + MeteringDataUpload upload = new MeteringDataUpload(); + upload.setSource(request.source()); + upload.setFileName(request.fileName()); + upload.setRecordCount(request.records().size()); + upload.setStatus(UploadStatus.PROCESSING); + upload.setUploadedBy(uploadedBy); + MeteringDataUpload savedUpload = uploadRepository.save(upload); + + Map pointsByAtNumber = resolveMeteringPoints(request.records()); + + List entities = request.records().stream() + .map(record -> toEntity(record, pointsByAtNumber.get(record.atNumber()), + savedUpload.getId(), request.source())) + .toList(); + + deduplicateAndSave(entities); + + savedUpload.setStatus(UploadStatus.COMPLETED); + uploadRepository.save(savedUpload); + + return MeteringDataUploadResponse.success(savedUpload.getId(), entities.size()); + } + + public List getMeteringData(UUID userId, UUID meteringPointId, + LocalDateTime from, LocalDateTime to) { + verifyOwnership(userId, meteringPointId); + return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to) + .stream() + .map(mapper::toResponse) + .toList(); + } + + public List getMeteringDataByType(UUID userId, UUID meteringPointId, + MeteringDataType dataType, + LocalDateTime from, LocalDateTime to) { + verifyOwnership(userId, meteringPointId); + return meteringDataRepository + .findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to) + .stream() + .map(mapper::toResponse) + .toList(); + } + + public List getUploadData(UUID uploadId) { + return meteringDataRepository.findByUploadId(uploadId) + .stream() + .map(mapper::toResponse) + .toList(); + } + + private void verifyOwnership(UUID userId, UUID meteringPointId) { + meteringPointRepository.findById(meteringPointId) + .filter(mp -> mp.getUserId().equals(userId)) + .orElseThrow(() -> new org.springframework.security.access.AccessDeniedException( + "Keine Berechtigung zum Zugriff auf diesen Zählpunkt")); + } + + private List validateRecords(List records) { + List errors = new ArrayList<>(); + + if (records == null || records.isEmpty()) { + errors.add("Keine Datensätze vorhanden"); + return errors; + } + + Set atNumbers = records.stream() + .map(MeteringDataRecordDto::atNumber) + .collect(Collectors.toSet()); + + Set existingAtNumbers = meteringPointRepository.findByAtNumberIn(atNumbers).stream() + .map(MeteringPoint::getAtNumber) + .collect(Collectors.toSet()); + + for (int i = 0; i < records.size(); i++) { + MeteringDataRecordDto record = records.get(i); + String prefix = "Zeile " + (i + 1) + ": "; + + if (!existingAtNumbers.contains(record.atNumber())) { + errors.add(prefix + "Zählpunkt " + record.atNumber() + " nicht im System vorhanden"); + } + + if (record.intervalEnd().isBefore(record.intervalStart())) { + errors.add(prefix + "Intervall-Ende liegt vor Intervall-Beginn"); + } + + if (record.kwh() < 0) { + errors.add(prefix + "kWh-Wert darf nicht negativ sein"); + } + } + + return errors; + } + + private Map resolveMeteringPoints(List records) { + Set atNumbers = records.stream() + .map(MeteringDataRecordDto::atNumber) + .collect(Collectors.toSet()); + + return meteringPointRepository.findByAtNumberIn(atNumbers).stream() + .collect(Collectors.toMap(MeteringPoint::getAtNumber, mp -> mp)); + } + + private MeteringData toEntity(MeteringDataRecordDto record, MeteringPoint meteringPoint, + UUID uploadId, DataSource source) { + MeteringData entity = new MeteringData(); + entity.setMeteringPoint(meteringPoint); + entity.setUploadId(uploadId); + entity.setDataType(record.dataType()); + entity.setIntervalStart(record.intervalStart()); + entity.setIntervalEnd(record.intervalEnd()); + entity.setKwh(record.kwh()); + entity.setReadingValue(record.readingValue()); + entity.setSource(source); + return entity; + } + + private void deduplicateAndSave(List entities) { + if (entities.isEmpty()) { + return; + } + + Set toDelete = new LinkedHashSet<>(); + + for (MeteringData entity : entities) { + meteringDataRepository + .findByMeteringPointIdAndDataTypeAndIntervalStart( + entity.getMeteringPoint().getId(), + entity.getDataType(), + entity.getIntervalStart()) + .forEach(toDelete::add); + } + + if (!toDelete.isEmpty()) { + meteringDataRepository.deleteAll(new ArrayList<>(toDelete)); + } + + meteringDataRepository.saveAll(entities); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java new file mode 100644 index 0000000..82c2056 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParser.java @@ -0,0 +1,130 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.domain.MeteringDataType; +import org.apache.poi.ss.usermodel.*; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Component +public class XlsxMeteringDataParser { + + private static final int COL_AT_NUMBER = 0; + private static final int COL_DATA_TYPE = 1; + private static final int COL_INTERVAL_START = 2; + private static final int COL_INTERVAL_END = 3; + private static final int COL_KWH = 4; + private static final int COL_READING_VALUE = 5; + + public List parse(InputStream inputStream) throws IOException { + List records = new ArrayList<>(); + + try (Workbook workbook = WorkbookFactory.create(inputStream)) { + Sheet sheet = workbook.getSheetAt(0); + + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + + String atNumber = getStringValue(row.getCell(COL_AT_NUMBER)); + if (atNumber == null || atNumber.isBlank()) { + continue; + } + + MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE))); + LocalDateTime intervalStart = getLocalDateTimeValue(row.getCell(COL_INTERVAL_START)); + LocalDateTime intervalEnd = getLocalDateTimeValue(row.getCell(COL_INTERVAL_END)); + + if (intervalStart == null || intervalEnd == null) { + continue; + } + + Double kwh = getNumericValue(row.getCell(COL_KWH)); + Double readingValue = getNumericValue(row.getCell(COL_READING_VALUE)); + + records.add(new MeteringDataRecordDto( + atNumber.trim(), + dataType, + intervalStart, + intervalEnd, + kwh != null ? kwh : 0.0, + readingValue + )); + } + } + + return records; + } + + private String getStringValue(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.STRING) { + return cell.getStringCellValue(); + } + if (cell.getCellType() == CellType.NUMERIC) { + return String.valueOf((long) cell.getNumericCellValue()); + } + return null; + } + + private Double getNumericValue(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC) { + return cell.getNumericCellValue(); + } + if (cell.getCellType() == CellType.STRING) { + String value = cell.getStringCellValue().replace(",", ".").trim(); + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private LocalDateTime getLocalDateTimeValue(Cell cell) { + if (cell == null) { + return null; + } + if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + return cell.getLocalDateTimeCellValue(); + } + if (cell.getCellType() == CellType.STRING) { + try { + return LocalDateTime.parse(cell.getStringCellValue()); + } catch (Exception e) { + try { + java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(), + java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")); + return date.atStartOfDay(); + } catch (Exception ex) { + return null; + } + } + } + return null; + } + + private MeteringDataType parseDataType(String value) { + if (value == null) { + return MeteringDataType.CONSUMPTION; + } + return switch (value.trim().toLowerCase()) { + case "einspeisung", "erzeugung", "feed_in", "feed-in" -> MeteringDataType.FEED_IN; + case "eigenverbrauch", "self_consumption", "self-consumption" -> MeteringDataType.SELF_CONSUMPTION; + default -> MeteringDataType.CONSUMPTION; + }; + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/SecurityConfig.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/SecurityConfig.java index 587299e..ad67fab 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/SecurityConfig.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/config/SecurityConfig.java @@ -56,6 +56,7 @@ public class SecurityConfig { .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll() .requestMatchers("/", "/index.html", "/assets/**", "/static/**", "/*.js", "/*.css", "/*.ico").permitAll() .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/**").authenticated() .anyRequest().denyAll() diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/mapper/UserMapper.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/mapper/UserMapper.java index 16e7834..b96db11 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/mapper/UserMapper.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/iam/mapper/UserMapper.java @@ -25,6 +25,8 @@ public interface UserMapper { @Mapping(target = "authorities", ignore = true) @Mapping(target = "emailVerified", ignore = true) @Mapping(target = "verificationToken", ignore = true) + @Mapping(target = "passwordResetToken", ignore = true) + @Mapping(target = "passwordResetTokenExpiry", ignore = true) User mapToEntity(IamDtos.RegistrationRequest request, @MappingTarget User user); diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/TariffInviteController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/TariffInviteController.java new file mode 100644 index 0000000..22baa08 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/TariffInviteController.java @@ -0,0 +1,57 @@ +package at.mueller.eeg.backend.tariff.api; + +import at.mueller.eeg.backend.common.security.CurrentUserId; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteRequest; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteResponse; +import at.mueller.eeg.backend.tariff.service.TariffInviteService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/tariffs/invites") +@RequiredArgsConstructor +public class TariffInviteController { + + private final TariffInviteService tariffInviteService; + + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity createInvite( + @CurrentUserId String userId, + @Valid @RequestBody TariffInviteRequest request) { + TariffInviteResponse response = tariffInviteService.createInvite(UUID.fromString(userId), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping(value = "/pending", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity> getPendingInvites( + @CurrentUserId String userId) { + return ResponseEntity.ok(tariffInviteService.getPendingInvitesForConsumer(UUID.fromString(userId))); + } + + @PostMapping(value = "/{inviteId}/accept", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity acceptInvite( + @CurrentUserId String userId, + @PathVariable UUID inviteId) { + TariffInviteResponse response = tariffInviteService.acceptInvite(UUID.fromString(userId), inviteId); + return ResponseEntity.ok(response); + } + + @GetMapping(value = "/community/{communityId}", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity> getInvitesForCommunity( + @CurrentUserId String userId, + @PathVariable UUID communityId) { + return ResponseEntity.ok(tariffInviteService.getInvitesForProducer(UUID.fromString(userId), communityId)); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteRequest.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteRequest.java new file mode 100644 index 0000000..a0e5d17 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteRequest.java @@ -0,0 +1,11 @@ +package at.mueller.eeg.backend.tariff.api.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record TariffInviteRequest( + @NotNull UUID consumerUserId, + @NotNull UUID energyCommunityId, + @Size(max = 500) String note +) {} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteResponse.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteResponse.java new file mode 100644 index 0000000..3c8e654 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/api/dto/TariffInviteResponse.java @@ -0,0 +1,11 @@ +package at.mueller.eeg.backend.tariff.api.dto; + +import at.mueller.eeg.backend.tariff.domain.InviteStatus; +import java.time.LocalDateTime; +import java.util.UUID; + +public record TariffInviteResponse( + UUID id, UUID producerUserId, UUID consumerUserId, UUID energyCommunityId, + String code, String note, InviteStatus status, + LocalDateTime expiresAt, LocalDateTime acceptedAt, LocalDateTime createdAt +) {} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/InviteStatus.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/InviteStatus.java new file mode 100644 index 0000000..fbca3d6 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/InviteStatus.java @@ -0,0 +1,7 @@ +package at.mueller.eeg.backend.tariff.domain; + +public enum InviteStatus { + PENDING, + ACCEPTED, + EXPIRED +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/TariffInvite.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/TariffInvite.java new file mode 100644 index 0000000..519684e --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/domain/TariffInvite.java @@ -0,0 +1,33 @@ +package at.mueller.eeg.backend.tariff.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "tariff_invites") +@Getter @Setter +public class TariffInvite { + @Id @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + @Column(name = "producer_user_id", nullable = false) + private UUID producerUserId; + @Column(name = "consumer_user_id", nullable = false) + private UUID consumerUserId; + @Column(name = "energy_community_id", nullable = false) + private UUID energyCommunityId; + @Column(nullable = false, unique = true, length = 6) + private String code; + @Column(length = 500) + private String note; + @Enumerated(EnumType.STRING) + private InviteStatus status; + private LocalDateTime expiresAt; + private LocalDateTime acceptedAt; + @Column(name = "created_at") + private LocalDateTime createdAt; + @PrePersist + protected void onCreate() { createdAt = LocalDateTime.now(); } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/repository/TariffInviteRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/repository/TariffInviteRepository.java new file mode 100644 index 0000000..ecccd9b --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/repository/TariffInviteRepository.java @@ -0,0 +1,19 @@ +package at.mueller.eeg.backend.tariff.repository; + +import at.mueller.eeg.backend.tariff.domain.InviteStatus; +import at.mueller.eeg.backend.tariff.domain.TariffInvite; +import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TariffInviteRepository extends JpaRepository { + Optional findByCode(String code); + List findByConsumerUserIdAndStatus(UUID consumerUserId, InviteStatus status); + List findByStatus(InviteStatus status); + List findByProducerUserIdAndEnergyCommunityId(UUID producerUserId, UUID communityId); + long countByProducerUserIdAndCreatedAtAfter(UUID userId, LocalDateTime since); + boolean existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + UUID producerUserId, UUID consumerUserId, UUID communityId, InviteStatus status); +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffInviteService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffInviteService.java new file mode 100644 index 0000000..cdd9272 --- /dev/null +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffInviteService.java @@ -0,0 +1,159 @@ +package at.mueller.eeg.backend.tariff.service; + +import at.mueller.eeg.backend.community.domain.MembershipStatus; +import at.mueller.eeg.backend.community.repository.MembershipRepository; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteRequest; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteResponse; +import at.mueller.eeg.backend.tariff.domain.InviteStatus; +import at.mueller.eeg.backend.tariff.domain.TariffInvite; +import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TariffInviteService { + + private static final int MAX_INVITES_PER_DAY = 5; + private static final int INVITE_EXPIRY_DAYS = 7; + private static final int CODE_LENGTH = 6; + private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final TariffInviteRepository tariffInviteRepository; + private final MembershipRepository membershipRepository; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + @Transactional + public TariffInviteResponse createInvite(UUID producerUserId, TariffInviteRequest request) { + if (!membershipRepository.isActiveMemberOfCommunity(producerUserId, request.energyCommunityId())) { + throw new IllegalStateException("Produzent ist kein Mitglied der Energiegemeinschaft."); + } + if (!membershipRepository.isActiveMemberOfCommunity(request.consumerUserId(), request.energyCommunityId())) { + throw new IllegalStateException("Konsument ist kein Mitglied der Energiegemeinschaft."); + } + if (producerUserId.equals(request.consumerUserId())) { + throw new IllegalStateException("Produzent und Konsument können nicht identisch sein."); + } + + if (tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + producerUserId, request.consumerUserId(), request.energyCommunityId(), InviteStatus.PENDING)) { + throw new IllegalStateException("Es existiert bereits eine offene Einladung für diesen Benutzer in dieser Energiegemeinschaft."); + } + + LocalDateTime since = LocalDateTime.now().minusDays(1); + long todayCount = tariffInviteRepository.countByProducerUserIdAndCreatedAtAfter(producerUserId, since); + if (todayCount >= MAX_INVITES_PER_DAY) { + throw new IllegalStateException("Tageslimit von " + MAX_INVITES_PER_DAY + " Einladungen erreicht."); + } + + TariffInvite invite = new TariffInvite(); + invite.setProducerUserId(producerUserId); + invite.setConsumerUserId(request.consumerUserId()); + invite.setEnergyCommunityId(request.energyCommunityId()); + invite.setCode(generateUniqueCode()); + invite.setNote(request.note()); + invite.setStatus(InviteStatus.PENDING); + invite.setExpiresAt(LocalDateTime.now().plusDays(INVITE_EXPIRY_DAYS)); + + TariffInvite saved = tariffInviteRepository.save(invite); + log.info("Tarif-Einladung erstellt: producer={}, consumer={}, community={}, code={}", + producerUserId, request.consumerUserId(), request.energyCommunityId(), saved.getCode()); + + return toResponse(saved); + } + + @Transactional + public TariffInviteResponse acceptInvite(UUID consumerUserId, UUID inviteId) { + TariffInvite invite = tariffInviteRepository.findById(inviteId) + .orElseThrow(() -> new IllegalArgumentException("Einladung nicht gefunden: " + inviteId)); + + if (!invite.getConsumerUserId().equals(consumerUserId)) { + throw new org.springframework.security.access.AccessDeniedException( + "Keine Berechtigung zum Annehmen dieser Einladung."); + } + if (invite.getStatus() != InviteStatus.PENDING) { + throw new IllegalStateException("Einladung ist nicht mehr aktiv."); + } + if (invite.getExpiresAt().isBefore(LocalDateTime.now())) { + invite.setStatus(InviteStatus.EXPIRED); + tariffInviteRepository.save(invite); + throw new IllegalStateException("Einladung ist abgelaufen."); + } + + invite.setStatus(InviteStatus.ACCEPTED); + invite.setAcceptedAt(LocalDateTime.now()); + + TariffInvite saved = tariffInviteRepository.save(invite); + log.info("Tarif-Einladung angenommen: id={}, consumer={}", inviteId, consumerUserId); + + return toResponse(saved); + } + + public List getPendingInvitesForConsumer(UUID consumerUserId) { + return tariffInviteRepository.findByConsumerUserIdAndStatus(consumerUserId, InviteStatus.PENDING).stream() + .filter(invite -> invite.getExpiresAt().isAfter(LocalDateTime.now())) + .map(this::toResponse) + .toList(); + } + + public List getInvitesForProducer(UUID producerUserId, UUID communityId) { + return tariffInviteRepository.findByProducerUserIdAndEnergyCommunityId(producerUserId, communityId).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional + public void cleanupExpiredInvites() { + List expired = tariffInviteRepository.findByStatus(InviteStatus.PENDING).stream() + .filter(invite -> invite.getExpiresAt().isBefore(LocalDateTime.now())) + .toList(); + for (TariffInvite invite : expired) { + invite.setStatus(InviteStatus.EXPIRED); + tariffInviteRepository.save(invite); + } + if (!expired.isEmpty()) { + log.info("Abgelaufene Einladungen bereinigt: {}", expired.size()); + } + } + + private String generateUniqueCode() { + String code; + do { + code = generateCode(); + } while (tariffInviteRepository.findByCode(code).isPresent()); + return code; + } + + private String generateCode() { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CODE_CHARS.charAt(SECURE_RANDOM.nextInt(CODE_CHARS.length()))); + } + return sb.toString(); + } + + private TariffInviteResponse toResponse(TariffInvite invite) { + return new TariffInviteResponse( + invite.getId(), + invite.getProducerUserId(), + invite.getConsumerUserId(), + invite.getEnergyCommunityId(), + invite.getCode(), + invite.getNote(), + invite.getStatus(), + invite.getExpiresAt(), + invite.getAcceptedAt(), + invite.getCreatedAt() + ); + } +} diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java index cef11cc..ffe9556 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java @@ -12,8 +12,10 @@ import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse; import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest; import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse; import at.mueller.eeg.backend.tariff.domain.CommunityTariff; +import at.mueller.eeg.backend.tariff.domain.InviteStatus; import at.mueller.eeg.backend.tariff.domain.UserTariff; import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository; +import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository; import at.mueller.eeg.backend.tariff.repository.UserTariffRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,6 +34,7 @@ public class TariffService { private final CommunityTariffRepository communityTariffRepository; private final UserTariffRepository userTariffRepository; + private final TariffInviteRepository tariffInviteRepository; private final MeteringPointRepository meteringPointRepository; private final MembershipRepository membershipRepository; private final ApplicationEventPublisher eventPublisher; @@ -116,6 +119,12 @@ public class TariffService { throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft."); } + if (!tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + sourceUserId, targetUserId, request.energyCommunityId(), InviteStatus.ACCEPTED)) { + throw new IllegalStateException( + "Zwischen Produzent und Konsument muss eine angenommene Einladung bestehen."); + } + UserTariff tariff = new UserTariff(); tariff.setEnergyCommunityId(request.energyCommunityId()); tariff.setSourceMeteringPointId(request.sourceMeteringPointId()); diff --git a/eeg_backend/src/main/resources/application-prod.yml b/eeg_backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..3f29369 --- /dev/null +++ b/eeg_backend/src/main/resources/application-prod.yml @@ -0,0 +1,54 @@ +spring: + datasource: + url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/eegportal} + driver-class-name: ${DATABASE_DRIVER:org.postgresql.Driver} + username: ${DATABASE_USERNAME:eeg} + password: ${DATABASE_PASSWORD:} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:update} + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + mail: + host: ${MAIL_HOST:} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + +jwt: + secret: ${JWT_SECRET} + +app: + frontend-url: ${APP_FRONTEND_URL} + backend-url: ${APP_BACKEND_URL} + cors-origins: ${CORS_ORIGINS} + +server: + port: ${SERVER_PORT:8080} + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when-authorized + +logging: + level: + at.mueller.eeg: INFO + org.springframework.security: WARN + org.hibernate.SQL: WARN diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java new file mode 100644 index 0000000..3088872 --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringDataServiceTest.java @@ -0,0 +1,294 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadResponse; +import at.mueller.eeg.backend.community.domain.*; +import at.mueller.eeg.backend.community.domain.state.MakoState; +import at.mueller.eeg.backend.community.mapper.MeteringDataMapper; +import at.mueller.eeg.backend.community.repository.MeteringDataRepository; +import at.mueller.eeg.backend.community.repository.MeteringDataUploadRepository; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MeteringDataServiceTest { + + @Mock + private MeteringDataRepository meteringDataRepository; + + @Mock + private MeteringDataUploadRepository uploadRepository; + + @Mock + private MeteringPointRepository meteringPointRepository; + + @Mock + private MeteringDataMapper mapper; + + @InjectMocks + private MeteringDataService meteringDataService; + + private MeteringPoint testMeteringPoint; + private String testAtNumber; + private UUID testUserId; + + @BeforeEach + void setUp() { + testAtNumber = "AT0010000000000000000000001234567"; + testUserId = UUID.randomUUID(); + + testMeteringPoint = new MeteringPoint(); + testMeteringPoint.setId(UUID.randomUUID()); + testMeteringPoint.setUserId(testUserId); + testMeteringPoint.setAtNumber(testAtNumber); + testMeteringPoint.setType(PointType.CONSUMER); + testMeteringPoint.setMakoState(MakoState.ACTIVE); + } + + @Test + void uploadMeteringData_successfulUpload() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 1.25, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertNotNull(response); + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(1, response.recordCount()); + assertTrue(response.validationErrors().isEmpty()); + } + + @Test + void uploadMeteringData_validationFailsForUnknownAtNumber() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(Collections.emptyList()); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 1.25, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertEquals(0, response.recordCount()); + assertFalse(response.validationErrors().isEmpty()); + assertTrue(response.validationErrors().get(0).contains("nicht im System vorhanden")); + } + + @Test + void uploadMeteringData_validationFailsForEmptyRecords() { + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + Collections.emptyList() + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertFalse(response.validationErrors().isEmpty()); + } + + @Test + void uploadMeteringData_validationFailsForNegativeKwh() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + -1.0, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertFalse(response.validationErrors().isEmpty()); + assertTrue(response.validationErrors().get(0).contains("negativ")); + } + + @Test + void uploadMeteringData_validationFailsForInvalidInterval() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 15), + LocalDateTime.of(2026, 1, 1, 0, 0), + 1.25, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.FAILED, response.status()); + assertFalse(response.validationErrors().isEmpty()); + assertTrue(response.validationErrors().get(0).contains("Intervall-Ende")); + } + + @Test + void uploadMeteringData_multipleRecords() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + List records = List.of( + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null), + new MeteringDataRecordDto(testAtNumber, MeteringDataType.FEED_IN, + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 2.50, null) + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + records + ); + + MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + assertEquals(UploadStatus.COMPLETED, response.status()); + assertEquals(3, response.recordCount()); + verify(meteringDataRepository).saveAll(any()); + } + + @Test + void uploadMeteringData_setsSourceFromRequest() { + when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint)); + when(uploadRepository.save(any())).thenAnswer(invocation -> { + MeteringDataUpload upload = invocation.getArgument(0); + upload.setId(UUID.randomUUID()); + return upload; + }); + when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + List entities = invocation.getArgument(0); + entities.forEach(e -> assertEquals(DataSource.EMAIL_XLSX, e.getSource())); + return entities; + }); + + MeteringDataRecordDto record = new MeteringDataRecordDto( + testAtNumber, + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 1, 0, 15), + 1.25, + null + ); + + MeteringDataUploadRequest request = new MeteringDataUploadRequest( + DataSource.EMAIL_XLSX, + "test.xlsx", + List.of(record) + ); + + meteringDataService.uploadMeteringData(request, UUID.randomUUID()); + + verify(meteringDataRepository).saveAll(any()); + } + + @Test + void getMeteringData_allowsOwner() { + when(meteringPointRepository.findById(testMeteringPoint.getId())) + .thenReturn(Optional.of(testMeteringPoint)); + when(meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween( + any(), any(), any())).thenReturn(Collections.emptyList()); + + meteringDataService.getMeteringData(testUserId, testMeteringPoint.getId(), + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)); + + verify(meteringDataRepository).findByMeteringPointIdAndIntervalStartBetween( + any(), any(), any()); + } + + @Test + void getMeteringData_rejectsNonOwner() { + UUID otherUserId = UUID.randomUUID(); + when(meteringPointRepository.findById(testMeteringPoint.getId())) + .thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(AccessDeniedException.class, () -> + meteringDataService.getMeteringData(otherUserId, testMeteringPoint.getId(), + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); + } + + @Test + void getMeteringDataByType_rejectsNonOwner() { + UUID otherUserId = UUID.randomUUID(); + when(meteringPointRepository.findById(testMeteringPoint.getId())) + .thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(AccessDeniedException.class, () -> + meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(), + MeteringDataType.CONSUMPTION, + LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59))); + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java new file mode 100644 index 0000000..f1d2dbf --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/XlsxMeteringDataParserTest.java @@ -0,0 +1,177 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto; +import at.mueller.eeg.backend.community.domain.MeteringDataType; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class XlsxMeteringDataParserTest { + + private XlsxMeteringDataParser parser; + + @BeforeEach + void setUp() { + parser = new XlsxMeteringDataParser(); + } + + @Test + void parse_validXlsxWithTwoRecords() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:15", "2026-01-01T00:30", "0.80", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(2, records.size()); + + MeteringDataRecordDto first = records.get(0); + assertEquals("AT0010000000000000000000001234567", first.atNumber()); + assertEquals(MeteringDataType.CONSUMPTION, first.dataType()); + assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0), first.intervalStart()); + assertEquals(LocalDateTime.of(2026, 1, 1, 0, 15), first.intervalEnd()); + assertEquals(1.25, first.kwh(), 0.001); + + MeteringDataRecordDto second = records.get(1); + assertEquals(0.80, second.kwh(), 0.001); + } + + @Test + void parse_einspeisungMapsToFeedIn() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Einspeisung", "2026-01-01T00:00", "2026-01-01T00:15", "2.50", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals(MeteringDataType.FEED_IN, records.get(0).dataType()); + } + + @Test + void parse_eigenverbrauchMapsToSelfConsumption() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Eigenverbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "0.50", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals(MeteringDataType.SELF_CONSUMPTION, records.get(0).dataType()); + } + + @Test + void parse_blankAtNumberRowIsSkipped() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber()); + } + + @Test + void parse_commaDecimalIsHandled() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1,25", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals(1.25, records.get(0).kwh(), 0.001); + } + + @Test + void parse_withReadingValue() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", "12345.67"} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals(12345.67, records.get(0).readingValue(), 0.01); + } + + @Test + void parse_emptySheetReturnsEmptyList() throws IOException { + byte[] xlsxBytes = createTestXlsx(); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertTrue(records.isEmpty()); + } + + @Test + void parse_defaultDataTypeIsConsumption() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Unbekannt", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals(MeteringDataType.CONSUMPTION, records.get(0).dataType()); + } + + @Test + void parse_nullDateRowIsSkipped() throws IOException { + byte[] xlsxBytes = createTestXlsx( + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "", "2026-01-01T00:15", "1.25", ""}, + new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-01-01T00:00", "2026-01-01T00:15", "1.25", ""} + ); + + List records = parser.parse(new ByteArrayInputStream(xlsxBytes)); + + assertEquals(1, records.size()); + assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber()); + } + + private byte[] createTestXlsx(String[]... dataRows) throws IOException { + try (Workbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + Sheet sheet = workbook.createSheet("Messdaten"); + + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("Zählpunktnummer"); + header.createCell(1).setCellValue("Typ"); + header.createCell(2).setCellValue("Von"); + header.createCell(3).setCellValue("Bis"); + header.createCell(4).setCellValue("kWh"); + header.createCell(5).setCellValue("Zählerstand"); + + for (int i = 0; i < dataRows.length; i++) { + Row row = sheet.createRow(i + 1); + String[] data = dataRows[i]; + for (int j = 0; j < data.length; j++) { + Cell cell = row.createCell(j); + if (j == 4 && !data[j].isEmpty()) { + cell.setCellValue(data[j].replace(",", ".")); + } else if (j == 5 && !data[j].isEmpty()) { + cell.setCellValue(data[j].replace(",", ".")); + } else { + cell.setCellValue(data[j]); + } + } + } + + workbook.write(out); + return out.toByteArray(); + } + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java index 968bcd7..5f24f40 100644 --- a/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java @@ -15,8 +15,10 @@ import at.mueller.eeg.backend.tariff.api.dto.CommunityTariffResponse; import at.mueller.eeg.backend.tariff.api.dto.UserTariffRequest; import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse; import at.mueller.eeg.backend.tariff.domain.CommunityTariff; +import at.mueller.eeg.backend.tariff.domain.InviteStatus; import at.mueller.eeg.backend.tariff.domain.UserTariff; import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository; +import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository; import at.mueller.eeg.backend.tariff.repository.UserTariffRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,6 +48,8 @@ class TariffServiceTest { @Mock private UserTariffRepository userTariffRepository; @Mock + private TariffInviteRepository tariffInviteRepository; + @Mock private MeteringPointRepository meteringPointRepository; @Mock private MembershipRepository membershipRepository; @@ -181,6 +185,8 @@ class TariffServiceTest { .thenReturn(Optional.of(communityTariff)); when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true); when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true); UserTariff savedTariff = new UserTariff(); savedTariff.setId(UUID.randomUUID()); @@ -508,6 +514,8 @@ class TariffServiceTest { .thenReturn(Optional.of(communityTariff)); when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true); when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true); UserTariff savedTariff = new UserTariff(); savedTariff.setId(UUID.randomUUID()); @@ -590,6 +598,8 @@ class TariffServiceTest { .thenReturn(Optional.of(communityTariff)); when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true); when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true); UserTariff savedTariff = new UserTariff(); savedTariff.setId(UUID.randomUUID()); diff --git a/eeg_frontend/openapi.yaml b/eeg_frontend/openapi.yaml index 101eaa1..5616b3f 100644 --- a/eeg_frontend/openapi.yaml +++ b/eeg_frontend/openapi.yaml @@ -323,6 +323,43 @@ paths: application/json: schema: $ref: "#/components/schemas/UserTariffResponse" + /api/tariffs/invites: + post: + tags: + - tariff-invite-controller + operationId: createInvite + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/TariffInviteRequest" + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TariffInviteResponse" + /api/tariffs/invites/{inviteId}/accept: + post: + tags: + - tariff-invite-controller + operationId: acceptInvite + parameters: + - name: inviteId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TariffInviteResponse" /api/community/metering-points: get: tags: @@ -354,6 +391,47 @@ paths: '*/*': schema: $ref: "#/components/schemas/MeteringPointResponse" + /api/community/metering-data/upload: + post: + tags: + - metering-data-controller + operationId: uploadMeteringData + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/MeteringDataUploadRequest" + required: true + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MeteringDataUploadResponse" + /api/community/metering-data/upload/xlsx: + post: + tags: + - metering-data-controller + operationId: uploadXlsx + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + required: + - file + responses: + "200": + description: OK + content: + '*/*': + schema: + $ref: "#/components/schemas/MeteringDataUploadResponse" /api/community/memberships/request: post: tags: @@ -505,6 +583,41 @@ paths: application/json: schema: $ref: "#/components/schemas/EnergyCommunityDto" + /api/tariffs/invites/pending: + get: + tags: + - tariff-invite-controller + operationId: getPendingInvites + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TariffInviteResponse" + /api/tariffs/invites/community/{communityId}: + get: + tags: + - tariff-invite-controller + operationId: getInvitesForCommunity + parameters: + - name: communityId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TariffInviteResponse" /api/tariffs/community/{communityId}: get: tags: @@ -612,6 +725,102 @@ paths: type: array items: $ref: "#/components/schemas/MeteringPointResponse" + /api/community/metering-data/{meteringPointId}: + get: + tags: + - metering-data-controller + operationId: getMeteringData + parameters: + - name: meteringPointId + in: path + required: true + schema: + type: string + format: uuid + - name: from + in: query + required: true + schema: + type: string + format: date-time + - name: to + in: query + required: true + schema: + type: string + format: date-time + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/MeteringDataResponse" + /api/community/metering-data/{meteringPointId}/by-type: + get: + tags: + - metering-data-controller + operationId: getMeteringDataByType + parameters: + - name: meteringPointId + in: path + required: true + schema: + type: string + format: uuid + - name: dataType + in: query + required: true + schema: + type: string + enum: + - CONSUMPTION + - FEED_IN + - SELF_CONSUMPTION + - name: from + in: query + required: true + schema: + type: string + format: date-time + - name: to + in: query + required: true + schema: + type: string + format: date-time + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/MeteringDataResponse" + /api/community/metering-data/upload/{uploadId}: + get: + tags: + - metering-data-controller + operationId: getUploadData + parameters: + - name: uploadId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: OK + content: + '*/*': + schema: + type: array + items: + $ref: "#/components/schemas/MeteringDataResponse" /api/community/memberships/me: get: tags: @@ -913,6 +1122,56 @@ components: updatedAt: type: string format: date-time + TariffInviteRequest: + type: object + properties: + consumerUserId: + type: string + format: uuid + energyCommunityId: + type: string + format: uuid + note: + type: string + maxLength: 500 + minLength: 0 + required: + - consumerUserId + - energyCommunityId + TariffInviteResponse: + type: object + properties: + id: + type: string + format: uuid + producerUserId: + type: string + format: uuid + consumerUserId: + type: string + format: uuid + energyCommunityId: + type: string + format: uuid + code: + type: string + note: + type: string + status: + type: string + enum: + - PENDING + - ACCEPTED + - EXPIRED + expiresAt: + type: string + format: date-time + acceptedAt: + type: string + format: date-time + createdAt: + type: string + format: date-time CreateMeteringPointRequest: type: object properties: @@ -928,6 +1187,77 @@ components: required: - atNumber - type + MeteringDataRecordDto: + type: object + properties: + atNumber: + type: string + minLength: 1 + pattern: "^AT[0-9]{31}$" + dataType: + type: string + enum: + - CONSUMPTION + - FEED_IN + - SELF_CONSUMPTION + intervalStart: + type: string + format: date-time + intervalEnd: + type: string + format: date-time + kwh: + type: number + format: double + minimum: 0 + readingValue: + type: number + format: double + required: + - atNumber + - dataType + - intervalEnd + - intervalStart + - kwh + MeteringDataUploadRequest: + type: object + properties: + source: + type: string + enum: + - EMAIL_XLSX + - API + - MANUAL + fileName: + type: string + records: + type: array + items: + $ref: "#/components/schemas/MeteringDataRecordDto" + minItems: 1 + required: + - records + - source + MeteringDataUploadResponse: + type: object + properties: + uploadId: + type: string + format: uuid + status: + type: string + enum: + - PENDING + - PROCESSING + - COMPLETED + - FAILED + recordCount: + type: integer + format: int32 + validationErrors: + type: array + items: + type: string MembershipRequest: type: object properties: @@ -1093,6 +1423,9 @@ components: pendingUsers: type: integer format: int64 + pendingMemberships: + type: integer + format: int64 totalActiveMemberships: type: integer format: int64 @@ -1112,6 +1445,41 @@ components: - BEG edaEcId: type: string + MeteringDataResponse: + type: object + properties: + id: + type: string + format: uuid + meteringPointId: + type: string + format: uuid + atNumber: + type: string + dataType: + type: string + enum: + - CONSUMPTION + - FEED_IN + - SELF_CONSUMPTION + intervalStart: + type: string + format: date-time + intervalEnd: + type: string + format: date-time + kwh: + type: number + format: double + readingValue: + type: number + format: double + source: + type: string + enum: + - EMAIL_XLSX + - API + - MANUAL MembershipResponse: type: object properties: diff --git a/nginx-ssl.conf b/nginx-ssl.conf new file mode 100644 index 0000000..2ef059b --- /dev/null +++ b/nginx-ssl.conf @@ -0,0 +1,67 @@ +server { + listen 80; + server_name your-domain.at; + + # Redirect HTTP to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.at; + + # SSL configuration (Certbot will generate these) + ssl_certificate /etc/letsencrypt/live/your-domain.at/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.at/privkey.pem; + + # SSL security settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Frontend static files + root /usr/share/nginx/html; + index index.html; + + # API proxy to backend + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_connect_timeout 10s; + } + + # Swagger/OpenAPI proxy + location /v3/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Angular SPA - serve index.html for all non-file routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..28ae805 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,49 @@ +server { + listen 80; + server_name _; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Frontend static files + root /usr/share/nginx/html; + index index.html; + + # API proxy to backend + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_connect_timeout 10s; + } + + # Swagger/OpenAPI proxy + location /v3/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Angular SPA - serve index.html for all non-file routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/setup-server.sh b/setup-server.sh new file mode 100644 index 0000000..eb2d18c --- /dev/null +++ b/setup-server.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# ============================================================ +# EEG Portal - Hetzner Server Setup Script +# Run this on a fresh Debian/Ubuntu server +# ============================================================ + +set -e + +echo "=== Hetzner Server Setup for EEG Portal ===" +echo "" + +# Update system +echo "Updating system packages..." +apt update && apt upgrade -y + +# Install Docker +echo "Installing Docker..." +apt install -y ca-certificates curl gnupg +install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +chmod a+r /etc/apt/keyrings/docker.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null +apt update +apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Enable Docker +systemctl enable docker +systemctl start docker + +# Install Git +echo "Installing Git..." +apt install -y git + +# Install Certbot for SSL +echo "Installing Certbot..." +apt install -y certbot + +echo "" +echo "=== Server setup complete! ===" +echo "" +echo "Next steps:" +echo " 1. Clone your repository:" +echo " git clone " +echo " cd eeg_portal" +echo "" +echo " 2. Create .env file:" +echo " cp .env.example .env" +echo " nano .env" +echo "" +echo " 3. Start the application:" +echo " bash deploy.sh" +echo "" +echo " 4. For SSL (after DNS is configured):" +echo " certbot certonly --standalone -d your-domain.at" diff --git a/setup-ssl.sh b/setup-ssl.sh new file mode 100644 index 0000000..bd539cc --- /dev/null +++ b/setup-ssl.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================ +# EEG Portal - SSL Setup Script +# Run this after DNS is configured and pointing to your server +# ============================================================ + +set -e + +echo "=== SSL Setup for EEG Portal ===" +echo "" + +# Check if domain is provided +if [ -z "$1" ]; then + echo "Usage: $0 " + echo "Example: $0 eeg-portal.at" + exit 1 +fi + +DOMAIN=$1 + +echo "Setting up SSL for: $DOMAIN" +echo "" + +# Stop nginx container temporarily +echo "Stopping nginx container..." +docker stop eeg-frontend + +# Get SSL certificate +echo "Getting SSL certificate from Let's Encrypt..." +certbot certonly --standalone -d $DOMAIN --non-interactive --agree-tos --email admin@$DOMAIN + +# Update nginx config with domain +echo "Updating nginx configuration..." +sed -i "s/your-domain.at/$DOMAIN/g" nginx-ssl.conf + +# Copy SSL config +echo "Activating SSL configuration..." +cp nginx-ssl.conf /etc/nginx/conf.d/default.conf + +# Start nginx container +echo "Starting nginx container..." +docker start eeg-frontend + +echo "" +echo "=== SSL setup complete! ===" +echo "" +echo "Your site is now available at: https://$DOMAIN" +echo "" +echo "Note: Certbot auto-renewal is set up. Certificate will be renewed automatically."