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
This commit is contained in:
parent
949e1e6c34
commit
975f76dbd3
45 changed files with 2573 additions and 4 deletions
31
.dockerignore
Normal file
31
.dockerignore
Normal file
|
|
@ -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
|
||||||
33
.env.example
Normal file
33
.env.example
Normal file
|
|
@ -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
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -49,3 +49,12 @@ test_*.json
|
||||||
.mimocode/
|
.mimocode/
|
||||||
run_test.ps1
|
run_test.ps1
|
||||||
server_output.log
|
server_output.log
|
||||||
|
backend.log
|
||||||
|
*.log
|
||||||
|
|
||||||
|
### Research / Umfeld ###
|
||||||
|
survey/
|
||||||
|
|
||||||
|
### Docker ###
|
||||||
|
.env
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
|
||||||
34
AGENTS.md
34
AGENTS.md
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
**EEG Portal** - Portal zur Verwaltung von Energiegemeinschaften in Österreich.
|
**EEG Portal** - Portal zur Verwaltung von Energiegemeinschaften in Österreich.
|
||||||
Behandelt: Benutzerregistrierung, Messpunkt-Consent-Workflows (MaKo), EDA-Topologieprüfungen,
|
Behandelt: Benutzerregistrierung, Messpunkt-Consent-Workflows (MaKo), EDA-Topologieprüfungen,
|
||||||
Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife.
|
Energiegemeinschaftsverwaltung, Mitgliedschaften, Tarife und Messdaten-Verwaltung.
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
|
|
@ -12,12 +12,12 @@ Energiegemeinschaftsverwaltung, Mitgliedschaften und Tarife.
|
||||||
|
|
||||||
### Backend (Spring Boot)
|
### Backend (Spring Boot)
|
||||||
- **Java 21**, Spring Boot 4.0.6
|
- **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`)
|
- **ORM:** Spring Data JPA + Hibernate (`ddl-auto: update`)
|
||||||
- **Annotation Processing:** Lombok + MapStruct (mit `unmappedTargetPolicy=ERROR`)
|
- **Annotation Processing:** Lombok + MapStruct (mit `unmappedTargetPolicy=ERROR`)
|
||||||
- **Pakete:**
|
- **Pakete:**
|
||||||
- `iam/` - Identitäts- und Zugriffsverwaltung (Auth, Users, Security)
|
- `iam/` - Identitäts- und Zugriffsverwaltung (Auth, Users, Security)
|
||||||
- `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte
|
- `community/` - Energiegemeinschaften, Mitgliedschaften, Messpunkte, Messdaten
|
||||||
- `mako/` - EDA-Kommunikation (Consent, Topologie)
|
- `mako/` - EDA-Kommunikation (Consent, Topologie)
|
||||||
- `tariff/` - Tarifverwaltung (nur Domain-Entity, noch kein Service)
|
- `tariff/` - Tarifverwaltung (nur Domain-Entity, noch kein Service)
|
||||||
- `dashboard/` - Dashboard-Statistiken
|
- `dashboard/` - Dashboard-Statistiken
|
||||||
|
|
@ -156,3 +156,31 @@ fix(mako): handle consent rejected event correctly
|
||||||
docs: update AGENTS.md with conventional commits
|
docs: update AGENTS.md with conventional commits
|
||||||
test(iam): add UserService unit tests
|
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.
|
||||||
|
|
|
||||||
128
DEPLOYMENT.md
Normal file
128
DEPLOYMENT.md
Normal file
|
|
@ -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-ip>
|
||||||
|
|
||||||
|
# Server-Setup-Skript ausführen
|
||||||
|
bash setup-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Projekt deployen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Projekt klonen
|
||||||
|
git clone <your-repo-url>
|
||||||
|
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
|
||||||
|
```
|
||||||
68
Dockerfile
Normal file
68
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
62
deploy.sh
Normal file
62
deploy.sh
Normal file
|
|
@ -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"
|
||||||
74
docker-compose.yml
Normal file
74
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -41,6 +41,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-mail</artifactId>
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
|
@ -57,12 +61,24 @@
|
||||||
<version>0.2.0</version>
|
<version>0.2.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.h2database</groupId>
|
<groupId>com.h2database</groupId>
|
||||||
<artifactId>h2</artifactId>
|
<artifactId>h2</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
|
@ -158,6 +174,16 @@
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.38</version>
|
<version>1.18.38</version>
|
||||||
</path>
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||||
|
<version>0.2.0</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>1.6.3</version>
|
||||||
|
</path>
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
|
|
@ -174,6 +200,16 @@
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<version>1.18.38</version>
|
<version>1.18.38</version>
|
||||||
</path>
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||||
|
<version>0.2.0</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>1.6.3</version>
|
||||||
|
</path>
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
|
|
|
||||||
|
|
@ -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<MeteringDataUploadResponse> 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<MeteringDataUploadResponse> uploadXlsx(
|
||||||
|
@CurrentUserId String userId,
|
||||||
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
|
||||||
|
List<MeteringDataRecordDto> 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<List<MeteringDataResponse>> 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<List<MeteringDataResponse>> 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<List<MeteringDataResponse>> getUploadData(@PathVariable UUID uploadId) {
|
||||||
|
return ResponseEntity.ok(meteringDataService.getUploadData(uploadId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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<MeteringDataRecordDto> records
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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<String> validationErrors
|
||||||
|
) {
|
||||||
|
public static MeteringDataUploadResponse success(UUID uploadId, Integer recordCount) {
|
||||||
|
return new MeteringDataUploadResponse(uploadId, UploadStatus.COMPLETED, recordCount, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeteringDataUploadResponse validationFailed(List<String> errors) {
|
||||||
|
return new MeteringDataUploadResponse(null, UploadStatus.FAILED, 0, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package at.mueller.eeg.backend.community.domain;
|
||||||
|
|
||||||
|
public enum DataSource {
|
||||||
|
EMAIL_XLSX,
|
||||||
|
API,
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package at.mueller.eeg.backend.community.domain;
|
||||||
|
|
||||||
|
public enum MeteringDataType {
|
||||||
|
CONSUMPTION,
|
||||||
|
FEED_IN,
|
||||||
|
SELF_CONSUMPTION
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package at.mueller.eeg.backend.community.domain;
|
||||||
|
|
||||||
|
public enum UploadStatus {
|
||||||
|
PENDING,
|
||||||
|
PROCESSING,
|
||||||
|
COMPLETED,
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import org.mapstruct.Mapper;
|
||||||
import org.mapstruct.Mapping;
|
import org.mapstruct.Mapping;
|
||||||
import org.mapstruct.MappingTarget;
|
import org.mapstruct.MappingTarget;
|
||||||
|
|
||||||
@Mapper
|
@Mapper(componentModel = "spring")
|
||||||
public interface EnergyCommunityMapper {
|
public interface EnergyCommunityMapper {
|
||||||
|
|
||||||
@Mapping(target = "id", ignore = true)
|
@Mapping(target = "id", ignore = true)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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<MeteringData, UUID> {
|
||||||
|
|
||||||
|
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
|
||||||
|
UUID meteringPointId, LocalDateTime from, LocalDateTime to);
|
||||||
|
|
||||||
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartBetween(
|
||||||
|
UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to);
|
||||||
|
|
||||||
|
List<MeteringData> findByUploadId(UUID uploadId);
|
||||||
|
|
||||||
|
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
|
||||||
|
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
|
||||||
|
}
|
||||||
|
|
@ -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<MeteringDataUpload, UUID> {
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package at.mueller.eeg.backend.community.repository;
|
||||||
import at.mueller.eeg.backend.community.domain.MeteringPoint;
|
import at.mueller.eeg.backend.community.domain.MeteringPoint;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -17,6 +18,8 @@ public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UU
|
||||||
|
|
||||||
Optional<MeteringPoint> findByAtNumber(String atNumber);
|
Optional<MeteringPoint> findByAtNumber(String atNumber);
|
||||||
|
|
||||||
|
List<MeteringPoint> findByAtNumberIn(Collection<String> atNumbers);
|
||||||
|
|
||||||
void deleteByUserId(UUID userId);
|
void deleteByUserId(UUID userId);
|
||||||
|
|
||||||
void deleteByIdAndUserId(UUID id, UUID userId);
|
void deleteByIdAndUserId(UUID id, UUID userId);
|
||||||
|
|
|
||||||
|
|
@ -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<String> 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<String, MeteringPoint> pointsByAtNumber = resolveMeteringPoints(request.records());
|
||||||
|
|
||||||
|
List<MeteringData> 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<MeteringDataResponse> 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<MeteringDataResponse> 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<MeteringDataResponse> 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<String> validateRecords(List<MeteringDataRecordDto> records) {
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
errors.add("Keine Datensätze vorhanden");
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> atNumbers = records.stream()
|
||||||
|
.map(MeteringDataRecordDto::atNumber)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
Set<String> 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<String, MeteringPoint> resolveMeteringPoints(List<MeteringDataRecordDto> records) {
|
||||||
|
Set<String> 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<MeteringData> entities) {
|
||||||
|
if (entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<MeteringData> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<MeteringDataRecordDto> parse(InputStream inputStream) throws IOException {
|
||||||
|
List<MeteringDataRecordDto> 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,7 @@ public class SecurityConfig {
|
||||||
.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
|
.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
|
||||||
.requestMatchers("/", "/index.html", "/assets/**", "/static/**", "/*.js", "/*.css", "/*.ico").permitAll()
|
.requestMatchers("/", "/index.html", "/assets/**", "/static/**", "/*.js", "/*.css", "/*.ico").permitAll()
|
||||||
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||||
|
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/api/**").authenticated()
|
.requestMatchers("/api/**").authenticated()
|
||||||
.anyRequest().denyAll()
|
.anyRequest().denyAll()
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ public interface UserMapper {
|
||||||
@Mapping(target = "authorities", ignore = true)
|
@Mapping(target = "authorities", ignore = true)
|
||||||
@Mapping(target = "emailVerified", ignore = true)
|
@Mapping(target = "emailVerified", ignore = true)
|
||||||
@Mapping(target = "verificationToken", 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);
|
User mapToEntity(IamDtos.RegistrationRequest request, @MappingTarget User user);
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<TariffInviteResponse> 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<List<TariffInviteResponse>> 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<TariffInviteResponse> 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<List<TariffInviteResponse>> getInvitesForCommunity(
|
||||||
|
@CurrentUserId String userId,
|
||||||
|
@PathVariable UUID communityId) {
|
||||||
|
return ResponseEntity.ok(tariffInviteService.getInvitesForProducer(UUID.fromString(userId), communityId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -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
|
||||||
|
) {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package at.mueller.eeg.backend.tariff.domain;
|
||||||
|
|
||||||
|
public enum InviteStatus {
|
||||||
|
PENDING,
|
||||||
|
ACCEPTED,
|
||||||
|
EXPIRED
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
|
@ -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<TariffInvite, UUID> {
|
||||||
|
Optional<TariffInvite> findByCode(String code);
|
||||||
|
List<TariffInvite> findByConsumerUserIdAndStatus(UUID consumerUserId, InviteStatus status);
|
||||||
|
List<TariffInvite> findByStatus(InviteStatus status);
|
||||||
|
List<TariffInvite> findByProducerUserIdAndEnergyCommunityId(UUID producerUserId, UUID communityId);
|
||||||
|
long countByProducerUserIdAndCreatedAtAfter(UUID userId, LocalDateTime since);
|
||||||
|
boolean existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
|
||||||
|
UUID producerUserId, UUID consumerUserId, UUID communityId, InviteStatus status);
|
||||||
|
}
|
||||||
|
|
@ -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<TariffInviteResponse> getPendingInvitesForConsumer(UUID consumerUserId) {
|
||||||
|
return tariffInviteRepository.findByConsumerUserIdAndStatus(consumerUserId, InviteStatus.PENDING).stream()
|
||||||
|
.filter(invite -> invite.getExpiresAt().isAfter(LocalDateTime.now()))
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TariffInviteResponse> getInvitesForProducer(UUID producerUserId, UUID communityId) {
|
||||||
|
return tariffInviteRepository.findByProducerUserIdAndEnergyCommunityId(producerUserId, communityId).stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void cleanupExpiredInvites() {
|
||||||
|
List<TariffInvite> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.UserTariffRequest;
|
||||||
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
|
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
|
||||||
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
|
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.domain.UserTariff;
|
||||||
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
|
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 at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
@ -32,6 +34,7 @@ public class TariffService {
|
||||||
|
|
||||||
private final CommunityTariffRepository communityTariffRepository;
|
private final CommunityTariffRepository communityTariffRepository;
|
||||||
private final UserTariffRepository userTariffRepository;
|
private final UserTariffRepository userTariffRepository;
|
||||||
|
private final TariffInviteRepository tariffInviteRepository;
|
||||||
private final MeteringPointRepository meteringPointRepository;
|
private final MeteringPointRepository meteringPointRepository;
|
||||||
private final MembershipRepository membershipRepository;
|
private final MembershipRepository membershipRepository;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
@ -116,6 +119,12 @@ public class TariffService {
|
||||||
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
|
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();
|
UserTariff tariff = new UserTariff();
|
||||||
tariff.setEnergyCommunityId(request.energyCommunityId());
|
tariff.setEnergyCommunityId(request.energyCommunityId());
|
||||||
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
|
tariff.setSourceMeteringPointId(request.sourceMeteringPointId());
|
||||||
|
|
|
||||||
54
eeg_backend/src/main/resources/application-prod.yml
Normal file
54
eeg_backend/src/main/resources/application-prod.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -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<MeteringDataRecordDto> 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<MeteringData> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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<MeteringDataRecordDto> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.UserTariffRequest;
|
||||||
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
|
import at.mueller.eeg.backend.tariff.api.dto.UserTariffResponse;
|
||||||
import at.mueller.eeg.backend.tariff.domain.CommunityTariff;
|
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.domain.UserTariff;
|
||||||
import at.mueller.eeg.backend.tariff.repository.CommunityTariffRepository;
|
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 at.mueller.eeg.backend.tariff.repository.UserTariffRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
@ -46,6 +48,8 @@ class TariffServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserTariffRepository userTariffRepository;
|
private UserTariffRepository userTariffRepository;
|
||||||
@Mock
|
@Mock
|
||||||
|
private TariffInviteRepository tariffInviteRepository;
|
||||||
|
@Mock
|
||||||
private MeteringPointRepository meteringPointRepository;
|
private MeteringPointRepository meteringPointRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private MembershipRepository membershipRepository;
|
private MembershipRepository membershipRepository;
|
||||||
|
|
@ -181,6 +185,8 @@ class TariffServiceTest {
|
||||||
.thenReturn(Optional.of(communityTariff));
|
.thenReturn(Optional.of(communityTariff));
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
||||||
|
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
|
||||||
|
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
|
||||||
|
|
||||||
UserTariff savedTariff = new UserTariff();
|
UserTariff savedTariff = new UserTariff();
|
||||||
savedTariff.setId(UUID.randomUUID());
|
savedTariff.setId(UUID.randomUUID());
|
||||||
|
|
@ -508,6 +514,8 @@ class TariffServiceTest {
|
||||||
.thenReturn(Optional.of(communityTariff));
|
.thenReturn(Optional.of(communityTariff));
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
||||||
|
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
|
||||||
|
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
|
||||||
|
|
||||||
UserTariff savedTariff = new UserTariff();
|
UserTariff savedTariff = new UserTariff();
|
||||||
savedTariff.setId(UUID.randomUUID());
|
savedTariff.setId(UUID.randomUUID());
|
||||||
|
|
@ -590,6 +598,8 @@ class TariffServiceTest {
|
||||||
.thenReturn(Optional.of(communityTariff));
|
.thenReturn(Optional.of(communityTariff));
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)).thenReturn(true);
|
||||||
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
when(membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)).thenReturn(true);
|
||||||
|
when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus(
|
||||||
|
sourceUserId, targetUserId, communityId, InviteStatus.ACCEPTED)).thenReturn(true);
|
||||||
|
|
||||||
UserTariff savedTariff = new UserTariff();
|
UserTariff savedTariff = new UserTariff();
|
||||||
savedTariff.setId(UUID.randomUUID());
|
savedTariff.setId(UUID.randomUUID());
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,43 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/UserTariffResponse"
|
$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:
|
/api/community/metering-points:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -354,6 +391,47 @@ paths:
|
||||||
'*/*':
|
'*/*':
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/MeteringPointResponse"
|
$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:
|
/api/community/memberships/request:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -505,6 +583,41 @@ paths:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/EnergyCommunityDto"
|
$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}:
|
/api/tariffs/community/{communityId}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -612,6 +725,102 @@ paths:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/MeteringPointResponse"
|
$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:
|
/api/community/memberships/me:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -913,6 +1122,56 @@ components:
|
||||||
updatedAt:
|
updatedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
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:
|
CreateMeteringPointRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -928,6 +1187,77 @@ components:
|
||||||
required:
|
required:
|
||||||
- atNumber
|
- atNumber
|
||||||
- type
|
- 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:
|
MembershipRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -1093,6 +1423,9 @@ components:
|
||||||
pendingUsers:
|
pendingUsers:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
pendingMemberships:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
totalActiveMemberships:
|
totalActiveMemberships:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|
@ -1112,6 +1445,41 @@ components:
|
||||||
- BEG
|
- BEG
|
||||||
edaEcId:
|
edaEcId:
|
||||||
type: string
|
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:
|
MembershipResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
67
nginx-ssl.conf
Normal file
67
nginx-ssl.conf
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
49
nginx.conf
Normal file
49
nginx.conf
Normal file
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
54
setup-server.sh
Normal file
54
setup-server.sh
Normal file
|
|
@ -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 <your-repo-url>"
|
||||||
|
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"
|
||||||
49
setup-ssl.sh
Normal file
49
setup-ssl.sh
Normal file
|
|
@ -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 <your-domain.at>"
|
||||||
|
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."
|
||||||
Loading…
Reference in a new issue