Projekt umbenannt

This commit is contained in:
2026-04-01 08:09:11 +02:00
parent df9dd07ebc
commit 697105f460
472 changed files with 0 additions and 0 deletions

12
xxxsphere/.gitattributes vendored Normal file
View File

@@ -0,0 +1,12 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# Linux start script should use lf
/gradlew text eol=lf
# These are Windows script files and should use crlf
*.bat text eol=crlf
# Binary files should be left untouched
*.jar binary

5
xxxsphere/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build

View File

@@ -0,0 +1,46 @@
plugins {
java
eclipse
id("org.springframework.boot") version "3.5.12"
id("io.spring.dependency-management") version "1.1.7"
}
group = "de.oaa.xxx"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testCompileOnly("org.projectlombok:lombok")
testAnnotationProcessor("org.projectlombok:lombok")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("commons-codec:commons-codec:1.16.0")
runtimeOnly("com.mysql:mysql-connector-j")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("com.h2database:h2")
}
tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
}
tasks.withType<Test> {
useJUnitPlatform()
}

21
xxxsphere/deploy.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
# Konfiguration
REMOTE_CONTEXT="proxmox-remote"
IMAGE_NAME="xxx-sphere"
TAG="latest"
echo "--- 1. Gradle Build: Erstelle Docker Image lokal ---"
# Dieser Befehl baut die Jar UND das Docker Image direkt in deinem lokalen Docker
./gradlew bootBuildImage --imageName=$IMAGE_NAME:$TAG
echo "--- 2. Transfer: Image zum Proxmox-Server schieben ---"
# Wir 'pipen' das Image direkt über SSH auf den Zielserver
docker save $IMAGE_NAME:$TAG | docker --context $REMOTE_CONTEXT load
echo "--- 3. Remote Deployment: Starten auf Proxmox ---"
# Wir führen Docker Compose direkt im Remote-Kontext aus
# --force-recreate stellt sicher, dass die App mit dem neuen Image neu startet
docker --context $REMOTE_CONTEXT compose up -d --force-recreate
echo "--- Fertig! Die App läuft auf dem Proxmox-Server ---"

View File

@@ -0,0 +1,32 @@
services:
db:
image: mysql:8.0
container_name: mysql-db
restart: always
environment:
MYSQL_DATABASE: xxx_sphere
MYSQL_ROOT_PASSWORD: xxxsphere123!
ports:
- "3306:3306" # <--- Jetzt steht es korrekt alleine!
volumes:
# Format: [Pfad auf dem Proxmox-Host]:[Pfad im Container]
- /mnt/pve_nas/.mysql_data:/var/lib/mysql
app:
image: xxx-sphere:latest
container_name: spring-boot-app
depends_on:
- db
ports:
- "8080:8080"
environment:
# Wir biegen localhost auf den Service-Namen 'db' um
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/xxx_sphere?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
# Hier injizieren wir die Werte für deine Platzhalter
- DB_USER=root
- DB_PASSWORD=xxxsphere123!
# Wartet kurz, bis die DB wirklich bereit ist (optional, aber empfohlen)
restart: on-failure
volumes:
mysql_data:

View File

@@ -0,0 +1,12 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
commons-math3 = "3.6.1"
guava = "33.1.0-jre"
junit-jupiter = "5.10.2"
[libraries]
commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" }
guava = { module = "com.google.guava:guava", version.ref = "guava" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

176
xxxsphere/gradlew vendored Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env sh
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=$(save "$@")
# Collect all arguments for the java command, following the shell quoting and substitution rules
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
fi
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
cd "$(dirname "$0")"
fi
exec "$JAVACMD" "$@"

84
xxxsphere/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,84 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windows variants
if not "%OS%" == "Windows_NT" goto win9xME_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,13 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.9/userguide/multi_project_builds.html in the Gradle documentation.
*/
plugins {
// Apply the foojay-resolver plugin to allow automatic download of JDKs
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "xxxthegame"

View File

@@ -0,0 +1,36 @@
Sammeln von Erfahrung
TODO: Im Time Lock, wenn im Spinning Wheel tasks drin sind, dürfen keine sonst keine Tasks gefordert sein und umgekehrt
Ich kann Spieler einladen zu spielen, dann kriegt die Person eine E-Mail und muss bestätigen, dass es diese PErson ist, sie wird dann ins spiel übernommen
-- Falls fall mit Chastity auftritt wird die Spielpartnerin als Keyholder eingetragen, diese Person darf entscheiden, was für ein Lock das wird.
Hier ein paar Ideen für neue Kartentypen:
Bestrafungskarten
- Straf-Karte Lockee muss eine vorher definierte Strafe erfüllen (ähnlich Task, aber negativer konnotiert)
- Extra-Rot Fügt sofort 2-3 rote Karten hinzu, kein Ziehen möglich
Belohnungskarten
- Bonus-Grün LatestOpeningTime wird auf jetzt gesetzt (sofortige Öffnungsmöglichkeit), aber nur kurz gültig (z.B. 30 Minuten Fenster)
- Karten entfernen Lockee darf eine bestimmte Anzahl roter Karten aus dem Deck entfernen
Ereigniskarten
- Würfel-Karte Zufällige Aktion: 1-2 = Freeze, 3-4 = Nichts, 5-6 = Grüne Karte
- Umkehr-Karte Die nächste Karte hat den umgekehrten Effekt (Rot → Grün, Freeze → Beschleunigung)
- Überraschungs-Karte Community, Keyholder oder Zufalls-Task, je nachdem was gerade konfiguriert ist
Zeitkarten
- Verlängerungs-Karte Verschiebt die latestOpeningtime nach hinten (nur bei Keyholder-Locks sinnvoll)
- Countdown-Karte Setzt einen Timer; wenn die Lockee innerhalb der Zeit eine Aufgabe erledigt, wird eine grüne Karte freigeschaltet
- Hygiene-Skip Nächste Hygiene-Öffnung wird übersprungen/gezählt ohne tatsächliche Öffnung
Soziale Karten
- Verifizierungs-Karte Erzwingt sofort eine Verifikations-Session
- Keyholder-Wahl Keyholder entscheidet frei was passiert (Freitext-Eingabe möglich)
- Community-Entscheid Community stimmt nicht über eine Aufgabe ab, sondern darüber was als nächstes passiert (z.B. Freeze vs. Aufgabe)
Die interessantesten wären wohl Würfel und Countdown, da sie mehr Spannung erzeugen ohne den Ablauf zu sehr zu unterbrechen.

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class XxxThegameApplication {
public static void main(String[] args) {
SpringApplication.run(XxxThegameApplication.class, args);
}
}

View File

@@ -0,0 +1,563 @@
package de.oaa.xxx.admin;
import java.security.Principal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feedback.FeedbackEntity;
import de.oaa.xxx.feedback.FeedbackRepository;
import de.oaa.xxx.feedback.FeedbackStatus;
import de.oaa.xxx.support.SupportUserService;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeDisplay;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import de.oaa.xxx.games.chastity.ttlock.TTLockConfigEntity;
import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository;
import de.oaa.xxx.meldung.MeldungEntity;
import de.oaa.xxx.meldung.MeldungRepository;
import de.oaa.xxx.meldung.MeldungStatus;
import de.oaa.xxx.subscription.SubscriptionType;
import de.oaa.xxx.subscription.UserSubscriptionEntity;
import de.oaa.xxx.subscription.UserSubscriptionRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/admin")
@Transactional
public class AdminController {
private final AdminRepository adminRepository;
private final UserRepository userRepository;
private final UserService userService;
private final MeldungRepository meldungRepository;
private final FeedbackRepository feedbackRepository;
private final SupportUserService supportUserService;
private final AufgabenGruppeRepository aufgabenGruppeRepository;
private final AufgabeRepository aufgabeRepository;
private final StrafeRepository strafeRepository;
private final SperreRepository sperreRepository;
private final FinisherRepository finisherRepository;
private final GruppenAboRepository gruppenAboRepository;
private final ToyRepository toyRepository;
private final TTLockConfigRepository ttLockConfigRepository;
private final UserSubscriptionRepository userSubscriptionRepository;
public AdminController(AdminRepository adminRepository, UserRepository userRepository,
UserService userService,
MeldungRepository meldungRepository,
FeedbackRepository feedbackRepository,
SupportUserService supportUserService,
AufgabenGruppeRepository aufgabenGruppeRepository,
AufgabeRepository aufgabeRepository,
StrafeRepository strafeRepository,
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
GruppenAboRepository gruppenAboRepository,
ToyRepository toyRepository,
TTLockConfigRepository ttLockConfigRepository,
UserSubscriptionRepository userSubscriptionRepository) {
this.adminRepository = adminRepository;
this.userRepository = userRepository;
this.userService = userService;
this.meldungRepository = meldungRepository;
this.feedbackRepository = feedbackRepository;
this.supportUserService = supportUserService;
this.aufgabenGruppeRepository = aufgabenGruppeRepository;
this.aufgabeRepository = aufgabeRepository;
this.strafeRepository = strafeRepository;
this.sperreRepository = sperreRepository;
this.finisherRepository = finisherRepository;
this.gruppenAboRepository = gruppenAboRepository;
this.toyRepository = toyRepository;
this.ttLockConfigRepository = ttLockConfigRepository;
this.userSubscriptionRepository = userSubscriptionRepository;
}
// ── DTOs ─────────────────────────────────────────────────────────────────
record AdminDto(UUID adminId, UUID userId, String userName, AdminRolle rolle, LocalDateTime createdAt) {}
record TtlockConfigDto(String clientId, String clientSecret, String baseUrl) {}
record TtlockConfigRequest(String clientId, String clientSecret, String baseUrl) {}
record MeldungDto(UUID meldungId, UUID melderId, String melderName,
de.oaa.xxx.meldung.MeldungZielTyp zielTyp, UUID zielId,
String grund, LocalDateTime gemeldetAt,
MeldungStatus status, UUID bearbeitetVon, LocalDateTime bearbeitetAt) {}
record CreateAdminRequest(UUID userId, AdminRolle rolle) {}
record StatusRequest(MeldungStatus status) {}
record UserSearchDto(UUID userId, String name) {}
record GiftSubscriptionRequest(UUID userId) {}
record SubscriptionStatusDto(UUID userId, String userName, String subscriptionType,
LocalDate subscribedAt, LocalDate validUntil) {}
record FeedbackDto(UUID feedbackId, String name, String seite, String grund,
String text, LocalDateTime eingegangen, FeedbackStatus status,
String inArbeitVonName) {}
record FeedbackAntwortRequest(String text) {}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private AdminEntity requireAdmin(Principal principal) {
var user = userService.requireUser(principal);
return adminRepository.findByUserId(user.getUserId())
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.FORBIDDEN, "Kein Admin"));
}
private AdminEntity requireSuperAdmin(Principal principal) {
AdminEntity admin = requireAdmin(principal);
if (admin.getRolle() != AdminRolle.SUPERADMIN) {
throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.FORBIDDEN, "Kein Superadmin");
}
return admin;
}
private AdminDto toDto(AdminEntity e) {
String name = userRepository.findById(e.getUserId()).map(UserEntity::getName).orElse("?");
return new AdminDto(e.getAdminId(), e.getUserId(), name, e.getRolle(), e.getCreatedAt());
}
private MeldungDto toMeldungDto(MeldungEntity e) {
String melderName = userRepository.findById(e.getMelderId()).map(UserEntity::getName).orElse("?");
return new MeldungDto(e.getMeldungId(), e.getMelderId(), melderName,
e.getZielTyp(), e.getZielId(), e.getGrund(), e.getGemeldetAt(),
e.getStatus(), e.getBearbeitetVon(), e.getBearbeitetAt());
}
// ── /admin/me ────────────────────────────────────────────────────────────
@GetMapping("/me")
public ResponseEntity<AdminDto> me(Principal principal) {
var user = userService.requireUser(principal);
return adminRepository.findByUserId(user.getUserId())
.map(a -> ResponseEntity.ok(toDto(a)))
.orElse(ResponseEntity.status(403).build());
}
// ── Meldungen ────────────────────────────────────────────────────────────
@GetMapping("/meldungen")
public ResponseEntity<List<MeldungDto>> getMeldungen(
@RequestParam(name = "status", required = false) MeldungStatus status,
Principal principal) {
requireAdmin(principal);
List<MeldungEntity> list = status != null
? meldungRepository.findByStatusOrderByGemeldetAtDesc(status)
: meldungRepository.findAllByOrderByGemeldetAtDesc();
return ResponseEntity.ok(list.stream().map(this::toMeldungDto).toList());
}
@PutMapping("/meldungen/{id}")
public ResponseEntity<Void> updateMeldung(@PathVariable("id") UUID id,
@RequestBody StatusRequest body,
Principal principal) {
requireAdmin(principal);
var user = userService.requireUser(principal);
MeldungEntity meldung = meldungRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
meldung.setStatus(body.status());
meldung.setBearbeitetVon(user.getUserId());
meldung.setBearbeitetAt(LocalDateTime.now());
return ResponseEntity.noContent().build();
}
// ── Aufgabengruppen ──────────────────────────────────────────────────────
@GetMapping("/aufgabengruppen")
public ResponseEntity<List<AufgabenGruppe>> getAufgabengruppen(Principal principal) {
requireAdmin(principal);
List<AufgabenGruppeEntity> list = aufgabenGruppeRepository
.findByUserIdIsNull(PageRequest.of(0, 1000)).getContent();
return ResponseEntity.ok(list.stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList());
}
@PostMapping("/aufgabengruppen")
public ResponseEntity<AufgabenGruppeDisplay> createAufgabengruppe(
@RequestBody AufgabenGruppe gruppe, Principal principal) {
requireAdmin(principal);
gruppe.setUserId(null);
gruppe.setPrivateGruppe(false);
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
aufgabenGruppeRepository.save(entity);
return ResponseEntity.status(201).body(entity.toAufgabenGruppeDisplay());
}
@PutMapping("/aufgabengruppen/{id}")
public ResponseEntity<Void> updateAufgabengruppe(@PathVariable("id") UUID id,
@RequestBody AufgabenGruppe gruppe,
Principal principal) {
requireAdmin(principal);
AufgabenGruppeEntity entity = aufgabenGruppeRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
entity.setName(gruppe.getName());
entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon());
if (gruppe.getBild() != null) {
entity.setBild(java.util.Base64.getDecoder().decode(gruppe.getBild()));
}
return ResponseEntity.noContent().build();
}
@DeleteMapping("/aufgabengruppen/{id}")
public ResponseEntity<Void> deleteAufgabengruppe(@PathVariable("id") UUID id, Principal principal) {
requireAdmin(principal);
AufgabenGruppeEntity entity = aufgabenGruppeRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
if (entity.getUserId() != null) {
return ResponseEntity.status(403).build(); // Nur System-Gruppen
}
gruppenAboRepository.deleteByAufgabenGruppe(entity);
aufgabeRepository.deleteAll(entity.getAufgaben());
strafeRepository.deleteAll(entity.getStrafen());
sperreRepository.deleteAll(entity.getSperren());
finisherRepository.deleteAll(entity.getFinisher());
aufgabenGruppeRepository.delete(entity);
return ResponseEntity.noContent().build();
}
// ── Item verschieben ─────────────────────────────────────────────────────
@PutMapping("/aufgabengruppen/items/{kind}/{itemId}/move")
public ResponseEntity<Void> moveItem(
@PathVariable("kind") String kind,
@PathVariable("itemId") UUID itemId,
@RequestParam("targetGruppeId") UUID targetGruppeId,
Principal principal) {
requireAdmin(principal);
AufgabenGruppeEntity targetGruppe = aufgabenGruppeRepository.findById(targetGruppeId)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND, "Zielgruppe nicht gefunden"));
switch (kind) {
case "aufgabe" -> aufgabeRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
aufgabeRepository.save(e);
});
case "strafe" -> strafeRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
strafeRepository.save(e);
});
case "zeitstrafe" -> sperreRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
sperreRepository.save(e);
});
case "finisher" -> finisherRepository.findById(itemId).ifPresent(e -> {
e.setAufgabenGruppe(targetGruppe);
finisherRepository.save(e);
});
default -> { return ResponseEntity.badRequest().build(); }
}
return ResponseEntity.noContent().build();
}
// ── Toys ─────────────────────────────────────────────────────────────────
@GetMapping("/toys")
public ResponseEntity<List<Toy>> getToys(Principal principal) {
requireAdmin(principal);
List<ToyEntity> list = toyRepository.findByUserIdIsNull(PageRequest.of(0, 1000, Sort.by(Sort.Direction.ASC, "name"))).getContent();
return ResponseEntity.ok(list.stream().map(ToyEntity::toToy).toList());
}
@PostMapping("/toys")
public ResponseEntity<Toy> createToy(@RequestBody Toy toy, Principal principal) {
requireAdmin(principal);
if (toyRepository.existsByNameIgnoreCaseAndUserIdIsNull(toy.getName())) {
return ResponseEntity.status(409).build();
}
toy.setUserId(null);
ToyEntity entity = ToyEntity.create(toy);
toyRepository.save(entity);
return ResponseEntity.status(201).body(entity.toToy());
}
@PutMapping("/toys/{id}")
public ResponseEntity<Void> updateToy(@PathVariable("id") UUID id,
@RequestBody Toy toy, Principal principal) {
requireAdmin(principal);
ToyEntity entity = toyRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
if (toyRepository.existsByNameIgnoreCaseAndUserIdIsNullAndToyIdNot(toy.getName(), id)) {
return ResponseEntity.status(409).build();
}
entity.setName(toy.getName());
entity.setBeschreibung(toy.getBeschreibung());
if (toy.getBild() != null) {
entity.setBild(java.util.Base64.getDecoder().decode(toy.getBild()));
}
return ResponseEntity.noContent().build();
}
@DeleteMapping("/toys/{id}")
public ResponseEntity<Void> deleteToy(@PathVariable("id") UUID id, Principal principal) {
requireAdmin(principal);
ToyEntity entity = toyRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
long usage = toyRepository.countAufgabeUsage(id)
+ toyRepository.countStrafeUsage(id)
+ toyRepository.countSperreUsage(id);
if (usage > 0) {
return ResponseEntity.status(409).build();
}
toyRepository.delete(entity);
return ResponseEntity.noContent().build();
}
// ── Benutzer-Suche (nur SUPERADMIN) ──────────────────────────────────────
@GetMapping("/users/search")
public ResponseEntity<List<UserSearchDto>> searchUsers(
@RequestParam String q, Principal principal) {
requireSuperAdmin(principal);
if (q == null || q.isBlank()) return ResponseEntity.ok(List.of());
List<UserEntity> users = userRepository.findByNameContainingIgnoreCase(q.trim());
return ResponseEntity.ok(users.stream()
.filter(u -> !adminRepository.existsByUserId(u.getUserId()))
.limit(20)
.map(u -> new UserSearchDto(u.getUserId(), u.getName()))
.toList());
}
@GetMapping("/users/search/all")
public ResponseEntity<List<UserSearchDto>> searchAllUsers(
@RequestParam String q, Principal principal) {
requireSuperAdmin(principal);
if (q == null || q.isBlank()) return ResponseEntity.ok(List.of());
List<UserEntity> users = userRepository.findByNameContainingIgnoreCase(q.trim());
return ResponseEntity.ok(users.stream()
.limit(20)
.map(u -> new UserSearchDto(u.getUserId(), u.getName()))
.toList());
}
// ── Admin-Verwaltung (nur SUPERADMIN) ────────────────────────────────────
@GetMapping("/admins")
public ResponseEntity<List<AdminDto>> getAdmins(Principal principal) {
requireSuperAdmin(principal);
return ResponseEntity.ok(adminRepository.findAll().stream().map(this::toDto).toList());
}
@PostMapping("/admins")
public ResponseEntity<AdminDto> createAdmin(@RequestBody CreateAdminRequest request, Principal principal) {
requireSuperAdmin(principal);
if (!userRepository.existsById(request.userId())) {
return ResponseEntity.status(404).build();
}
if (adminRepository.existsByUserId(request.userId())) {
return ResponseEntity.status(409).build();
}
AdminEntity entity = AdminEntity.create(request.userId(), request.rolle());
adminRepository.save(entity);
return ResponseEntity.status(201).body(toDto(entity));
}
@DeleteMapping("/admins/{id}")
public ResponseEntity<Void> deleteAdmin(@PathVariable("id") UUID id, Principal principal) {
var requestingUser = userService.requireUser(principal);
requireSuperAdmin(principal);
AdminEntity entity = adminRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
if (entity.getUserId().equals(requestingUser.getUserId())) {
return ResponseEntity.status(400).build(); // Selbstlöschung verhindern
}
adminRepository.delete(entity);
return ResponseEntity.noContent().build();
}
// ── Abonnement verschenken (nur SUPERADMIN) ──────────────────────────────
@GetMapping("/subscriptions")
public ResponseEntity<List<SubscriptionStatusDto>> getAllSubscriptions(Principal principal) {
requireSuperAdmin(principal);
var activeSubscriptions = userSubscriptionRepository
.findByValidUntilGreaterThanEqualOrderByValidUntilDesc(LocalDate.now());
return ResponseEntity.ok(activeSubscriptions.stream().map(sub -> {
String name = userRepository.findById(sub.getUserId()).map(UserEntity::getName).orElse("?");
return new SubscriptionStatusDto(sub.getUserId(), name,
sub.getSubscriptionType().name(), sub.getSubscribedAt(), sub.getValidUntil());
}).toList());
}
@GetMapping("/subscriptions/user/{userId}")
public ResponseEntity<SubscriptionStatusDto> getSubscriptionStatus(
@PathVariable UUID userId, Principal principal) {
requireSuperAdmin(principal);
UserEntity user = userRepository.findById(userId).orElse(null);
if (user == null) return ResponseEntity.notFound().build();
var sub = userSubscriptionRepository
.findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(userId, LocalDate.now())
.orElse(null);
return ResponseEntity.ok(new SubscriptionStatusDto(
userId, user.getName(),
sub != null ? sub.getSubscriptionType().name() : "STANDARD",
sub != null ? sub.getSubscribedAt() : null,
sub != null ? sub.getValidUntil() : null
));
}
@PostMapping("/subscriptions/gift")
public ResponseEntity<SubscriptionStatusDto> giftSubscription(
@RequestBody GiftSubscriptionRequest request, Principal principal) {
requireSuperAdmin(principal);
UserEntity user = userRepository.findById(request.userId()).orElse(null);
if (user == null) return ResponseEntity.notFound().build();
LocalDate today = LocalDate.now();
var existing = userSubscriptionRepository
.findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(request.userId(), today)
.orElse(null);
UserSubscriptionEntity sub = new UserSubscriptionEntity();
sub.setUserId(request.userId());
sub.setSubscriptionType(SubscriptionType.PREMIUM);
sub.setSubscribedAt(today);
// Hat der User bereits ein aktives Abo: Laufzeit um 1 Monat verlängern
sub.setValidUntil(existing != null
? existing.getValidUntil().plusMonths(1)
: today.plusMonths(1));
sub.setCancellableFrom(null); // Geschenk, kein Vertrag
userSubscriptionRepository.save(sub);
return ResponseEntity.ok(new SubscriptionStatusDto(
request.userId(), user.getName(),
sub.getSubscriptionType().name(),
sub.getSubscribedAt(), sub.getValidUntil()
));
}
// ── Feedback ─────────────────────────────────────────────────────────────
private FeedbackDto toFeedbackDto(FeedbackEntity e) {
String inArbeitName = null;
if (e.getInArbeitVon() != null) {
inArbeitName = userRepository.findById(e.getInArbeitVon())
.map(UserEntity::getName).orElse("?");
}
return new FeedbackDto(e.getFeedbackId(), e.getName(), e.getSeite(), e.getGrund(),
e.getText(), e.getEingegangen(), e.getStatus(), inArbeitName);
}
@GetMapping("/feedback")
public ResponseEntity<java.util.Map<String, List<FeedbackDto>>> getFeedback(Principal principal) {
requireAdmin(principal);
List<FeedbackDto> ungelesen = feedbackRepository
.findByStatusOrderByEingegangenDesc(FeedbackStatus.UNGELESEN)
.stream().map(this::toFeedbackDto).toList();
List<FeedbackDto> inArbeit = feedbackRepository
.findByStatusOrderByEingegangenDesc(FeedbackStatus.IN_ARBEIT)
.stream().map(this::toFeedbackDto).toList();
List<FeedbackDto> beantwortet = feedbackRepository
.findByStatusOrderByEingegangenDesc(FeedbackStatus.BEANTWORTET)
.stream().map(this::toFeedbackDto).toList();
return ResponseEntity.ok(java.util.Map.of(
"ungelesen", ungelesen,
"inArbeit", inArbeit,
"beantwortet", beantwortet));
}
@PutMapping("/feedback/{id}/annehmen")
public ResponseEntity<Void> feedbackAnnehmen(@PathVariable("id") UUID id, Principal principal) {
AdminEntity admin = requireAdmin(principal);
FeedbackEntity f = feedbackRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
if (f.getStatus() == FeedbackStatus.IN_ARBEIT) {
// Bereits von jemand anderem in Arbeit Konflikt
return ResponseEntity.status(409).build();
}
f.setStatus(FeedbackStatus.IN_ARBEIT);
f.setInArbeitVon(admin.getUserId());
feedbackRepository.save(f);
return ResponseEntity.noContent().build();
}
@PostMapping("/feedback/{id}/antworten")
public ResponseEntity<Void> feedbackAntworten(@PathVariable("id") UUID id,
@RequestBody FeedbackAntwortRequest body,
Principal principal) {
requireAdmin(principal);
if (body.text() == null || body.text().isBlank()) {
return ResponseEntity.badRequest().build();
}
FeedbackEntity f = feedbackRepository.findById(id)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.NOT_FOUND));
f.setStatus(FeedbackStatus.BEANTWORTET);
feedbackRepository.save(f);
// DM an den Nutzer senden, falls er eingeloggt war
if (f.getUserId() != null) {
String dm = "Ursprüngliche Nachricht\n" + f.getText() + "\n\nAntwort\n" + body.text();
supportUserService.sendDm(f.getUserId(), dm);
}
return ResponseEntity.noContent().build();
}
// ── TTLock-Konfiguration (nur SUPERADMIN) ─────────────────────────────────
@GetMapping("/ttlock")
public ResponseEntity<TtlockConfigDto> getTtlockConfig(Principal principal) {
requireSuperAdmin(principal);
TTLockConfigEntity cfg = ttLockConfigRepository.findById(1L)
.orElse(new TTLockConfigEntity());
return ResponseEntity.ok(new TtlockConfigDto(
cfg.getClientId(),
cfg.getClientSecret(),
cfg.getBaseUrl()
));
}
@PutMapping("/ttlock")
public ResponseEntity<Void> saveTtlockConfig(@RequestBody TtlockConfigRequest body, Principal principal) {
requireSuperAdmin(principal);
TTLockConfigEntity cfg = ttLockConfigRepository.findById(1L)
.orElseGet(TTLockConfigEntity::new);
cfg.setClientId(body.clientId());
cfg.setClientSecret(body.clientSecret());
cfg.setBaseUrl(body.baseUrl());
ttLockConfigRepository.save(cfg);
return ResponseEntity.noContent().build();
}
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.admin;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "admin")
public class AdminEntity {
@Id
@Column
private UUID adminId;
@Column(nullable = false)
private UUID userId;
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
private AdminRolle rolle;
@Column(nullable = false)
private LocalDateTime createdAt;
public static AdminEntity create(UUID userId, AdminRolle rolle) {
AdminEntity entity = new AdminEntity();
entity.setAdminId(UUID.randomUUID());
entity.setUserId(userId);
entity.setRolle(rolle);
entity.setCreatedAt(LocalDateTime.now());
return entity;
}
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.admin;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface AdminRepository extends JpaRepository<AdminEntity, UUID> {
Optional<AdminEntity> findByUserId(UUID userId);
boolean existsByUserId(UUID userId);
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.admin;
public enum AdminRolle {
ADMIN, SUPERADMIN
}

View File

@@ -0,0 +1,48 @@
package de.oaa.xxx.config;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
@Component
public class JwtFilter extends OncePerRequestFilter {
private final JwtService jwtService;
public JwtFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("jwt".equals(cookie.getName())) {
try {
Claims claims = jwtService.validateAndGetClaims(cookie.getValue());
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
claims.getSubject(), null, Collections.emptyList()
);
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
// Ungültiger oder abgelaufener Token ohne Authentifizierung weiter
}
break;
}
}
}
chain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,49 @@
package de.oaa.xxx.config;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Date;
@Service
public class JwtService {
private static final long EXPIRATION_MS = 24L * 60 * 60 * 1000; // 24 Stunden
private final PrivateKey privateKey;
private final PublicKey publicKey;
public JwtService(
@Value("${jwt.keystore.path}") Resource keystoreResource,
@Value("${jwt.keystore.password}") String password,
@Value("${jwt.keystore.alias}") String alias) throws Exception {
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(keystoreResource.getInputStream(), password.toCharArray());
this.privateKey = (PrivateKey) keyStore.getKey(alias, password.toCharArray());
this.publicKey = keyStore.getCertificate(alias).getPublicKey();
}
public String generateToken(String email, String name) {
return Jwts.builder()
.subject(email)
.claim("name", name)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
.signWith(privateKey)
.compact();
}
public Claims validateAndGetClaims(String token) {
return Jwts.parser()
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@@ -0,0 +1,36 @@
package de.oaa.xxx.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@Component
public class SchemaMigration implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(SchemaMigration.class);
private final JdbcTemplate jdbc;
public SchemaMigration(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void run(ApplicationArguments args) {
try {
String columnType = jdbc.queryForObject(
"SELECT DATA_TYPE FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'verification' AND COLUMN_NAME = 'image'",
String.class);
if ("blob".equalsIgnoreCase(columnType)) {
log.info("Migrating verification.image from BLOB to MEDIUMBLOB");
jdbc.execute("ALTER TABLE verification MODIFY COLUMN image MEDIUMBLOB");
log.info("Migration complete");
}
} catch (Exception e) {
log.warn("Schema migration check failed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,119 @@
package de.oaa.xxx.config;
import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtFilter jwtFilter;
public SecurityConfig(JwtFilter jwtFilter) {
this.jwtFilter = jwtFilter;
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) ->
response.sendRedirect("/login.html")))
.authorizeHttpRequests(auth -> auth
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers("/").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers("/api").permitAll()
.requestMatchers("/userhome.html").authenticated()
.requestMatchers("/games/chastity/toys.html").authenticated()
.requestMatchers("/games/bdsm/aufgaben.html").authenticated()
.requestMatchers("/games/chastity/entdecken.html").authenticated()
.requestMatchers("/konto/profile.html").authenticated()
.requestMatchers("/games/vanilla/infovanilla.html").authenticated()
.requestMatchers("/games/bdsm/infobdsm.html").authenticated()
.requestMatchers("/games/chastity/infochastity.html").authenticated()
.requestMatchers("/games/vanilla/sessionvanilla.html").authenticated()
.requestMatchers("/sessionbdsm.html").authenticated()
.requestMatchers("/games/chastity/sessionchastity.html").authenticated()
.requestMatchers("/games/chastity/neulock.html").authenticated()
.requestMatchers("/games/chastity/activelock.html").authenticated()
.requestMatchers("/sessionbdsmtoys.html").authenticated()
.requestMatchers("/sessionbdsmingame.html").authenticated()
.requestMatchers("/games/bdsm/neubdsm.html").authenticated()
.requestMatchers("/games/bdsm/bdsmingame.html").authenticated()
.requestMatchers("/community/personen-suchen.html").authenticated()
.requestMatchers("/community/freunde.html").authenticated()
.requestMatchers("/community/nachrichten.html").authenticated()
.requestMatchers("/community/benutzer.html").authenticated()
.requestMatchers("/community/gruppen.html").authenticated()
.requestMatchers("/community/gruppe.html").authenticated()
.requestMatchers("/community/feed.html").authenticated()
.requestMatchers("/admin/admin.html").authenticated()
.requestMatchers("/games/chastity/communityvotes.html").authenticated()
.requestMatchers("/games/chastity/keyholder.html").authenticated()
.requestMatchers("/games/chastity/keyholder-finden.html").authenticated()
.requestMatchers("/games/chastity/meine-locks.html").authenticated()
.requestMatchers("/games/chastity/entdecken-vorlagen.html").authenticated()
.requestMatchers("/games/chastity/unlock-history.html").authenticated()
.requestMatchers("/games/common/einladungen.html").authenticated()
.requestMatchers("/games/chastity/joinlock.html").authenticated()
.requestMatchers("/community/benachrichtigungen.html").authenticated()
.requestMatchers("/community/abonnements.html").authenticated()
.requestMatchers("/gruppen/**").authenticated()
.requestMatchers("/feed/**").authenticated()
.requestMatchers("/notifications/**").authenticated()
.requestMatchers("/events/**").authenticated()
.requestMatchers("/*.html").permitAll()
.requestMatchers("/**/*.html").permitAll()
.requestMatchers("/help/*.html").permitAll()
.requestMatchers("/css/**").permitAll()
.requestMatchers("/js/**").permitAll()
.requestMatchers("/images/**").permitAll()
.requestMatchers("/img/**").permitAll()
.requestMatchers("/favicon.ico").permitAll()
.requestMatchers("/audio/**").permitAll()
.requestMatchers("/*.png").permitAll()
.requestMatchers("/*.jpg").permitAll()
.requestMatchers("/*.svg").permitAll()
.requestMatchers("/*.webp").permitAll()
.requestMatchers(HttpMethod.GET, "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/ttlock").permitAll()
.requestMatchers(HttpMethod.POST, "/login").permitAll()
.requestMatchers(HttpMethod.GET, "/login/publickey").permitAll()
.requestMatchers(HttpMethod.GET, "/login/logout").permitAll()
.requestMatchers(HttpMethod.POST, "/user").permitAll()
.requestMatchers(HttpMethod.GET, "/registration").permitAll()
.requestMatchers(HttpMethod.POST, "/registration").permitAll()
.requestMatchers(HttpMethod.GET, "/activation").permitAll()
.requestMatchers(HttpMethod.GET, "/activation/**").permitAll()
.requestMatchers(HttpMethod.POST, "/password-reset/request").permitAll()
.requestMatchers(HttpMethod.POST, "/password-reset/confirm").permitAll()
.requestMatchers(HttpMethod.GET, "/email-change/**").permitAll()
.requestMatchers(HttpMethod.GET, "/keyholder/invitation/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/feedback").permitAll()
.requestMatchers(HttpMethod.POST, "/filler").permitAll()
.requestMatchers(HttpMethod.POST, "/api/ttlock/callback").permitAll()
.requestMatchers(HttpMethod.GET, "/api/ttlock/callback").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class StringListConverter implements AttributeConverter<List<String>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<String> list) {
if (list == null || list.isEmpty()) return null;
try {
return mapper.writeValueAsString(list);
} catch (Exception e) {
return null;
}
}
@Override
public List<String> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return List.of();
try {
if (!json.startsWith("[")) {
// Legacy: single base64 string
return List.of(json);
}
return mapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return List.of();
}
}
}

View File

@@ -0,0 +1,54 @@
package de.oaa.xxx.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Serves /css/variables.css dynamically from application.properties theme settings.
* All HTML pages load this first, so changing app.theme.* immediately updates the whole UI.
*/
@RestController
public class ThemeController {
@Value("${app.theme.color-bg:#1a1a2e}")
private String colorBg;
@Value("${app.theme.color-card:#16213e}")
private String colorCard;
@Value("${app.theme.color-primary:#e94560}")
private String colorPrimary;
@Value("${app.theme.color-secondary:#0f3460}")
private String colorSecondary;
@Value("${app.theme.color-text:#eeeeee}")
private String colorText;
@Value("${app.theme.color-muted:#888888}")
private String colorMuted;
@Value("${app.theme.color-success:#2ecc71}")
private String colorSuccess;
/** Mobile breakpoint in px (unitless integer). Used by sidebar.js and lightbox layout. */
@Value("${app.theme.breakpoint-mobile:768}")
private int breakpointMobile;
@GetMapping(value = "/css/variables.css", produces = "text/css")
public String variables() {
return """
:root {
--color-bg: %s;
--color-card: %s;
--color-primary: %s;
--color-secondary: %s;
--color-text: %s;
--color-muted: %s;
--color-success: %s;
--breakpoint-mobile: %d;
}
""".formatted(colorBg, colorCard, colorPrimary, colorSecondary, colorText, colorMuted, colorSuccess, breakpointMobile);
}
}

View File

@@ -0,0 +1,125 @@
package de.oaa.xxx.emailchange;
import de.oaa.xxx.mail.Email;
import de.oaa.xxx.mail.MailService;
import de.oaa.xxx.mail.MailTemplateService;
import de.oaa.xxx.registration.RegistrationRepository;
import de.oaa.xxx.user.UserRepository;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.security.Principal;
import java.util.UUID;
import java.util.regex.Pattern;
@RestController
@RequestMapping("/email-change")
public class EmailChangeController {
private static final Logger LOGGER = LoggerFactory.getLogger(EmailChangeController.class);
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$");
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private final EmailChangeRepository emailChangeRepository;
private final UserRepository userRepository;
private final RegistrationRepository registrationRepository;
private final MailService mailService;
private final MailTemplateService mailTemplateService;
public EmailChangeController(EmailChangeRepository emailChangeRepository,
UserRepository userRepository,
RegistrationRepository registrationRepository,
MailService mailService,
MailTemplateService mailTemplateService) {
this.emailChangeRepository = emailChangeRepository;
this.userRepository = userRepository;
this.registrationRepository = registrationRepository;
this.mailService = mailService;
this.mailTemplateService = mailTemplateService;
}
record EmailChangeRequest(String newEmail) {}
@PostMapping
public ResponseEntity<Void> requestChange(@RequestBody EmailChangeRequest request, Principal principal) {
String currentEmail = principal.getName();
String newEmail = request.newEmail();
if (newEmail == null || newEmail.isBlank() || !EMAIL_PATTERN.matcher(newEmail).matches()) {
return ResponseEntity.badRequest().build();
}
if (userRepository.findByEmail(newEmail).isPresent()
|| registrationRepository.findByEmail(newEmail).isPresent()) {
return ResponseEntity.status(409).build();
}
// Remove any pending request for this user
emailChangeRepository.findByUserEmail(currentEmail)
.ifPresent(emailChangeRepository::delete);
var user = userRepository.findByEmail(currentEmail);
if (user.isEmpty()) return ResponseEntity.status(401).build();
EmailChangeEntity entity = EmailChangeEntity.create(currentEmail, newEmail);
emailChangeRepository.save(entity);
Email email = new Email();
email.setTitel("Bitte bestätige deine neue E-Mail-Adresse");
email.setEmailAdresse(newEmail);
String confirmLink = baseUrl + "/email-change/" + entity.getTokenId().toString();
email.setText(mailTemplateService.buildEmailChangeMail(user.get().getName(), confirmLink, newEmail));
if (!mailService.send(email)) {
emailChangeRepository.delete(entity);
return ResponseEntity.internalServerError().build();
}
return ResponseEntity.status(202).build();
}
@GetMapping("/{token}")
public void confirm(@PathVariable String token, HttpServletResponse response) throws IOException {
UUID tokenId;
try {
tokenId = UUID.fromString(token);
} catch (IllegalArgumentException e) {
response.sendRedirect("/login.html");
return;
}
var entity = emailChangeRepository.findById(tokenId);
if (entity.isEmpty()) {
response.sendRedirect("/login.html");
return;
}
var user = userRepository.findByEmail(entity.get().getUserEmail());
if (user.isPresent()) {
user.get().setEmail(entity.get().getNewEmail());
userRepository.save(user.get());
LOGGER.info("E-Mail geändert von {} zu {}", entity.get().getUserEmail(), entity.get().getNewEmail());
}
emailChangeRepository.delete(entity.get());
// Clear JWT cookie so user must log in with new email
ResponseCookie cookie = ResponseCookie.from("jwt", "")
.httpOnly(true)
.sameSite("Strict")
.path("/")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
response.sendRedirect("/login.html?emailChanged=1");
}
}

View File

@@ -0,0 +1,45 @@
package de.oaa.xxx.emailchange;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "email_change")
public class EmailChangeEntity {
@Id
@Column
private UUID tokenId;
@Column
private String userEmail;
@Column
private String newEmail;
@Column
private LocalDateTime createdAt;
@Override
public String toString() {
return "EmailChangeEntity[tokenId=" + tokenId + ", userEmail=" + userEmail + ", newEmail=" + newEmail + ", createdAt=" + createdAt + "]";
}
public static EmailChangeEntity create(String userEmail, String newEmail) {
EmailChangeEntity entity = new EmailChangeEntity();
entity.setTokenId(UUID.randomUUID());
entity.setUserEmail(userEmail);
entity.setNewEmail(newEmail);
entity.setCreatedAt(LocalDateTime.now());
return entity;
}
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.emailchange;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface EmailChangeRepository extends JpaRepository<EmailChangeEntity, UUID> {
Optional<EmailChangeEntity> findByUserEmail(String userEmail);
}

View File

@@ -0,0 +1,408 @@
package de.oaa.xxx.feed;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Slice;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.feed.dto.FeedItemDto;
import de.oaa.xxx.feed.dto.FeedPostRequest;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import de.oaa.xxx.feed.repository.FeedPostLikeRepository;
import de.oaa.xxx.feed.repository.FeedPostOptionRepository;
import de.oaa.xxx.feed.repository.FeedPostRepository;
import de.oaa.xxx.feed.repository.FeedPostVoteRepository;
import de.oaa.xxx.gruppe.BeitragTyp;
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity;
import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity;
import de.oaa.xxx.gruppe.repository.GruppeRepository;
import de.oaa.xxx.gruppe.repository.GruppenbeitragLikeRepository;
import de.oaa.xxx.gruppe.repository.GruppenbeitragRepository;
import de.oaa.xxx.gruppe.repository.GruppenmitgliedRepository;
import de.oaa.xxx.gruppe.repository.UmfrageOptionRepository;
import de.oaa.xxx.gruppe.repository.UmfrageStimmeRepository;
import de.oaa.xxx.social.LikeService;
import de.oaa.xxx.social.entity.FriendshipEntity;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.social.repository.KommentarRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/feed")
public class FeedController {
private static final Logger LOGGER = LoggerFactory.getLogger(FeedController.class);
private final FeedPostRepository feedPostRepository;
private final FeedPostLikeRepository feedPostLikeRepository;
private final FeedPostOptionRepository feedPostOptionRepository;
private final FeedPostVoteRepository feedPostVoteRepository;
private final FriendshipRepository friendshipRepository;
private final GruppenmitgliedRepository mitgliedRepository;
private final GruppenbeitragRepository gruppenbeitragRepository;
private final UmfrageOptionRepository umfrageOptionRepository;
private final UmfrageStimmeRepository umfrageStimmeRepository;
private final GruppenbeitragLikeRepository gruppenbeitragLikeRepository;
private final GruppeRepository gruppeRepository;
private final KommentarRepository kommentarRepository;
private final UserRepository userRepository;
private final UserService userService;
private final LikeService likeService;
public FeedController(FeedPostRepository feedPostRepository,
FeedPostLikeRepository feedPostLikeRepository,
FeedPostOptionRepository feedPostOptionRepository,
FeedPostVoteRepository feedPostVoteRepository,
FriendshipRepository friendshipRepository,
GruppenmitgliedRepository mitgliedRepository,
GruppenbeitragRepository gruppenbeitragRepository,
UmfrageOptionRepository umfrageOptionRepository,
UmfrageStimmeRepository umfrageStimmeRepository,
GruppenbeitragLikeRepository gruppenbeitragLikeRepository,
GruppeRepository gruppeRepository,
KommentarRepository kommentarRepository,
UserRepository userRepository,
UserService userService,
LikeService likeService) {
this.feedPostRepository = feedPostRepository;
this.feedPostLikeRepository = feedPostLikeRepository;
this.feedPostOptionRepository = feedPostOptionRepository;
this.feedPostVoteRepository = feedPostVoteRepository;
this.friendshipRepository = friendshipRepository;
this.mitgliedRepository = mitgliedRepository;
this.gruppenbeitragRepository = gruppenbeitragRepository;
this.umfrageOptionRepository = umfrageOptionRepository;
this.umfrageStimmeRepository = umfrageStimmeRepository;
this.gruppenbeitragLikeRepository = gruppenbeitragLikeRepository;
this.gruppeRepository = gruppeRepository;
this.kommentarRepository = kommentarRepository;
this.userRepository = userRepository;
this.userService = userService;
this.likeService = likeService;
}
record FeedPage(List<FeedItemDto> posts, boolean hasMore) {}
record VoteRequest(UUID optionId) {}
// ── POST /feed/posts ──
@PostMapping("/posts")
public ResponseEntity<FeedItemDto> createPost(@RequestBody FeedPostRequest req, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (req.text() == null || req.text().isBlank()) return ResponseEntity.badRequest().build();
BeitragTyp typ;
try {
typ = BeitragTyp.valueOf(req.beitragTyp());
} catch (Exception e) {
return ResponseEntity.badRequest().build();
}
FeedPostEntity post = new FeedPostEntity();
post.setPostId(UUID.randomUUID());
post.setAuthorId(myId);
post.setText(req.text().trim());
post.setBeitragTyp(typ);
post.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null);
post.setBilder(req.bilder() != null ? req.bilder() : List.of());
post.setPublic(req.isPublic());
post.setCreatedAt(LocalDateTime.now());
feedPostRepository.save(post);
LOGGER.info("User {} hat Feed-Post {} erstellt (Typ: {}, public: {})", myId, post.getPostId(), typ, post.isPublic());
if (typ == BeitragTyp.UMFRAGE && req.optionen() != null) {
for (int i = 0; i < req.optionen().size(); i++) {
String optText = req.optionen().get(i);
if (optText == null || optText.isBlank()) continue;
FeedPostOptionEntity opt = new FeedPostOptionEntity();
opt.setOptionId(UUID.randomUUID());
opt.setPostId(post.getPostId());
opt.setText(optText.trim());
opt.setReihenfolge(i);
feedPostOptionRepository.save(opt);
}
}
return ResponseEntity.status(201).body(toFeedItemDtoFromPost(post, myId));
}
// ── GET /feed/mine ──
@GetMapping("/mine")
public ResponseEntity<FeedPage> getMyFeed(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
// Collect friend IDs
List<UUID> friendIds = friendshipRepository
.findFriends(myId, FriendshipEntity.Status.ACCEPTED)
.stream()
.map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId())
.toList();
List<UUID> authorIds = new ArrayList<>(friendIds);
authorIds.add(myId);
// Collect group IDs
List<UUID> gruppeIds = mitgliedRepository.findByUserId(myId)
.stream()
.map(m -> m.getGruppeId())
.toList();
LocalDateTime since = LocalDateTime.now().minusDays(90);
// Fetch feed posts from friends + self
List<FeedPostEntity> feedPosts = feedPostRepository
.findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(authorIds, since);
// Fetch gruppe posts
List<GruppenbeitragEntity> gruppePosts = gruppeIds.isEmpty() ? List.of() :
gruppenbeitragRepository.findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(gruppeIds, since);
// Merge, convert, sort
List<FeedItemDto> merged = Stream.concat(
feedPosts.stream().map(p -> toFeedItemDtoFromPost(p, myId)),
gruppePosts.stream().map(b -> toFeedItemDtoFromGruppe(b, myId))
).sorted(Comparator.comparing(FeedItemDto::createdAt).reversed()).toList();
int from = page * size;
int to = Math.min(from + size, merged.size());
List<FeedItemDto> items = from < merged.size() ? merged.subList(from, to) : List.of();
boolean hasMore = to < merged.size();
return ResponseEntity.ok(new FeedPage(items, hasMore));
}
// ── GET /feed/public ──
@GetMapping("/public")
public ResponseEntity<FeedPage> getPublicFeed(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
Slice<FeedPostEntity> slice = feedPostRepository
.findByIsPublicTrueOrderByCreatedAtDesc(PageRequest.of(page, size));
List<FeedItemDto> items = slice.getContent().stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, slice.hasNext()));
}
// ── GET /feed/user/{userId} ──
@GetMapping("/user/{userId}")
public ResponseEntity<FeedPage> getUserPosts(@PathVariable UUID userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
PageRequest pageable = PageRequest.of(page, size);
List<FeedPostEntity> posts;
if (myId.equals(userId)) {
posts = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, pageable);
} else {
posts = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, pageable);
}
// Check if there's a next page
PageRequest nextPageable = PageRequest.of(page + 1, size);
List<FeedPostEntity> nextPage;
if (myId.equals(userId)) {
nextPage = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, nextPageable);
} else {
nextPage = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, nextPageable);
}
List<FeedItemDto> items = posts.stream()
.map(p -> toFeedItemDtoFromPost(p, myId))
.toList();
return ResponseEntity.ok(new FeedPage(items, !nextPage.isEmpty()));
}
// ── POST /feed/posts/{id}/like ──
@PostMapping("/posts/{id}/like")
public ResponseEntity<Void> toggleLike(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
if (feedPostRepository.findById(id).isEmpty()) return ResponseEntity.notFound().build();
likeService.toggleFeedPostLike(id, myId);
return ResponseEntity.ok().build();
}
// ── POST /feed/posts/{id}/vote ──
@PostMapping("/posts/{id}/vote")
public ResponseEntity<Void> vote(@PathVariable UUID id,
@RequestBody VoteRequest req,
Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get();
var optOpt = feedPostOptionRepository.findById(req.optionId());
if (optOpt.isEmpty() || !optOpt.get().getPostId().equals(id))
return ResponseEntity.badRequest().build();
boolean isMultiChoice = Boolean.TRUE.equals(post.getMultiChoice());
var existingVote = feedPostVoteRepository.findByOptionIdAndUserId(req.optionId(), myId);
if (existingVote.isPresent()) {
feedPostVoteRepository.delete(existingVote.get());
return ResponseEntity.ok().build();
}
if (!isMultiChoice) {
List<FeedPostVoteEntity> existing = feedPostVoteRepository.findByPostIdAndUserId(id, myId);
feedPostVoteRepository.deleteAll(existing);
}
FeedPostVoteEntity vote = new FeedPostVoteEntity();
vote.setStimmeId(UUID.randomUUID());
vote.setOptionId(req.optionId());
vote.setPostId(id);
vote.setUserId(myId);
feedPostVoteRepository.save(vote);
LOGGER.debug("User {} hat für Option {} in Feed-Post {} gestimmt", myId, req.optionId(), id);
return ResponseEntity.ok().build();
}
// ── DELETE /feed/posts/{id} ──
@DeleteMapping("/posts/{id}")
public ResponseEntity<Void> deletePost(@PathVariable UUID id, Principal principal) {
UUID myId = resolveMyId(principal);
if (myId == null) return ResponseEntity.status(401).build();
var postOpt = feedPostRepository.findById(id);
if (postOpt.isEmpty()) return ResponseEntity.notFound().build();
FeedPostEntity post = postOpt.get();
if (!post.getAuthorId().equals(myId)) return ResponseEntity.status(403).build();
feedPostVoteRepository.deleteByPostId(id);
feedPostOptionRepository.deleteByPostId(id);
feedPostLikeRepository.deleteByPostId(id);
var kommentare = kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("FEED_POST", id);
kommentarRepository.deleteAll(kommentare);
feedPostRepository.delete(post);
LOGGER.info("User {} hat Feed-Post {} gelöscht", myId, id);
return ResponseEntity.noContent().build();
}
// ── Helpers ──
private UUID resolveMyId(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal).getUserId();
}
private FeedItemDto toFeedItemDtoFromPost(FeedPostEntity p, UUID myId) {
UserEntity author = userRepository.findById(p.getAuthorId()).orElse(null);
long likeCount = feedPostLikeRepository.countByPostId(p.getPostId());
boolean likedByMe = feedPostLikeRepository.findByPostIdAndUserId(p.getPostId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("FEED_POST", p.getPostId());
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (p.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = feedPostOptionRepository.findByPostIdOrderByReihenfolge(p.getPostId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
feedPostVoteRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = feedPostVoteRepository.findByPostIdAndUserId(p.getPostId(), myId)
.stream()
.map(FeedPostVoteEntity::getOptionId)
.toList();
}
return new FeedItemDto(
p.getPostId(), "FEED",
null, null,
p.getAuthorId(),
author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null,
p.getBeitragTyp().name(), p.getText(), p.getMultiChoice(), p.getBilder(),
p.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
p.isPublic()
);
}
private FeedItemDto toFeedItemDtoFromGruppe(GruppenbeitragEntity b, UUID myId) {
UserEntity author = userRepository.findById(b.getAuthorId()).orElse(null);
long likeCount = gruppenbeitragLikeRepository.countByBeitragId(b.getBeitragId());
boolean likedByMe = gruppenbeitragLikeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId).isPresent();
long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("GROUP_POST", b.getBeitragId());
String gruppeName = gruppeRepository.findById(b.getGruppeId())
.map(g -> g.getName())
.orElse("Gruppe");
List<UmfrageOptionDto> optionen = List.of();
List<UUID> myVoteOptionIds = List.of();
if (b.getBeitragTyp() == BeitragTyp.UMFRAGE) {
optionen = umfrageOptionRepository.findByBeitragIdOrderByReihenfolge(b.getBeitragId())
.stream()
.map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(),
umfrageStimmeRepository.countByOptionId(o.getOptionId())))
.toList();
myVoteOptionIds = umfrageStimmeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId)
.stream()
.map(UmfrageStimmeEntity::getOptionId)
.toList();
}
return new FeedItemDto(
b.getBeitragId(), "GROUP",
b.getGruppeId(), gruppeName,
b.getAuthorId(),
author != null ? author.getName() : "Unbekannt",
author != null ? author.getProfilePicture() : null,
b.getBeitragTyp().name(), b.getText(), b.getMultiChoice(), b.getBilder(),
b.getCreatedAt(),
likeCount, likedByMe, kommentarCount,
optionen, myVoteOptionIds,
false
);
}
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.feed.dto;
import de.oaa.xxx.gruppe.dto.UmfrageOptionDto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public record FeedItemDto(
UUID postId,
String postType, // "FEED" | "GROUP"
UUID gruppeId,
String gruppeName,
UUID authorId,
String authorName,
String authorPicture,
String beitragTyp,
String text,
Boolean multiChoice,
List<String> bilder,
LocalDateTime createdAt,
long likeCount,
boolean likedByMe,
long kommentarCount,
List<UmfrageOptionDto> optionen,
List<UUID> myVoteOptionIds,
boolean isPublic
) {}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.feed.dto;
import java.util.List;
public record FeedPostRequest(
String beitragTyp,
String text,
Boolean multiChoice,
List<String> optionen,
List<String> bilder,
boolean isPublic
) {}

View File

@@ -0,0 +1,45 @@
package de.oaa.xxx.feed.entity;
import de.oaa.xxx.config.StringListConverter;
import de.oaa.xxx.gruppe.BeitragTyp;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "feed_post")
public class FeedPostEntity {
@Id
@Column
private UUID postId;
@Column(nullable = false)
private UUID authorId;
@Column(columnDefinition = "TEXT")
private String text;
@Convert(converter = StringListConverter.class)
@Column(name = "bild", columnDefinition = "MEDIUMTEXT")
private List<String> bilder;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private BeitragTyp beitragTyp;
@Column
private Boolean multiChoice;
@Column(nullable = false)
private boolean isPublic;
@Column(nullable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "feed_post_like", uniqueConstraints = {
@UniqueConstraint(columnNames = {"postId", "userId"})
})
public class FeedPostLikeEntity {
@Id
@Column
private UUID likeId;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private UUID userId;
@Column(nullable = false)
private LocalDateTime likedAt;
}

View File

@@ -0,0 +1,27 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "feed_post_option")
public class FeedPostOptionEntity {
@Id
@Column
private UUID optionId;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private String text;
@Column(nullable = false)
private int reihenfolge;
}

View File

@@ -0,0 +1,27 @@
package de.oaa.xxx.feed.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "feed_post_vote")
public class FeedPostVoteEntity {
@Id
@Column
private UUID stimmeId;
@Column(nullable = false)
private UUID optionId;
@Column(nullable = false)
private UUID postId;
@Column(nullable = false)
private UUID userId;
}

View File

@@ -0,0 +1,18 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostLikeEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import java.util.UUID;
public interface FeedPostLikeRepository extends JpaRepository<FeedPostLikeEntity, UUID> {
Optional<FeedPostLikeEntity> findByPostIdAndUserId(UUID postId, UUID userId);
long countByPostId(UUID postId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostOptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
public interface FeedPostOptionRepository extends JpaRepository<FeedPostOptionEntity, UUID> {
List<FeedPostOptionEntity> findByPostIdOrderByReihenfolge(UUID postId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,25 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostEntity;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface FeedPostRepository extends JpaRepository<FeedPostEntity, UUID> {
Slice<FeedPostEntity> findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable);
List<FeedPostEntity> findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(List<UUID> authorIds, LocalDateTime since);
List<FeedPostEntity> findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(UUID authorId, Pageable pageable);
List<FeedPostEntity> findByAuthorIdOrderByCreatedAtDesc(UUID authorId, Pageable pageable);
@Transactional
void deleteByAuthorId(UUID authorId);
}

View File

@@ -0,0 +1,21 @@
package de.oaa.xxx.feed.repository;
import de.oaa.xxx.feed.entity.FeedPostVoteEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface FeedPostVoteRepository extends JpaRepository<FeedPostVoteEntity, UUID> {
List<FeedPostVoteEntity> findByPostIdAndUserId(UUID postId, UUID userId);
Optional<FeedPostVoteEntity> findByOptionIdAndUserId(UUID optionId, UUID userId);
long countByOptionId(UUID optionId);
@Transactional
void deleteByPostId(UUID postId);
}

View File

@@ -0,0 +1,89 @@
package de.oaa.xxx.feedback;
import de.oaa.xxx.mail.Email;
import de.oaa.xxx.mail.MailService;
import de.oaa.xxx.support.SupportUserService;
import de.oaa.xxx.user.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.UUID;
@RestController
@RequestMapping("/api/feedback")
public class FeedbackController {
private final MailService mailService;
private final FeedbackRepository feedbackRepository;
private final UserRepository userRepository;
private final SupportUserService supportUserService;
public FeedbackController(MailService mailService,
FeedbackRepository feedbackRepository,
UserRepository userRepository,
SupportUserService supportUserService) {
this.mailService = mailService;
this.feedbackRepository = feedbackRepository;
this.userRepository = userRepository;
this.supportUserService = supportUserService;
}
record FeedbackRequest(String name, String seite, String grund, String text) {}
@PostMapping
public ResponseEntity<Void> send(@RequestBody FeedbackRequest req, Principal principal) {
if (req.text() == null || req.text().isBlank() || req.text().length() < 10 || req.text().length() > 1000) {
return ResponseEntity.badRequest().build();
}
// Eingeloggten User ermitteln (optional)
UUID userId = null;
if (principal != null) {
userId = userRepository.findByEmail(principal.getName())
.map(u -> u.getUserId()).orElse(null);
}
FeedbackEntity entity = new FeedbackEntity();
entity.setUserId(userId);
entity.setName(req.name());
entity.setSeite(req.seite());
entity.setGrund(req.grund());
entity.setText(req.text());
entity.setEingegangen(LocalDateTime.now());
entity.setStatus(FeedbackStatus.UNGELESEN);
feedbackRepository.save(entity);
// Bestätigungs-DM an eingeloggten Nutzer
if (userId != null) {
supportUserService.sendDm(userId,
"Vielen Dank für dein Feedback! ✉️\n\n" +
"Wir haben deine Nachricht erhalten und werden uns so schnell wie möglich darum kümmern.\n\n" +
"Bitte antworte nicht auf diese Nachricht du kannst uns jederzeit über " +
"Kontakt & Feedback erneut erreichen.");
}
try {
Email email = new Email();
email.setEmailAdresse("kontakt@xxx-sphere.de");
email.setTitel("[xXx Sphere] " + esc(req.grund()));
email.setText(
"<b>Von:</b> " + esc(req.name()) + "<br>" +
"<b>Seite:</b> " + esc(req.seite()) + "<br>" +
"<b>Grund:</b> " + esc(req.grund()) + "<br><br>" +
"<b>Nachricht:</b><br>" + esc(req.text()).replace("\n", "<br>")
);
mailService.send(email);
} catch (Exception e) {
// Mail-Server nicht erreichbar Eintrag ist bereits gespeichert
}
return ResponseEntity.ok().build();
}
private String esc(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}

View File

@@ -0,0 +1,38 @@
package de.oaa.xxx.feedback;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "feedback")
@Getter
@Setter
public class FeedbackEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID feedbackId;
/** Eingeloggter Nutzer null wenn Gast */
private UUID userId;
private String name;
private String seite;
private String grund;
@Column(columnDefinition = "TEXT")
private String text;
private LocalDateTime eingegangen;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private FeedbackStatus status = FeedbackStatus.UNGELESEN;
/** Admin-UserId der den Eintrag in Arbeit genommen hat */
private UUID inArbeitVon;
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.feedback;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface FeedbackRepository extends JpaRepository<FeedbackEntity, UUID> {
List<FeedbackEntity> findByStatusOrderByEingegangenDesc(FeedbackStatus status);
}

View File

@@ -0,0 +1,7 @@
package de.oaa.xxx.feedback;
public enum FeedbackStatus {
UNGELESEN,
IN_ARBEIT,
BEANTWORTET
}

View File

@@ -0,0 +1,28 @@
package de.oaa.xxx.games.bdsm;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AktiveSperre {
private UUID aktiveSperreId;
private BdsmMitspieler mitspieler;
private Integer minuten;
private LocalDateTime startzeit;
private LocalDateTime endzeit;
private List<Werkzeug> fuer;
private String releaseText;
@Override
public String toString() {
return "AktiveSperre[id=" + aktiveSperreId + ", mitspieler=" + (mitspieler != null ? mitspieler.getName() : null)
+ ", " + minuten + "min, von=" + startzeit + ", bis=" + endzeit + ", fuer=" + fuer + "]";
}
}

View File

@@ -0,0 +1,25 @@
package de.oaa.xxx.games.bdsm;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
public class AufgabeAnzeige {
private String nameAktiverMitspieler;
private String aufgabeText;
private Integer timer;
private Callback callback;
private Integer level;
private UUID mitspielerId;
private boolean eigenesGeraet;
@Override
public String toString() {
return "AufgabeAnzeige[mitspieler=" + nameAktiverMitspieler + ", level=" + level + ", timer=" + timer
+ ", callback=" + (callback != null ? callback.getClass().getSimpleName() : null) + "]";
}
}

View File

@@ -0,0 +1,7 @@
package de.oaa.xxx.games.bdsm;
public enum AufgabeArt {
AUFGABE,
STRAFE,
SPERRE;
}

View File

@@ -0,0 +1,32 @@
package de.oaa.xxx.games.bdsm;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
public class BdsmGame {
private UUID sessionId;
private UUID userId;
private UUID setupId;
private Integer wahrscheinlichkeitSperre;
private Integer wahrscheinlichkeitStrafe;
private Integer aufgabenProLevel;
private Double zeitfaktorZeitstrafen;
private Integer level;
private Integer aufgabenAufAktuellemLevel;
private LocalDateTime startZeit;
private LocalDateTime letzteAktivitaet;
@Override
public String toString() {
return "Session[sessionId=" + sessionId + ", userId=" + userId
+ ", level=" + level + ", aufgaben=" + aufgabenAufAktuellemLevel + "/" + aufgabenProLevel
+ ", pStrafe=" + wahrscheinlichkeitStrafe + "%, pSperre=" + wahrscheinlichkeitSperre + "%"
+ ", zeitfaktor=" + zeitfaktorZeitstrafen + "]";
}
}

View File

@@ -0,0 +1,282 @@
package de.oaa.xxx.games.bdsm;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.common.aufgaben.Aufgabe;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.Sperre;
import de.oaa.xxx.games.common.aufgaben.Strafe;
import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import de.oaa.xxx.games.bdsm.sperre.SperreCallback;
import de.oaa.xxx.games.bdsm.sperre.SperrenVerlaengernCallback;
public class BdsmGameDurchfuehren {
private final AufgabenList aufgabenList;
private final List<BdsmMitspieler> mitspieler = new ArrayList<>();
private final List<AktiveSperre> aktiveSperren = new ArrayList<>();
private final Integer wahrscheinlichkeitSperre;
private final Integer wahrscheinlichkeitStrafe;
private int aufgabenProLevel;
private int level;
private int aufgabenAufAktuellemLevel;
public BdsmGameDurchfuehren(BdsmGameEntity entity) throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
aufgabenList = objectMapper.readValue(entity.getAufgaben(), AufgabenList.class);
entity.getMitspieler().forEach(mitspielerEntity -> mitspieler.add(mitspielerEntity.toMitspieler()));
entity.getAktiveSperren().forEach(sperreEntity -> aktiveSperren.add(sperreEntity.toSperre(mitspieler)));
wahrscheinlichkeitSperre = entity.getWahrscheinlichkeitSperre();
wahrscheinlichkeitStrafe = entity.getWahrscheinlichkeitStrafe();
this.aufgabenProLevel = entity.getAufgabenProLevel() != null ? entity.getAufgabenProLevel() : 5;
this.level = entity.getLevel() != null ? entity.getLevel() : 1;
this.aufgabenAufAktuellemLevel = entity.getAufgabenAufAktuellemLevel() != null ? entity.getAufgabenAufAktuellemLevel() : 0;
}
public AufgabeAnzeige getNext() {
checkLevel();
if (level == 6) {
return null;
}
int nextInt = new Random().nextInt(1, 100);
// Sonderfälle: bleiben wie bisher (inkl. eigener interner Fallbacks)
if (nextInt == 1) {
AufgabeAnzeige anzeige = findUltimativeStrafe();
if (anzeige != null) return anzeige;
} else if (nextInt == 2) {
AufgabeAnzeige anzeige = findSperreVerlaengern();
if (anzeige != null) return anzeige;
} else {
// Reihenfolge der Kategorien: gewürfelte zuerst, dann die anderen
List<Supplier<AufgabeAnzeige>> reihenfolge;
if (nextInt > wahrscheinlichkeitSperre + wahrscheinlichkeitStrafe + 2) {
reihenfolge = List.of(this::findeAufgabe, this::findeStrafe, this::findeSperre);
} else if (nextInt > wahrscheinlichkeitSperre + 2) {
reihenfolge = List.of(this::findeStrafe, this::findeAufgabe, this::findeSperre);
} else {
reihenfolge = List.of(this::findeSperre, this::findeStrafe, this::findeAufgabe);
}
for (Supplier<AufgabeAnzeige> finder : reihenfolge) {
AufgabeAnzeige anzeige = finder.get();
if (anzeige != null) return anzeige;
}
}
// Echtes Fallback: nur wenn wirklich keine Kategorie eine Aufgabe liefert
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.AUFGABE_AKTIV);
BdsmMitspieler passiv = findeMitspielerMitRolle(RolleEnum.AUFGABE_PASSIV, aktiv);
String text = "Ups, da ist etwas schief gelaufen. Keine potenzielle Aufgabe gefunden. Entweder seid ihr inzwischen so gut weggesperrt, dass wirklich keine Aufgaben mehr zur Verfügung stehen, oder uns ist ein Fehler unterlaufen. {AKTIV} und {PASSIV} überbrücken die Zeit mit ein wenig Petting.";
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(aktiv != null ? aktiv.getName() : "");
setMitspielerInfo(anzeige, aktiv);
anzeige.setAufgabeText(getAnzeigeText(text, aktiv != null ? aktiv.getName() : "?", passiv != null ? passiv.getName() : "?"));
anzeige.setTimer(120);
return anzeige;
}
public void backToLvl5() {
this.level = 5;
this.aufgabenAufAktuellemLevel = 0;
}
public List<AufgabeAnzeige> getFinisher() {
var list = new ArrayList<AufgabeAnzeige>();
List.of(GeschlechtEnum.WEIBLICH, GeschlechtEnum.DIVERS, GeschlechtEnum.MAENNLICH).forEach(geschlecht -> {
mitspieler.stream().filter(m -> geschlecht == m.getGeschlecht()).toList().forEach(cumming -> {
var partner = findeMitspielerMitRolle(RolleEnum.AUFGABE_PASSIV, cumming);
var finishers = aufgabenList.getFinisher().stream()
.filter(finisher -> geschlecht == finisher.getGeschlecht() && finisher.isAufgabePassend(partner, cumming))
.toList();
if (!finishers.isEmpty()) {
var aufgabe = finishers.get(new Random().nextInt(finishers.size()));
var anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(cumming.getName());
setMitspielerInfo(anzeige, cumming);
anzeige.setAufgabeText(getAnzeigeText(aufgabe.getText(),
cumming.getName(), partner != null ? partner.getName() : ""));
list.add(anzeige);
} else {
var anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(cumming.getName());
anzeige.setAufgabeText(cumming.getName() + "geht heute leider leer aus...");
list.add(anzeige);
}
});
});
return list;
}
private void checkLevel() {
if (++aufgabenAufAktuellemLevel >= 1 + aufgabenProLevel) {
aufgabenAufAktuellemLevel = 0;
level++;
}
}
private void setMitspielerInfo(AufgabeAnzeige anzeige, BdsmMitspieler aktiv) {
if (aktiv != null) {
anzeige.setMitspielerId(aktiv.getId());
anzeige.setEigenesGeraet(aktiv.isEigenesGeraet());
}
}
private AufgabeAnzeige findUltimativeStrafe() {
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_AKTIV);
if (aktiv != null) {
BdsmMitspieler passiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_PASSIV, aktiv);
if (passiv != null) {
String text = "{AKTIV}, verschnüre {PASSIV} fachmännisch inkl. KG, Plugs, Knebel, Augenbinde und was dir sonst einfällt. Nutze die Ruhe für was auch immer du möchtest.";
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(aktiv.getName());
setMitspielerInfo(anzeige, aktiv);
anzeige.setAufgabeText(getAnzeigeText(text, aktiv.getName(), passiv.getName()));
anzeige.setTimer(new Random().nextInt(1800, 7200));
return anzeige;
}
}
return findeStrafe();
}
private AufgabeAnzeige findSperreVerlaengern() {
if (!aktiveSperren.isEmpty()) {
AktiveSperre sperre = aktiveSperren.get(new Random().nextInt(aktiveSperren.size()));
BdsmMitspieler passiv = sperre.getMitspieler();
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_AKTIV, passiv);
if (aktiv != null) {
String text = "{AKTIV}, du entscheidest. Sollen alle bestehenden Zeitstrafen von {PASSIV} verlängert werden...?";
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setAufgabeText(getAnzeigeText(text, aktiv.getName(), passiv.getName()));
anzeige.setNameAktiverMitspieler(aktiv.getName());
setMitspielerInfo(anzeige, aktiv);
SperrenVerlaengernCallback callback = new SperrenVerlaengernCallback();
callback.setFaktor(new Random().nextInt(2, 4));
callback.setSpielerId(passiv.getId());
anzeige.setCallback(callback);
return anzeige;
}
}
return findeSperre();
}
private AufgabeAnzeige findeAufgabe() {
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.AUFGABE_AKTIV);
if (aktiv != null) {
BdsmMitspieler passiv = findeMitspielerMitRolle(RolleEnum.AUFGABE_PASSIV, aktiv);
if (passiv != null) {
List<Aufgabe> list = aufgabenList.getAufgaben().stream()
.filter(aufgabe -> aufgabe.isAufgabePassend(level, aktiv, passiv))
.collect(Collectors.toList());
if (!list.isEmpty()) {
Aufgabe aufgabe = list.get(new Random().nextInt(list.size()));
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(aktiv.getName());
setMitspielerInfo(anzeige, aktiv);
anzeige.setAufgabeText(getAnzeigeText(aufgabe.getText(), aktiv.getName(), passiv.getName()));
if (aufgabe.getSekundenVon() != null) {
if (aufgabe.getSekundenBis() != null) {
anzeige.setTimer(new Random().nextInt(aufgabe.getSekundenVon(), aufgabe.getSekundenBis()));
} else {
anzeige.setTimer(aufgabe.getSekundenVon());
}
}
return anzeige;
}
}
}
return null;
}
private AufgabeAnzeige findeStrafe() {
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_AKTIV);
if (aktiv != null) {
BdsmMitspieler passiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_PASSIV, aktiv);
if (passiv != null) {
List<Strafe> list = aufgabenList.getStrafen().stream()
.filter(strafe -> strafe.isAufgabePassend(level, aktiv, passiv))
.collect(Collectors.toList());
if (!list.isEmpty()) {
Strafe strafe = list.get(new Random().nextInt(list.size()));
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(aktiv.getName());
setMitspielerInfo(anzeige, aktiv);
anzeige.setAufgabeText(getAnzeigeText(strafe.getText(), aktiv.getName(), passiv.getName()));
if (strafe.getSekundenVon() != null) {
if (strafe.getSekundenBis() != null) {
anzeige.setTimer(new Random().nextInt(strafe.getSekundenVon(), strafe.getSekundenBis()));
} else {
anzeige.setTimer(strafe.getSekundenVon());
}
}
return anzeige;
}
}
}
return null;
}
private AufgabeAnzeige findeSperre() {
BdsmMitspieler aktiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_AKTIV);
if (aktiv != null) {
BdsmMitspieler passiv = findeMitspielerMitRolle(RolleEnum.BESTRAFUNG_PASSIV, aktiv);
if (passiv != null) {
List<Sperre> list = aufgabenList.getSperren().stream()
.filter(sperre -> sperre.isAufgabePassend(passiv))
.collect(Collectors.toList());
if (!list.isEmpty()) {
Sperre sperre = list.get(new Random().nextInt(list.size()));
AufgabeAnzeige anzeige = new AufgabeAnzeige();
anzeige.setNameAktiverMitspieler(aktiv.getName());
setMitspielerInfo(anzeige, aktiv);
anzeige.setAufgabeText(getAnzeigeText(sperre.getText(), aktiv.getName(), passiv.getName()));
SperreCallback callback = new SperreCallback();
callback.setSperreId(sperre.getSperreId());
callback.setSpielerId(passiv.getId());
callback.setReleaseText(getAnzeigeText(sperre.getReleaseText(), aktiv.getName(), passiv.getName()));
anzeige.setCallback(callback);
return anzeige;
}
}
}
return null;
}
private String getAnzeigeText(String textMitPlatzhaltern, String nameAktiv, String namePassiv) {
return textMitPlatzhaltern.replace("{AKTIV}", nameAktiv).replace("{PASSIV}", namePassiv);
}
private BdsmMitspieler findeMitspielerMitRolle(RolleEnum rolle) {
List<BdsmMitspieler> list = mitspieler.stream()
.filter(m -> m.getRollen().contains(rolle))
.toList();
return list.isEmpty() ? null : list.get(new Random().nextInt(list.size()));
}
private BdsmMitspieler findeMitspielerMitRolle(RolleEnum rolle, BdsmMitspieler gegenspieler) {
if (gegenspieler == null) return findeMitspielerMitRolle(rolle);
List<BdsmMitspieler> list = mitspieler.stream()
.filter(m -> m != gegenspieler)
.filter(m -> m.isPassenderSpielpartner(gegenspieler))
.filter(m -> m.getRollen().contains(rolle))
.toList();
return list.isEmpty() ? null : list.get(new Random().nextInt(list.size()));
}
public int getAufgabenAufAktuellemLevel() {
return aufgabenAufAktuellemLevel;
}
public int getLevel() {
return level;
}
}

View File

@@ -0,0 +1,208 @@
package de.oaa.xxx.games.bdsm;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import de.oaa.xxx.games.bdsm.repository.AktiveSperreRepository;
import de.oaa.xxx.games.bdsm.repository.BdsmGameRepository;
import de.oaa.xxx.games.bdsm.repository.MitspielerRepository;
import de.oaa.xxx.games.chastity.cardlock.CardLockEntity;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.games.history.GameHistoryEntity;
import de.oaa.xxx.games.history.GameHistoryRepository;
import de.oaa.xxx.games.history.GameRole;
import de.oaa.xxx.games.history.GameType;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserRepository;
/**
* Service für komplexe BDSM-Game-Operationen.
* Kapselt Spielabschluss-Logik (XP-Vergabe, History) und den BDSM→Chastity-Übergang.
*/
@Service
public class BdsmGameService {
private static final Logger LOGGER = LoggerFactory.getLogger(BdsmGameService.class);
private final BdsmGameRepository sessionRepository;
private final MitspielerRepository mitspielerRepository;
private final AktiveSperreRepository aktiveSperreRepository;
private final UserRepository userRepository;
private final GameHistoryRepository gameHistoryRepository;
private final CardlockRepository cardlockRepository;
private final SystemMessageService systemMessageService;
public BdsmGameService(BdsmGameRepository sessionRepository,
MitspielerRepository mitspielerRepository,
AktiveSperreRepository aktiveSperreRepository,
UserRepository userRepository,
GameHistoryRepository gameHistoryRepository,
CardlockRepository cardlockRepository,
SystemMessageService systemMessageService) {
this.sessionRepository = sessionRepository;
this.mitspielerRepository = mitspielerRepository;
this.aktiveSperreRepository = aktiveSperreRepository;
this.userRepository = userRepository;
this.gameHistoryRepository = gameHistoryRepository;
this.cardlockRepository = cardlockRepository;
this.systemMessageService = systemMessageService;
}
/**
* Beendet eine BDSM-Session ordentlich: History speichern, XP vergeben,
* Gäste auf eigenem Gerät benachrichtigen, Daten aufräumen.
*/
@Transactional
public void spielAbschliessen(BdsmGameEntity entity) {
LocalDateTime endTime = LocalDateTime.now();
long durationMinutes = Duration.between(entity.getStartZeit(), endTime).toMinutes();
GameHistoryEntity entry = new GameHistoryEntity();
entry.setGameName("BDSM Game");
entry.setGameType(GameType.BDSM);
entry.setStartTime(entity.getStartZeit());
entry.setEndTime(endTime);
entry.setDurationMinutes(durationMinutes);
entry.addParticipant(entity.getUserId(), GameRole.PLAYER);
entity.getMitspieler().stream()
.filter(m -> m.getUserId() != null)
.forEach(m -> entry.addParticipant(m.getUserId(), GameRole.PLAYER));
gameHistoryRepository.save(entry);
int xp = (int) durationMinutes;
userRepository.findById(entity.getUserId()).ifPresent(u -> {
u.setBdsmXp(u.getBdsmXp() + xp);
userRepository.save(u);
});
entity.getMitspieler().stream()
.filter(m -> m.getUserId() != null)
.forEach(m -> userRepository.findById(m.getUserId()).ifPresent(u -> {
u.setBdsmXp(u.getBdsmXp() + xp);
userRepository.save(u);
}));
// Gäste auf eigenem Gerät benachrichtigen
String endNachricht = "Das BDSM-Spiel wurde erfolgreich beendet. Danke fürs Mitspielen! 🎉";
entity.getMitspieler().stream()
.filter(m -> m.isEigenesGeraet() && m.getUserId() != null)
.forEach(m -> systemMessageService.send(entity.getUserId(), m.getUserId(),
endNachricht, "/userhome.html", MessageCause.GAME_STATE));
bereinige(entity);
}
/**
* Überführt eine BDSM-Session in ein neues Chastity-Lock (BDSM→Chastity-Transition).
* History + XP werden wie beim normalen Spielabschluss vergeben.
*
* @return Das neu angelegte CardLockEntity
* @throws IllegalArgumentException wenn Session oder Template nicht gefunden
* @throws IllegalStateException wenn Lockee bereits ein aktives Lock hat
*/
@Transactional
public CardLockEntity zuChastity(UUID sessionId, UUID templateLockId, UUID lockeeUserId, UUID keyholderUserId) {
BdsmGameEntity entity = sessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("Session nicht gefunden: " + sessionId));
CardLockEntity template = cardlockRepository.findById(templateLockId)
.orElseThrow(() -> new IllegalArgumentException("Template-Lock nicht gefunden: " + templateLockId));
if (lockeeUserId != null
&& cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeUserId)) {
throw new IllegalStateException("Lockee hat bereits ein aktives Chastity-Lock");
}
LocalDateTime now = LocalDateTime.now();
CardLockEntity newLock = new CardLockEntity();
newLock.setName(template.getName());
newLock.setLockee(lockeeUserId);
newLock.setKeyholder(keyholderUserId);
newLock.setInitialCards(template.getInitialCards());
newLock.setPickEveryMinute(template.getPickEveryMinute());
newLock.setAccumulatePicks(template.isAccumulatePicks());
newLock.setShowRemainingCards(template.isShowRemainingCards());
newLock.setLatestOpeningtime(template.getLatestOpeningtime());
newLock.setHygineOpeningDurationMinutes(template.getHygineOpeningDurationMinutes());
newLock.setHygineOpeningEveryMinites(template.getHygineOpeningEveryMinites());
newLock.setTasks(template.getTasks());
newLock.setRequiresVerification(template.isRequiresVerification());
newLock.setTestLock(false);
newLock.setTaskMode(template.getTaskMode());
int codeLines = template.getUnlockCodeLength() != null ? template.getUnlockCodeLength() : 5;
newLock.setUnlockCodeLength(codeLines);
StringBuilder codeBuilder = new StringBuilder();
java.util.Random rng = new java.util.Random();
for (int i = 0; i < codeLines; i++) codeBuilder.append(rng.nextInt(10));
newLock.setUnlockCode(codeBuilder.toString());
newLock.setStartTime(now);
newLock.setAvailableCards(template.getInitialCards() != null
? new ArrayList<>(template.getInitialCards()) : new ArrayList<>());
newLock.setOpenPicks(0);
if (template.getPickEveryMinute() != null) {
newLock.setNextCardIn(now.plusMinutes(template.getPickEveryMinute()));
}
if (template.getHygineOpeningEveryMinites() != null) {
newLock.setLastHygineOpening(now);
}
cardlockRepository.save(newLock);
// Lockee benachrichtigen
if (lockeeUserId != null) {
userRepository.findById(keyholderUserId).ifPresent(keyholder ->
systemMessageService.send(keyholderUserId, lockeeUserId,
keyholder.getName() + " hat nach dem BDSM Game ein Chastity Lock auf dich gesetzt.",
"/games/chastity/activelock.html", MessageCause.GAME_STATE));
}
// Spielabschluss-Logik (History + XP + Cleanup)
LocalDateTime endTime = LocalDateTime.now();
long durationMinutes = Duration.between(entity.getStartZeit(), endTime).toMinutes();
GameHistoryEntity entry = new GameHistoryEntity();
entry.setGameName("BDSM Game");
entry.setGameType(GameType.BDSM);
entry.setStartTime(entity.getStartZeit());
entry.setEndTime(endTime);
entry.setDurationMinutes(durationMinutes);
entry.addParticipant(entity.getUserId(), GameRole.PLAYER);
entity.getMitspieler().stream()
.filter(m -> m.getUserId() != null)
.forEach(m -> entry.addParticipant(m.getUserId(), GameRole.PLAYER));
gameHistoryRepository.save(entry);
int xp = (int) durationMinutes;
userRepository.findById(entity.getUserId()).ifPresent(u -> {
u.setBdsmXp(u.getBdsmXp() + xp);
userRepository.save(u);
});
entity.getMitspieler().stream()
.filter(m -> m.getUserId() != null)
.forEach(m -> userRepository.findById(m.getUserId()).ifPresent(u -> {
u.setBdsmXp(u.getBdsmXp() + xp);
userRepository.save(u);
}));
bereinige(entity);
LOGGER.info("BDSM-Session {} in Chastity-Lock {} überführt (Lockee: {}, Keyholder: {})",
sessionId, newLock.getLockId(), lockeeUserId, keyholderUserId);
return newLock;
}
/** Löscht alle Session-Daten (Sperren, Mitspieler, Session selbst). */
private void bereinige(BdsmGameEntity entity) {
aktiveSperreRepository.deleteAll(entity.getAktiveSperren());
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
}
}

View File

@@ -0,0 +1,44 @@
package de.oaa.xxx.games.bdsm;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.common.aufgaben.CommonMitspieler;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BdsmMitspieler implements CommonMitspieler {
private UUID id;
private UUID userId;
private boolean eigenesGeraet;
private boolean sperrenVorFinaleAufloesen = true;
private String name;
private GeschlechtEnum geschlecht;
private List<GeschlechtEnum> spieltMit;
private List<RolleEnum> rollen;
private List<Werkzeug> verfuegbareWerkzeuge;
public boolean isVerfuegbar(Werkzeug werkzeug) {
return verfuegbareWerkzeuge.contains(werkzeug);
}
@Override
public String toString() {
return "Mitspieler[id=" + id + ", name=" + name + ", geschlecht=" + geschlecht
+ ", rollen=" + rollen + ", werkzeuge=" + verfuegbareWerkzeuge + "]";
}
public boolean isPassenderSpielpartner(BdsmMitspieler other) {
if (!spieltMit.contains(other.getGeschlecht())) {
return false;
}
if (!other.spieltMit.contains(geschlecht)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,16 @@
package de.oaa.xxx.games.bdsm;
import java.util.UUID;
public abstract class Callback {
private UUID sessionId;
public UUID getSessionId() { return sessionId; }
public void setSessionId(UUID sessionId) { this.sessionId = sessionId; }
@Override
public String toString() {
return getClass().getSimpleName() + "[sessionId=" + sessionId + "]";
}
}

View File

@@ -0,0 +1,7 @@
package de.oaa.xxx.games.bdsm;
public enum GeschlechtEnum {
WEIBLICH,
DIVERS,
MAENNLICH;
}

View File

@@ -0,0 +1,8 @@
package de.oaa.xxx.games.bdsm;
public enum RolleEnum {
BESTRAFUNG_AKTIV,
BESTRAFUNG_PASSIV,
AUFGABE_AKTIV,
AUFGABE_PASSIV;
}

View File

@@ -0,0 +1,148 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.GruppenAboEntity;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Principal;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/abo")
@Transactional
public class AboController {
private static final Logger LOGGER = LoggerFactory.getLogger(AboController.class);
private static final int DEFAULT_PAGE_SIZE = 5;
private static final int DISCOVER_PAGE_SIZE = 10;
private final GruppenAboRepository aboRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final UserService userService;
public AboController(GruppenAboRepository aboRepository,
AufgabenGruppeRepository gruppeRepository,
UserService userService) {
this.aboRepository = aboRepository;
this.gruppeRepository = gruppeRepository;
this.userService = userService;
}
// ── Abonnierte Gruppen laden ──
@GetMapping("/list")
public ResponseEntity<AufgabenGruppePage> listSubscribed(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size,
Principal principal) {
UserEntity user = userService.requireUser(principal);
List<AufgabenGruppe> dtos = aboRepository.findByUserId(user.getUserId()).stream()
.map(GruppenAboEntity::getAufgabenGruppe)
.filter(g -> !g.isPrivateGruppe()) // ignoriere inzwischen wieder private Gruppen
.map(g -> enrich(g, user.getUserId(), true))
.sorted(Comparator.comparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
return ResponseEntity.ok(manualPage(dtos, page, size));
}
// ── Entdecken ──
@GetMapping("/discover")
public ResponseEntity<AufgabenGruppePage> discover(
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DISCOVER_PAGE_SIZE) int size,
Principal principal) {
UserEntity user = userService.requireUser(principal);
String namePattern = name != null && !name.isBlank() ? "%" + name.trim() + "%" : null;
List<AufgabenGruppe> dtos = gruppeRepository
.findPublicFromOthers(user.getUserId(), namePattern).stream()
.map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g)))
.sorted(Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed()
.thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER))
.toList();
return ResponseEntity.ok(manualPage(dtos, page, size));
}
// ── Abonnieren ──
@PostMapping("/{gruppenId}")
public ResponseEntity<Void> subscribe(@PathVariable UUID gruppenId, Principal principal) {
UserEntity user = userService.requireUser(principal);
AufgabenGruppeEntity gruppe = gruppeRepository.findById(gruppenId).orElse(null);
if (gruppe == null || gruppe.isPrivateGruppe() || user.getUserId().equals(gruppe.getUserId())) {
return ResponseEntity.badRequest().build();
}
if (aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), gruppe)) {
return ResponseEntity.ok().build();
}
GruppenAboEntity abo = new GruppenAboEntity();
abo.setAboId(UUID.randomUUID());
abo.setUserId(user.getUserId());
abo.setAufgabenGruppe(gruppe);
aboRepository.save(abo);
LOGGER.info("User {} hat Gruppe {} abonniert", user.getUserId(), gruppenId);
return ResponseEntity.status(201).build();
}
// ── Abonnement kündigen ──
@DeleteMapping("/{gruppenId}")
public ResponseEntity<Void> unsubscribe(@PathVariable UUID gruppenId, Principal principal) {
UserEntity user = userService.requireUser(principal);
AufgabenGruppeEntity gruppe = gruppeRepository.findById(gruppenId).orElse(null);
if (gruppe == null) return ResponseEntity.noContent().build();
aboRepository.deleteByUserIdAndAufgabenGruppe(user.getUserId(), gruppe);
LOGGER.info("User {} hat Abo auf Gruppe {} beendet", user.getUserId(), gruppenId);
return ResponseEntity.accepted().build();
}
// ── Hilfsmethoden ──
private AufgabenGruppe enrich(AufgabenGruppeEntity entity, UUID userId, boolean subscribed) {
AufgabenGruppe g = entity.toAufgabenGruppe();
g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity));
g.setSubscribed(subscribed);
return g;
}
private AufgabenGruppePage manualPage(List<AufgabenGruppe> all, int page, int size) {
int total = all.size();
int start = page * size;
List<AufgabenGruppe> content = start >= total ? List.of() : all.subList(start, Math.min(start + size, total));
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(content);
result.setCurrentPage(page);
result.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size));
result.setTotalElements(total);
return result;
}
}

View File

@@ -0,0 +1,126 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Aufgabe;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.entity.AufgabeEntity;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/aufgabe")
@Transactional
public class AufgabeController {
private static final Logger LOGGER = LoggerFactory.getLogger(AufgabeController.class);
private final AufgabeRepository aufgabeRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final ToyRepository toyRepository;
private final UserService userService;
private final SubscriptionLimitService limitService;
public AufgabeController(AufgabeRepository aufgabeRepository,
AufgabenGruppeRepository gruppeRepository,
ToyRepository toyRepository,
UserService userService,
SubscriptionLimitService limitService) {
this.aufgabeRepository = aufgabeRepository;
this.gruppeRepository = gruppeRepository;
this.toyRepository = toyRepository;
this.userService = userService;
this.limitService = limitService;
}
@GetMapping("/{aufgabeId}")
public ResponseEntity<Aufgabe> get(@PathVariable UUID aufgabeId) {
return aufgabeRepository.findById(aufgabeId)
.map(entity -> ResponseEntity.ok(entity.toAufgabe()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Aufgabe aufgabe, Principal principal) {
if (aufgabe.getKurzText() == null || aufgabe.getText() == null || aufgabe.getLevel() == null || aufgabe.getGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(aufgabe.getGruppeId()).orElse(null);
if (gruppeEntity == null) {
return ResponseEntity.badRequest().build();
}
int limit = limitService.maxTasksPerGroup(userService.requireUser(principal).getUserId());
if (gruppeEntity.getAufgaben().size() >= limit) {
return ResponseEntity.status(409).build();
}
List<ToyEntity> toys = resolveToys(aufgabe.getBenoetigteToys());
AufgabeEntity entity = AufgabeEntity.create(aufgabe, gruppeEntity, toys);
aufgabeRepository.save(entity);
LOGGER.debug("Aufgabe {} '{}' in Gruppe {} erstellt", entity.getAufgabeId(), entity.getKurzText(), aufgabe.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getAufgabeId()).toUri()
).build();
}
@PutMapping("/{aufgabeId}")
public ResponseEntity<Void> update(@PathVariable UUID aufgabeId, @RequestBody Aufgabe aufgabe) {
if (aufgabe.getKurzText() == null || aufgabe.getText() == null || aufgabe.getLevel() == null) {
return ResponseEntity.badRequest().build();
}
AufgabeEntity entity = aufgabeRepository.findById(aufgabeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
entity.setKurzText(aufgabe.getKurzText());
entity.setText(aufgabe.getText());
entity.setLevel(aufgabe.getLevel());
entity.setSekundenVon(aufgabe.getSekundenVon());
entity.setSekundenBis(aufgabe.getSekundenBis());
entity.setBenoetigtAktiv(aufgabe.getBenoetigtAktiv());
entity.setBenoetigtPassiv(aufgabe.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(aufgabe.getBenoetigteToys()));
aufgabeRepository.save(entity);
LOGGER.debug("Aufgabe {} aktualisiert", aufgabeId);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Aufgabe aufgabe) {
try {
aufgabeRepository.findById(aufgabe.getAufgabeId()).ifPresent(aufgabeRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private List<ToyEntity> resolveToys(List<Toy> toys) {
if (toys == null || toys.isEmpty()) return new ArrayList<>();
List<UUID> ids = toys.stream()
.filter(t -> t.getToyId() != null)
.map(Toy::getToyId)
.toList();
if (ids.isEmpty()) return new ArrayList<>();
return toyRepository.findAllById(ids);
}
}

View File

@@ -0,0 +1,257 @@
package de.oaa.xxx.games.bdsm.controller;
import java.security.Principal;
import java.util.Base64;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeList;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppePage;
import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeService;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.repository.AufgabeRepository;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/gruppe")
@Transactional
public class AufgabenGruppeController {
private static final Logger LOGGER = LoggerFactory.getLogger(AufgabenGruppeController.class);
private static final int DEFAULT_PAGE_SIZE = 5;
private final AufgabenGruppeRepository gruppeRepository;
private final AufgabeRepository aufgabeRepository;
private final StrafeRepository strafeRepository;
private final SperreRepository sperreRepository;
private final FinisherRepository finisherRepository;
private final GruppenAboRepository aboRepository;
private final AufgabenGruppeService aufgabenGruppeService;
private final SubscriptionLimitService limitService;
private final UserService userService;
public AufgabenGruppeController(AufgabenGruppeRepository gruppeRepository,
AufgabeRepository aufgabeRepository,
StrafeRepository strafeRepository,
SperreRepository sperreRepository,
FinisherRepository finisherRepository,
GruppenAboRepository aboRepository,
AufgabenGruppeService aufgabenGruppeService,
SubscriptionLimitService limitService,
UserService userService) {
this.gruppeRepository = gruppeRepository;
this.aufgabeRepository = aufgabeRepository;
this.strafeRepository = strafeRepository;
this.sperreRepository = sperreRepository;
this.finisherRepository = finisherRepository;
this.aboRepository = aboRepository;
this.aufgabenGruppeService = aufgabenGruppeService;
this.limitService = limitService;
this.userService = userService;
}
// ── Paginierte Listen ──
@GetMapping("/list/user")
public ResponseEntity<AufgabenGruppePage> listUser(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size,
Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserId(
user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
return ResponseEntity.ok(toGruppePage(result, true));
}
@GetMapping("/list/system")
public ResponseEntity<AufgabenGruppePage> listSystem(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
Page<AufgabenGruppeEntity> result = gruppeRepository.findByUserIdIsNull(
PageRequest.of(page, size, Sort.by("name")));
return ResponseEntity.ok(toGruppePage(result));
}
// ── Bestehende Endpunkte ──
@GetMapping("/all")
public ResponseEntity<AufgabenGruppeList> getAll(@RequestParam(required = false) String search, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
String searchPattern = search != null ? "%" + search + "%" : null;
AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.listWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500))
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list);
}
@GetMapping("/own")
public ResponseEntity<AufgabenGruppeList> getOwn(@RequestParam UUID userId) {
AufgabenGruppeList list = new AufgabenGruppeList();
list.setGruppen(gruppeRepository.findByUserId(userId)
.stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList());
return ResponseEntity.ok(list);
}
@GetMapping("/{gruppeId}")
public ResponseEntity<AufgabenGruppe> get(@PathVariable UUID gruppeId) {
return gruppeRepository.findById(gruppeId)
.map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe()))
.orElse(ResponseEntity.noContent().build());
}
// ── Anlegen ──
@PostMapping
public ResponseEntity<Void> create(@RequestBody AufgabenGruppe gruppe, Principal principal) {
if (gruppe.getName() == null || gruppe.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
if (gruppeRepository.countByUserId(user.getUserId()) >= limitService.maxTaskGroups(user.getUserId())) {
return ResponseEntity.status(409).build();
}
AufgabenGruppeEntity entity = AufgabenGruppeEntity.create(gruppe);
entity.setUserId(user.getUserId());
entity.setPrivateGruppe(true);
gruppeRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe '{}' ({}) erstellt", user.getUserId(), entity.getName(), entity.getGruppenId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getGruppenId()).toUri()
).build();
}
// ── Bearbeiten ──
@PutMapping("/{gruppeId}")
public ResponseEntity<Void> update(@PathVariable UUID gruppeId,
@RequestBody AufgabenGruppe gruppe,
Principal principal) {
if (gruppe.getName() == null || gruppe.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
entity.setName(gruppe.getName().trim());
entity.setBeschreibung(gruppe.getBeschreibung());
entity.setVon(gruppe.getVon());
entity.setPrivateGruppe(gruppe.isPrivateGruppe());
if (gruppe.getBild() != null) {
entity.setBild(Base64.getDecoder().decode(gruppe.getBild()));
}
gruppeRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId);
return ResponseEntity.ok().build();
}
// ── Kopieren (Systemgruppe → eigene) ──
@PostMapping("/copy/{gruppeId}")
public ResponseEntity<Void> copy(@PathVariable UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
try {
aufgabenGruppeService.copyGruppe(gruppeId, user.getUserId());
return ResponseEntity.status(201).build();
} catch (IllegalStateException e) {
return ResponseEntity.status(409).build();
} catch (IllegalArgumentException e) {
String msg = e.getMessage();
if (msg != null && msg.contains("nicht gefunden")) return ResponseEntity.notFound().build();
return ResponseEntity.status(403).build();
}
}
// ── Löschen ──
@DeleteMapping("/{gruppeId}")
public ResponseEntity<Void> deleteById(@PathVariable UUID gruppeId, Principal principal) {
UserEntity user = resolveUser(principal);
if (user == null) return ResponseEntity.status(401).build();
AufgabenGruppeEntity entity = gruppeRepository.findById(gruppeId).orElse(null);
if (entity == null) return ResponseEntity.noContent().build();
if (!user.getUserId().equals(entity.getUserId())) return ResponseEntity.status(403).build();
try {
aboRepository.deleteByAufgabenGruppe(entity);
aufgabeRepository.deleteAll(entity.getAufgaben());
strafeRepository.deleteAll(entity.getStrafen());
sperreRepository.deleteAll(entity.getSperren());
finisherRepository.deleteAll(entity.getFinisher());
gruppeRepository.delete(entity);
return ResponseEntity.accepted().build();
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
return ResponseEntity.internalServerError().build();
}
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody AufgabenGruppe gruppe) {
try {
gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(gruppeRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
// ── Hilfsmethoden ──
private UserEntity resolveUser(Principal principal) {
if (principal == null) return null;
return userService.requireUser(principal);
}
private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page) {
return toGruppePage(page, false);
}
private AufgabenGruppePage toGruppePage(Page<AufgabenGruppeEntity> page, boolean withSubscriberCount) {
AufgabenGruppePage result = new AufgabenGruppePage();
result.setContent(page.getContent().stream().map(entity -> {
AufgabenGruppe g = entity.toAufgabenGruppe();
if (withSubscriberCount) g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity));
return g;
}).toList());
result.setCurrentPage(page.getNumber());
result.setTotalPages(page.getTotalPages());
result.setTotalElements(page.getTotalElements());
return result;
}
}

View File

@@ -0,0 +1,238 @@
package de.oaa.xxx.games.bdsm.controller;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity.Status;
import de.oaa.xxx.games.bdsm.repository.BdsmEinladungRepository;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.repository.FriendshipRepository;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/bdsm/einladung")
@Transactional
public class BdsmEinladungController {
private final BdsmEinladungRepository einladungRepository;
private final UserRepository userRepository;
private final FriendshipRepository friendshipRepository;
private final SystemMessageService systemMessageService;
private final UserService userService;
public BdsmEinladungController(BdsmEinladungRepository einladungRepository,
UserRepository userRepository,
FriendshipRepository friendshipRepository,
SystemMessageService systemMessageService,
UserService userService) {
this.einladungRepository = einladungRepository;
this.userRepository = userRepository;
this.friendshipRepository = friendshipRepository;
this.systemMessageService = systemMessageService;
this.userService = userService;
}
record EinladungRequest(UUID setupId, int slotIndex, UUID inviteeId) {}
record AntwortRequest(boolean accepted, String mode) {} // mode: OWN_DEVICE | HOST_DEVICE
record SpielerDatenRequest(String spielerDatenJson) {}
private UUID currentUserId(Principal principal) {
return userService.requireUser(principal).getUserId();
}
@PostMapping
public ResponseEntity<Map<String, Object>> sendEinladung(@RequestBody EinladungRequest req, Principal principal) {
UUID inviterId = currentUserId(principal);
if (inviterId == null) return ResponseEntity.status(401).build();
// Freundschaft prüfen
var friendship = friendshipRepository.findExisting(inviterId, req.inviteeId());
if (friendship.isEmpty() || friendship.get().getStatus() != de.oaa.xxx.social.entity.FriendshipEntity.Status.ACCEPTED) {
return ResponseEntity.status(403).build();
}
if (req.setupId() == null) return ResponseEntity.badRequest().build();
// Prüfen ob Person bereits aktiv eingeladen oder Teil des Spiels
boolean alreadyInvited = einladungRepository.findBySetupId(req.setupId()).stream()
.anyMatch(e -> req.inviteeId().equals(e.getInviteeId())
&& (e.getStatus() == Status.PENDING
|| e.getStatus() == Status.ACCEPTED_OWN
|| e.getStatus() == Status.ACCEPTED_HOST));
if (alreadyInvited) {
return ResponseEntity.status(409).build();
}
// Alte Einladung für diesen Slot canceln
einladungRepository.findBySetupId(req.setupId()).stream()
.filter(e -> e.getSlotIndex() == req.slotIndex() && e.getStatus() == Status.PENDING)
.forEach(e -> e.setStatus(Status.CANCELLED));
BdsmEinladungEntity entity = new BdsmEinladungEntity();
entity.setEinladungId(UUID.randomUUID());
entity.setSetupId(req.setupId());
entity.setInviterId(inviterId);
entity.setInviteeId(req.inviteeId());
entity.setSlotIndex(req.slotIndex());
entity.setStatus(Status.PENDING);
entity.setCreatedAt(LocalDateTime.now());
einladungRepository.save(entity);
systemMessageService.pushInvitationUpdate(req.inviteeId());
Map<String, Object> result = new LinkedHashMap<>();
result.put("einladungId", entity.getEinladungId());
return ResponseEntity.ok(result);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancelEinladung(@PathVariable UUID id, Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmEinladungEntity e = einladungRepository.findById(id).orElse(null);
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviterId().equals(userId)) return ResponseEntity.status(403).build();
e.setStatus(Status.CANCELLED);
systemMessageService.pushInvitationUpdate(e.getInviteeId());
return ResponseEntity.accepted().build();
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> getBySetupId(@RequestParam UUID setupId, Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
List<Map<String, Object>> list = einladungRepository.findBySetupId(setupId).stream()
.map(this::toMap).toList();
return ResponseEntity.ok(list);
}
@GetMapping("/meine-aktive")
public ResponseEntity<Map<String, Object>> getAktive(Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
return einladungRepository.findByInviteeIdAndStatus(userId, Status.ACCEPTED_OWN)
.stream().findFirst()
.map(e -> ResponseEntity.ok(toMap(e)))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping("/pending/count")
public ResponseEntity<Integer> getPendingCount(Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
return ResponseEntity.ok(einladungRepository.findByInviteeIdAndStatus(userId, Status.PENDING).size());
}
@GetMapping("/pending")
public ResponseEntity<List<Map<String, Object>>> getPending(Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
List<Map<String, Object>> list = einladungRepository.findByInviteeIdAndStatus(userId, Status.PENDING)
.stream().map(e -> {
Map<String, Object> m = toMap(e);
userRepository.findById(e.getInviterId()).ifPresent(u -> {
m.put("inviterName", u.getName());
m.put("inviterAvatar", u.getProfilePicture() != null ? u.getProfilePicture() : "");
});
return m;
}).toList();
return ResponseEntity.ok(list);
}
@GetMapping("/sent")
public ResponseEntity<List<Map<String, Object>>> getSent(Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
List<Map<String, Object>> list = einladungRepository.findByInviterIdAndStatus(userId, Status.PENDING)
.stream().map(e -> {
Map<String, Object> m = toMap(e);
userRepository.findById(e.getInviteeId()).ifPresent(u -> {
m.put("inviteeName", u.getName());
m.put("inviteeAvatar", u.getProfilePicture() != null ? u.getProfilePicture() : "");
});
return m;
}).toList();
return ResponseEntity.ok(list);
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getById(@PathVariable("id") UUID id, Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmEinladungEntity e = einladungRepository.findById(id).orElse(null);
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviteeId().equals(userId) && !e.getInviterId().equals(userId)) {
return ResponseEntity.status(403).build();
}
Map<String, Object> m = toMap(e);
userRepository.findById(e.getInviterId()).ifPresent(u -> {
m.put("inviterName", u.getName());
m.put("inviterAvatar", u.getProfilePicture() != null ? u.getProfilePicture() : "");
});
return ResponseEntity.ok(m);
}
@PutMapping("/{id}/spielerdaten")
public ResponseEntity<Void> spielerDatenEinreichen(@PathVariable UUID id, @RequestBody SpielerDatenRequest req, Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmEinladungEntity e = einladungRepository.findById(id).orElse(null);
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviteeId().equals(userId)) return ResponseEntity.status(403).build();
if (e.getStatus() != Status.ACCEPTED_OWN) return ResponseEntity.badRequest().build();
e.setSpielerDatenJson(req.spielerDatenJson());
e.setBereit(true);
return ResponseEntity.accepted().build();
}
@PutMapping("/{id}/antwort")
public ResponseEntity<Void> antwort(@PathVariable UUID id, @RequestBody AntwortRequest req, Principal principal) {
UUID userId = currentUserId(principal);
if (userId == null) return ResponseEntity.status(401).build();
BdsmEinladungEntity e = einladungRepository.findById(id).orElse(null);
if (e == null) return ResponseEntity.notFound().build();
if (!e.getInviteeId().equals(userId)) return ResponseEntity.status(403).build();
if (e.getStatus() != Status.PENDING) return ResponseEntity.badRequest().build();
if (!req.accepted()) {
e.setStatus(Status.DECLINED);
} else if ("OWN_DEVICE".equals(req.mode())) {
e.setStatus(Status.ACCEPTED_OWN);
} else {
e.setStatus(Status.ACCEPTED_HOST);
}
return ResponseEntity.accepted().build();
}
private Map<String, Object> toMap(BdsmEinladungEntity e) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("einladungId", e.getEinladungId());
m.put("setupId", e.getSetupId());
m.put("slotIndex", e.getSlotIndex());
m.put("inviteeId", e.getInviteeId());
m.put("inviterId", e.getInviterId());
m.put("status", e.getStatus().name());
m.put("sessionId", e.getSessionId());
m.put("bereit", e.isBereit());
m.put("spielerDatenJson", e.getSpielerDatenJson());
userRepository.findById(e.getInviteeId()).ifPresent(u -> m.put("inviteeName", u.getName()));
return m;
}
}

View File

@@ -0,0 +1,624 @@
package de.oaa.xxx.games.bdsm.controller;
import java.security.Principal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.bdsm.AufgabeAnzeige;
import de.oaa.xxx.games.bdsm.BdsmGame;
import de.oaa.xxx.games.bdsm.BdsmGameDurchfuehren;
import de.oaa.xxx.games.bdsm.BdsmGameService;
import de.oaa.xxx.games.bdsm.GeschlechtEnum;
import de.oaa.xxx.games.bdsm.BdsmMitspieler;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import de.oaa.xxx.games.bdsm.entity.AktiveSperreEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import de.oaa.xxx.games.bdsm.entity.MitspielerEntity;
import de.oaa.xxx.games.bdsm.repository.AktiveSperreRepository;
import de.oaa.xxx.games.bdsm.repository.BdsmEinladungRepository;
import de.oaa.xxx.games.bdsm.repository.BdsmGameRepository;
import de.oaa.xxx.games.bdsm.repository.MitspielerRepository;
import de.oaa.xxx.games.bdsm.sperre.SperreCallback;
import de.oaa.xxx.games.bdsm.sperre.SperreVerarbeiten;
import de.oaa.xxx.games.bdsm.sperre.SperrenVerlaengernCallback;
import de.oaa.xxx.games.chastity.cardlock.CardLockEntity;
import de.oaa.xxx.games.chastity.cardlock.CardlockRepository;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.social.entity.MessageCause;
import de.oaa.xxx.user.UserRepository;
import de.oaa.xxx.user.UserService;
@RestController
@RequestMapping("/bdsm")
@Transactional
public class BdsmGameController {
private static final Logger LOGGER = LoggerFactory.getLogger(BdsmGameController.class);
/**
* Kurzlebiger In-Memory-Marker: Sessions die ordentlich über spielAbgeschlossen
* beendet wurden.
*/
private static final Set<UUID> ORDENTLICH_BEENDET = Collections.synchronizedSet(new HashSet<>());
private final BdsmGameRepository sessionRepository;
private final MitspielerRepository mitspielerRepository;
private final AktiveSperreRepository aktiveSperreRepository;
private final UserRepository userRepository;
private final BdsmEinladungRepository einladungRepository;
private final ObjectMapper objectMapper;
private final SystemMessageService systemMessageService;
private final CardlockRepository cardlockRepository;
private final BdsmGameService bdsmGameService;
private final UserService userService;
public BdsmGameController(BdsmGameRepository sessionRepository, MitspielerRepository mitspielerRepository,
AktiveSperreRepository aktiveSperreRepository, UserRepository userRepository,
BdsmEinladungRepository einladungRepository, ObjectMapper objectMapper,
SystemMessageService systemMessageService, CardlockRepository cardlockRepository,
BdsmGameService bdsmGameService, UserService userService) {
this.sessionRepository = sessionRepository;
this.mitspielerRepository = mitspielerRepository;
this.aktiveSperreRepository = aktiveSperreRepository;
this.userRepository = userRepository;
this.einladungRepository = einladungRepository;
this.objectMapper = objectMapper;
this.systemMessageService = systemMessageService;
this.cardlockRepository = cardlockRepository;
this.bdsmGameService = bdsmGameService;
this.userService = userService;
}
@GetMapping("/{sessionId}")
public ResponseEntity<BdsmGame> getBySessionId(@PathVariable UUID sessionId) {
return sessionRepository.findById(sessionId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping
public ResponseEntity<BdsmGame> getByUserId(@RequestParam UUID userId) {
return sessionRepository.findByUserId(userId)
.map(entity -> ResponseEntity.ok(toSession(entity)))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody BdsmGame session, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var existingOpt = sessionRepository.findByUserId(userId);
if (existingOpt.isPresent()) {
BdsmGameEntity existing = existingOpt.get();
if (existing.getAufgaben() != null) return ResponseEntity.status(409).build();
// Unvollständige Session (aufgaben=null) bereinigen
aktiveSperreRepository.deleteAll(existing.getAktiveSperren());
mitspielerRepository.deleteAll(existing.getMitspieler());
sessionRepository.delete(existing);
}
BdsmGameEntity entity = new BdsmGameEntity();
entity.setSessionId(UUID.randomUUID());
entity.setUserId(userId);
entity.setAufgabenAufAktuellemLevel(0);
entity.setAufgabenProLevel(session.getAufgabenProLevel() != null ? session.getAufgabenProLevel() : 5);
LocalDateTime now = LocalDateTime.now();
entity.setLetzteAktivitaet(now);
entity.setStartZeit(now);
entity.setWahrscheinlichkeitSperre(session.getWahrscheinlichkeitSperre() != null ? session.getWahrscheinlichkeitSperre() : 10);
entity.setWahrscheinlichkeitStrafe(session.getWahrscheinlichkeitStrafe() != null ? session.getWahrscheinlichkeitStrafe() : 10);
entity.setZeitfaktorZeitstrafen(session.getZeitfaktorZeitstrafen() != null ? session.getZeitfaktorZeitstrafen() : 1.0);
entity.setLevel(1);
entity.setSetupId(session.getSetupId());
sessionRepository.save(entity);
LOGGER.debug("BdsmGame gestartet [sessionId={}, userId={}, aufgabenProLevel={}, wahrscheinlichkeitStrafe={}%, wahrscheinlichkeitSperre={}%, zeitfaktorZeitstrafen={}]",
entity.getSessionId(), entity.getUserId(), entity.getAufgabenProLevel(),
entity.getWahrscheinlichkeitStrafe(), entity.getWahrscheinlichkeitSperre(),
entity.getZeitfaktorZeitstrafen());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getSessionId()).toUri()
).build();
}
@DeleteMapping
public ResponseEntity<Void> deleteSession(@RequestBody BdsmGame session) {
return sessionRepository.findById(session.getSessionId())
.map(entity -> {
aktiveSperreRepository.deleteAll(entity.getAktiveSperren());
mitspielerRepository.deleteAll(entity.getMitspieler());
sessionRepository.delete(entity);
return ResponseEntity.accepted().<Void>build();
})
.orElse(ResponseEntity.noContent().build());
}
@PostMapping("/{sessionId}/abgeschlossen")
public ResponseEntity<Void> spielAbgeschlossen(@PathVariable UUID sessionId) {
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
ORDENTLICH_BEENDET.add(sessionId);
bdsmGameService.spielAbschliessen(entity);
return ResponseEntity.accepted().build();
}
/** Prüft ob eine Session ordentlich (nicht abgebrochen) beendet wurde. */
@GetMapping("/{sessionId}/beendet")
public ResponseEntity<Void> istBeendet(@PathVariable UUID sessionId) {
if (ORDENTLICH_BEENDET.remove(sessionId)) return ResponseEntity.ok().build();
return ResponseEntity.notFound().build();
}
@DeleteMapping("/{sessionId}/verlassen")
public ResponseEntity<Void> verlasseSpiel(@PathVariable UUID sessionId, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
MitspielerEntity self = session.getMitspieler().stream()
.filter(m -> userId.equals(m.getUserId()))
.findFirst().orElse(null);
if (self == null) return ResponseEntity.status(403).build();
String name = self.getName();
String nachricht = name + " hat das BDSM-Spiel verlassen. Das Spiel wurde abgebrochen.";
systemMessageService.send(userId, session.getUserId(), nachricht, "/userhome.html", MessageCause.GAME_STATE);
session.getMitspieler().stream()
.filter(m -> m.isEigenesGeraet() && m.getUserId() != null && !userId.equals(m.getUserId()))
.forEach(m -> systemMessageService.send(userId, m.getUserId(), nachricht, "/userhome.html", MessageCause.GAME_STATE));
aktiveSperreRepository.deleteAll(session.getAktiveSperren());
mitspielerRepository.deleteAll(session.getMitspieler());
sessionRepository.delete(session);
return ResponseEntity.accepted().build();
}
@PostMapping("/{sessionId}/aufgaben")
public ResponseEntity<Void> setAufgaben(@RequestBody AufgabenList list, @PathVariable UUID sessionId) {
try {
if (list.size() > 1000) {
return ResponseEntity.badRequest().build();
}
String aufgaben = objectMapper.writeValueAsString(list);
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) {
return ResponseEntity.badRequest().build();
}
session.setAufgaben(aufgaben);
sessionRepository.save(session);
// Erst jetzt Einladungen mit der Session verknüpfen Gäste werden nur weitergeleitet wenn aufgaben bereit sind
if (session.getSetupId() != null) {
einladungRepository.findBySetupId(session.getSetupId()).stream()
.filter(e -> e.getStatus() == BdsmEinladungEntity.Status.ACCEPTED_OWN
|| e.getStatus() == BdsmEinladungEntity.Status.ACCEPTED_HOST)
.forEach(e -> e.setSessionId(session.getSessionId()));
}
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/{sessionId}/aufgaben/next")
public ResponseEntity<AufgabeAnzeige> getNextAufgabe(@PathVariable UUID sessionId) {
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null || session.getAufgaben() == null) {
return ResponseEntity.badRequest().build();
}
session.setLetzteAktivitaet(LocalDateTime.now());
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
AufgabeAnzeige next = durchfuehren.getNext();
session.setLevel(durchfuehren.getLevel());
session.setAufgabenAufAktuellemLevel(durchfuehren.getAufgabenAufAktuellemLevel());
if (next == null) {
return ResponseEntity.noContent().build();
}
next.setLevel(durchfuehren.getLevel());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Neue Aufgabe [sessionId={}, level={}, aufgaben={}/{}, aktiveSperren={}]",
sessionId, session.getLevel(), session.getAufgabenAufAktuellemLevel(),
session.getAufgabenProLevel(), session.getAktiveSperren().size());
session.getAktiveSperren().forEach(s ->
LOGGER.debug(" Sperre [mitspieler={}, {}min, ende={}]",
s.getMitspieler().getName(), s.getMinuten(), s.getEndzeit()));
}
return ResponseEntity.ok(next);
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/{sessionId}/mitspieler")
public ResponseEntity<Void> addMitspieler(@RequestBody BdsmMitspieler mitspieler, @PathVariable UUID sessionId) {
if (mitspieler.getName() == null || mitspieler.getGeschlecht() == null || mitspieler.getRollen() == null
|| mitspieler.getRollen().isEmpty() || mitspieler.getSpieltMit() == null || mitspieler.getSpieltMit().isEmpty()
|| mitspieler.getVerfuegbareWerkzeuge() == null || mitspieler.getVerfuegbareWerkzeuge().isEmpty()) {
return ResponseEntity.badRequest().build();
}
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) {
return ResponseEntity.badRequest().build();
}
MitspielerEntity entity = new MitspielerEntity();
entity.setMitspielerId(UUID.randomUUID());
entity.setGeschlecht(mitspieler.getGeschlecht());
entity.setName(mitspieler.getName());
entity.setRollen(mitspieler.getRollen());
entity.setSpieltMit(mitspieler.getSpieltMit());
entity.setWerkzeuge(new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge()));
entity.setUserId(mitspieler.getUserId());
entity.setEigenesGeraet(mitspieler.isEigenesGeraet());
entity.setSperrenVorFinaleAufloesen(mitspieler.isSperrenVorFinaleAufloesen());
entity.setSession(session);
mitspielerRepository.save(entity);
// Aktive Chastity-Lockees: 365-Tage-Zeitstrafe auf das gesperrte Körperteil
if (mitspieler.getUserId() != null
&& cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(mitspieler.getUserId())) {
List<Werkzeug> locked = new ArrayList<>();
if (mitspieler.getGeschlecht() == GeschlechtEnum.WEIBLICH) locked.add(Werkzeug.VAGINA);
else if (mitspieler.getGeschlecht() == GeschlechtEnum.MAENNLICH) locked.add(Werkzeug.PENIS);
else { locked.add(Werkzeug.VAGINA); locked.add(Werkzeug.PENIS); }
if (!locked.isEmpty()) {
// Gesperrte Werkzeuge force-hinzufügen (auch wenn Checkbox nicht angekreuzt)
locked.forEach(w -> { if (!entity.getWerkzeuge().contains(w)) entity.getWerkzeuge().add(w); });
LocalDateTime now = LocalDateTime.now();
AktiveSperreEntity chastitySperre = new AktiveSperreEntity();
chastitySperre.setAktiveSperreId(UUID.randomUUID());
chastitySperre.setMitspieler(entity);
chastitySperre.setSession(session);
chastitySperre.setFuer(locked);
chastitySperre.setMinuten(1440);
chastitySperre.setStartzeit(now);
chastitySperre.setEndzeit(now.plusHours(24));
chastitySperre.setReleaseText(entity.getName() + " hat die Keuschheit durchgehalten das Schloss ist ab sofort offen.");
aktiveSperreRepository.save(chastitySperre);
// Werkzeug für die Spieldauer durch die Zeitstrafe sperren
locked.forEach(entity.getWerkzeuge()::remove);
mitspielerRepository.save(entity);
}
}
return ResponseEntity.accepted().build();
}
@GetMapping("/{sessionId}/finisher")
public ResponseEntity<List<AufgabeAnzeige>> getFinisher(@PathVariable UUID sessionId) {
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
return ResponseEntity.ok(durchfuehren.getFinisher());
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/{sessionId}/backToLevel5")
public ResponseEntity<Void> backToLevel5(@PathVariable UUID sessionId) {
try {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.badRequest().build();
BdsmGameDurchfuehren durchfuehren = new BdsmGameDurchfuehren(session);
durchfuehren.backToLvl5();
session.setLevel(durchfuehren.getLevel());
session.setAufgabenAufAktuellemLevel(durchfuehren.getAufgabenAufAktuellemLevel());
sessionRepository.save(session);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
@GetMapping("/{sessionId}/mitspieler/me")
public ResponseEntity<Map<String, Object>> getMeinMitspieler(@PathVariable UUID sessionId, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
return session.getMitspieler().stream()
.filter(m -> userId.equals(m.getUserId()))
.findFirst()
.map(m -> {
Map<String, Object> result = new LinkedHashMap<>();
result.put("mitspielerId", m.getMitspielerId());
result.put("name", m.getName());
result.put("eigenesGeraet", m.isEigenesGeraet());
return ResponseEntity.ok(result);
})
.orElse(ResponseEntity.noContent().build());
}
record AbschliessenRequest(boolean sperreAnwenden) {}
record SperreFreigabe(String text, UUID mitspielerId, boolean eigenesGeraet) {}
record AbschliessenResponse(List<SperreFreigabe> abgelaufeneSperren) {}
@PostMapping("/{sessionId}/active-task/abschliessen")
public ResponseEntity<AbschliessenResponse> activeTaskAbschliessen(
@PathVariable UUID sessionId, @RequestBody AbschliessenRequest req) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
SperreVerarbeiten sperreVerarbeiten = new SperreVerarbeiten();
if (req.sperreAnwenden() && session.getActiveTaskJson() != null) {
try {
JsonNode task = objectMapper.readTree(session.getActiveTaskJson());
JsonNode cb = task.get("callback");
if (cb != null && !cb.isNull()) {
if (cb.has("sperreId") && !cb.get("sperreId").isNull()) {
SperreCallback callback = objectMapper.treeToValue(cb, SperreCallback.class);
callback.setSessionId(sessionId);
sperreVerarbeiten.sperreAnwenden(callback, sessionRepository, mitspielerRepository, aktiveSperreRepository);
LOGGER.info("Zeitstrafe via abschliessen angewandt [session={}, spieler={}]", sessionId, callback.getSpielerId());
} else if (cb.has("faktor") && !cb.get("faktor").isNull()) {
SperrenVerlaengernCallback callback = objectMapper.treeToValue(cb, SperrenVerlaengernCallback.class);
List<AktiveSperreEntity> locks = aktiveSperreRepository.findAktiveLocks(callback.getSpielerId());
locks.forEach(lock -> sperreVerarbeiten.sperreVerlaengern(lock, callback.getFaktor(), aktiveSperreRepository));
LOGGER.info("Sperren via abschliessen verlängert [session={}, spieler={}, faktor={}]", sessionId, callback.getSpielerId(), callback.getFaktor());
}
}
} catch (Exception e) {
LOGGER.error("Fehler beim Verarbeiten des Callbacks beim Abschließen: {}", e.getMessage(), e);
}
}
session.setActiveTaskJson(null);
session.setTaskStartedAt(null);
sessionRepository.save(session);
List<SperreFreigabe> freigaben = new ArrayList<>();
aktiveSperreRepository.findAbgelaufene(sessionId, LocalDateTime.now()).forEach(s -> {
UUID mitspielerId = s.getMitspieler().getMitspielerId();
boolean eigenesGeraet = s.getMitspieler().isEigenesGeraet();
String t = sperreVerarbeiten.sperreAufheben(s, aktiveSperreRepository, mitspielerRepository);
if (t != null && !t.isBlank()) freigaben.add(new SperreFreigabe(t, mitspielerId, eigenesGeraet));
});
return ResponseEntity.ok(new AbschliessenResponse(freigaben));
}
record ActiveTaskRequest(String taskJson, LocalDateTime timerStartedAt) {}
record ActiveTaskResponse(String taskJson, Long elapsedSeconds) {}
@PutMapping("/{sessionId}/active-task")
public ResponseEntity<Void> setActiveTask(@PathVariable UUID sessionId, @RequestBody ActiveTaskRequest req) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
session.setActiveTaskJson(req.taskJson());
session.setTaskStartedAt(req.timerStartedAt());
sessionRepository.save(session);
return ResponseEntity.accepted().build();
}
@DeleteMapping("/{sessionId}/active-task")
public ResponseEntity<Void> clearActiveTask(@PathVariable UUID sessionId) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
session.setActiveTaskJson(null);
session.setTaskStartedAt(null);
sessionRepository.save(session);
return ResponseEntity.accepted().build();
}
@GetMapping("/{sessionId}/active-task")
public ResponseEntity<ActiveTaskResponse> getActiveTask(@PathVariable UUID sessionId) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
if (session.getActiveTaskJson() == null) return ResponseEntity.noContent().build();
Long elapsed = null;
if (session.getTaskStartedAt() != null) {
elapsed = Duration.between(session.getTaskStartedAt(), LocalDateTime.now()).getSeconds();
}
return ResponseEntity.ok(new ActiveTaskResponse(session.getActiveTaskJson(), elapsed));
}
// ── Keyholder-Angebot: prüft ob am Ende eine VAGINA/PENIS-Sperre vorliegt ──
@GetMapping("/{sessionId}/keyholder-angebot")
public ResponseEntity<Map<String, Object>> keyholderAngebot(@PathVariable UUID sessionId) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
// Alle noch in der DB vorhandenen VAGINA/PENIS-Sperren auch abgelaufene,
// da im Finale-Flow bereits abgelaufene Sperren noch nicht formal aufgehoben wurden.
List<AktiveSperreEntity> relevantesSperren = session.getAktiveSperren().stream()
.filter(s -> s.getFuer().contains(Werkzeug.VAGINA) || s.getFuer().contains(Werkzeug.PENIS))
.toList();
for (AktiveSperreEntity sperre : relevantesSperren) {
MitspielerEntity lockee = sperre.getMitspieler();
if (lockee == null || lockee.getUserId() == null || lockee.getGeschlecht() == null) continue;
// Kein Angebot wenn Lockee bereits aktiv in einem Chastity-Game gesperrt ist
if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockee.getUserId())) continue;
for (MitspielerEntity kandidat : session.getMitspieler()) {
if (kandidat.getMitspielerId().equals(lockee.getMitspielerId())) continue;
if (kandidat.getUserId() == null) continue;
if (!kandidat.getSpieltMit().contains(lockee.getGeschlecht())) continue;
List<CardLockEntity> locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(kandidat.getUserId());
if (locks.isEmpty()) continue;
Map<String, Object> result = new LinkedHashMap<>();
result.put("lockeeId", lockee.getMitspielerId());
result.put("lockeeName", lockee.getName());
result.put("lockeeUserId", lockee.getUserId());
result.put("keyholderMitspielerId", kandidat.getMitspielerId());
result.put("keyholderName", kandidat.getName());
result.put("keyholderUserId", kandidat.getUserId());
return ResponseEntity.ok(result);
}
}
return ResponseEntity.noContent().build();
}
@GetMapping("/{sessionId}/keyholder-locks")
public ResponseEntity<List<Map<String, Object>>> keyholderLocks(
@PathVariable UUID sessionId, @RequestParam UUID keyholderUserId) {
BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null);
if (session == null) return ResponseEntity.notFound().build();
List<Map<String, Object>> result = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(keyholderUserId).stream()
.map(l -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("lockId", l.getLockId());
item.put("name", l.getName() != null ? l.getName() : "Unbenanntes Lock");
item.put("pickEveryMinute", l.getPickEveryMinute());
item.put("totalCards", l.getInitialCards() != null ? l.getInitialCards().size() : 0);
item.put("active", l.getStartTime() != null && l.getUnlockTime() == null);
return item;
})
.toList();
if (result.isEmpty()) return ResponseEntity.noContent().build();
return ResponseEntity.ok(result);
}
record ZuChastityRequest(UUID lockId, UUID lockeeUserId, UUID keyholderUserId) {}
@PostMapping("/{sessionId}/zu-chastity")
public ResponseEntity<Map<String, Object>> zuChastity(
@PathVariable UUID sessionId, @RequestBody ZuChastityRequest req) {
try {
CardLockEntity newLock = bdsmGameService.zuChastity(
sessionId, req.lockId(), req.lockeeUserId(), req.keyholderUserId());
Map<String, Object> response = new LinkedHashMap<>();
response.put("lockId", newLock.getLockId().toString());
response.put("unlockCode", newLock.getUnlockCode());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
String msg = e.getMessage();
if (msg != null && msg.contains("Session")) return ResponseEntity.notFound().build();
return ResponseEntity.badRequest().build();
} catch (IllegalStateException e) {
return ResponseEntity.status(409).build();
}
}
/** Gibt zurück welches Werkzeug für einen User durch ein aktives Chastity-Lock blockiert ist. */
@GetMapping("/chastity-constraint")
public ResponseEntity<Map<String, Object>> chastityConstraint(@RequestParam UUID userId) {
Map<String, Object> result = new LinkedHashMap<>();
if (!cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(userId)) {
result.put("lockedWerkzeug", null);
return ResponseEntity.ok(result);
}
return userRepository.findById(userId).map(u -> {
String werkzeug = null;
if (u.getGeschlecht() != null) {
werkzeug = switch (u.getGeschlecht().name()) {
case "WEIBLICH" -> "VAGINA";
case "MAENNLICH" -> "PENIS";
default -> "BOTH";
};
}
result.put("lockedWerkzeug", werkzeug);
return ResponseEntity.ok(result);
}).orElseGet(() -> {
result.put("lockedWerkzeug", null);
return ResponseEntity.ok(result);
});
}
// ── Debug-Endpoint: vollständiger Entity-Zustand ──
@GetMapping("/{sessionId}/debug")
public ResponseEntity<Map<String, Object>> debug(@PathVariable UUID sessionId) {
BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
Map<String, Object> session = new LinkedHashMap<>();
session.put("sessionId", entity.getSessionId());
session.put("userId", entity.getUserId());
session.put("setupId", entity.getSetupId());
session.put("startZeit", entity.getStartZeit());
session.put("letzteAktivitaet", entity.getLetzteAktivitaet());
session.put("level", entity.getLevel());
session.put("aufgabenAufAktuellemLevel", entity.getAufgabenAufAktuellemLevel());
session.put("aufgabenProLevel", entity.getAufgabenProLevel());
session.put("wahrscheinlichkeitSperre", entity.getWahrscheinlichkeitSperre());
session.put("wahrscheinlichkeitStrafe", entity.getWahrscheinlichkeitStrafe());
session.put("zeitfaktorZeitstrafen", entity.getZeitfaktorZeitstrafen());
session.put("taskStartedAt", entity.getTaskStartedAt());
session.put("hatAufgaben", entity.getAufgaben() != null);
session.put("hatActiveTask", entity.getActiveTaskJson() != null);
List<Map<String, Object>> mitspielerList = entity.getMitspieler().stream().map(m -> {
Map<String, Object> mp = new LinkedHashMap<>();
mp.put("mitspielerId", m.getMitspielerId());
mp.put("name", m.getName());
mp.put("userId", m.getUserId());
mp.put("geschlecht", m.getGeschlecht());
mp.put("rollen", m.getRollen());
mp.put("werkzeuge", m.getWerkzeuge());
mp.put("spieltMit", m.getSpieltMit());
mp.put("eigenesGeraet", m.isEigenesGeraet());
mp.put("sperrenVorFinaleAufloesen", m.isSperrenVorFinaleAufloesen());
return mp;
}).toList();
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> sperrenList = entity.getAktiveSperren().stream().map(s -> {
Map<String, Object> sp = new LinkedHashMap<>();
sp.put("aktiveSperreId", s.getAktiveSperreId());
sp.put("mitspielerName", s.getMitspieler() != null ? s.getMitspieler().getName() : null);
sp.put("fuer", s.getFuer());
sp.put("minuten", s.getMinuten());
sp.put("startzeit", s.getStartzeit());
sp.put("endzeit", s.getEndzeit());
sp.put("abgelaufen", s.getEndzeit() != null && s.getEndzeit().isBefore(now));
sp.put("releaseText", s.getReleaseText());
return sp;
}).toList();
Map<String, Object> result = new LinkedHashMap<>();
result.put("session", session);
result.put("mitspieler", mitspielerList);
result.put("aktiveSperren", sperrenList);
return ResponseEntity.ok(result);
}
private BdsmGame toSession(BdsmGameEntity entity) {
BdsmGame session = new BdsmGame();
session.setSessionId(entity.getSessionId());
session.setUserId(entity.getUserId());
session.setAufgabenProLevel(entity.getAufgabenProLevel());
session.setWahrscheinlichkeitSperre(entity.getWahrscheinlichkeitSperre());
session.setWahrscheinlichkeitStrafe(entity.getWahrscheinlichkeitStrafe());
session.setZeitfaktorZeitstrafen(entity.getZeitfaktorZeitstrafen());
session.setLevel(entity.getLevel());
session.setAufgabenAufAktuellemLevel(entity.getAufgabenAufAktuellemLevel());
session.setStartZeit(entity.getStartZeit());
session.setLetzteAktivitaet(entity.getLetzteAktivitaet());
return session;
}
}

View File

@@ -0,0 +1,71 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.bdsm.entity.BdsmSetupDraftEntity;
import de.oaa.xxx.games.bdsm.repository.BdsmSetupDraftRepository;
import de.oaa.xxx.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/bdsm/setup-draft")
@Transactional
public class BdsmSetupDraftController {
private final BdsmSetupDraftRepository draftRepository;
private final UserService userService;
public BdsmSetupDraftController(BdsmSetupDraftRepository draftRepository, UserService userService) {
this.draftRepository = draftRepository;
this.userService = userService;
}
record DraftRequest(String setupId, String settingsJson, String setupJson, String gruppenJson) {}
@GetMapping
public ResponseEntity<Map<String, Object>> getDraft(
@RequestParam(required = false) String setupId,
Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
var lookup = (setupId != null && !setupId.isBlank())
? draftRepository.findBySetupId(setupId)
: draftRepository.findByUserId(userId);
return lookup
.map(d -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("setupId", d.getSetupId());
m.put("settingsJson", d.getSettingsJson());
m.put("setupJson", d.getSetupJson());
m.put("gruppenJson", d.getGruppenJson());
return ResponseEntity.ok(m);
})
.orElse(ResponseEntity.noContent().build());
}
@PutMapping
public ResponseEntity<Void> saveDraft(@RequestBody DraftRequest req, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
BdsmSetupDraftEntity d = draftRepository.findByUserId(userId)
.orElseGet(() -> { BdsmSetupDraftEntity n = new BdsmSetupDraftEntity(); n.setUserId(userId); return n; });
if (req.setupId() != null) d.setSetupId(req.setupId());
if (req.settingsJson() != null) d.setSettingsJson(req.settingsJson());
if (req.setupJson() != null) d.setSetupJson(req.setupJson());
if (req.gruppenJson() != null) d.setGruppenJson(req.gruppenJson());
d.setUpdatedAt(LocalDateTime.now());
draftRepository.save(d);
return ResponseEntity.accepted().build();
}
@DeleteMapping
public ResponseEntity<Void> deleteDraft(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
draftRepository.findByUserId(userId).ifPresent(draftRepository::delete);
return ResponseEntity.accepted().build();
}
}

View File

@@ -0,0 +1,89 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Favorit;
import de.oaa.xxx.games.common.aufgaben.FavoritList;
import de.oaa.xxx.games.common.entity.FavoritEntity;
import de.oaa.xxx.games.common.repository.FavoritRepository;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.security.Principal;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/favorit")
@Transactional
public class FavoritController {
private static final Logger LOGGER = LoggerFactory.getLogger(FavoritController.class);
private final FavoritRepository favoritRepository;
private final UserService userService;
public FavoritController(FavoritRepository favoritRepository, UserService userService) {
this.favoritRepository = favoritRepository;
this.userService = userService;
}
@GetMapping("/{favoritId}")
public ResponseEntity<Favorit> get(@PathVariable UUID favoritId) {
return favoritRepository.findById(favoritId)
.map(entity -> ResponseEntity.ok(entity.toFavorit()))
.orElse(ResponseEntity.noContent().build());
}
@GetMapping
public ResponseEntity<FavoritList> all(Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
List<FavoritEntity> entities = favoritRepository.findByUserId(userId);
FavoritList result = new FavoritList();
result.setFavoriten(entities.stream().map(FavoritEntity::toFavorit).toList());
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Favorit favorit, Principal principal) {
UUID userId = userService.requireUser(principal).getUserId();
if (favorit.getAufgabenGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
List<FavoritEntity> existing = favoritRepository.findByUserIdAndAufgabenGruppeId(userId, favorit.getAufgabenGruppeId());
FavoritEntity entity;
if (existing.isEmpty()) {
entity = FavoritEntity.fromFavorit(favorit, userId);
favoritRepository.save(entity);
LOGGER.debug("User {} hat AufgabenGruppe {} als Favorit gespeichert", userId, favorit.getAufgabenGruppeId());
} else {
entity = existing.get(0);
}
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getFavoritId()).toUri()
).build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Favorit favorit, Principal principal) {
try {
UUID userId = userService.requireUser(principal).getUserId();
favoritRepository.findByUserIdAndAufgabenGruppeId(userId, favorit.getAufgabenGruppeId())
.forEach(favoritRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
}

View File

@@ -0,0 +1,34 @@
package de.oaa.xxx.games.bdsm.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import de.oaa.xxx.games.common.aufgaben.DefaultFiller;
@RestController
@RequestMapping("/filler")
public class FillerController {
private static final Logger LOGGER = LoggerFactory.getLogger(FillerController.class);
private final DefaultFiller defaultFiller;
public FillerController(DefaultFiller defaultFiller) {
this.defaultFiller = defaultFiller;
}
@PostMapping
public ResponseEntity<Void> fill() {
try {
defaultFiller.fill();
return ResponseEntity.ok().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
}

View File

@@ -0,0 +1,116 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Finisher;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.FinisherEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.FinisherRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/finisher")
@Transactional
public class FinisherController {
private static final Logger LOGGER = LoggerFactory.getLogger(FinisherController.class);
private final FinisherRepository finisherRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final ToyRepository toyRepository;
public FinisherController(FinisherRepository finisherRepository,
AufgabenGruppeRepository gruppeRepository,
ToyRepository toyRepository) {
this.finisherRepository = finisherRepository;
this.gruppeRepository = gruppeRepository;
this.toyRepository = toyRepository;
}
@GetMapping("/{finisherId}")
public ResponseEntity<Finisher> get(@PathVariable UUID finisherId) {
return finisherRepository.findById(finisherId)
.map(entity -> ResponseEntity.ok(entity.toFinisher()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null
|| finisher.getGeschlecht() == null || finisher.getGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(finisher.getGruppeId()).orElse(null);
if (gruppeEntity == null) {
return ResponseEntity.badRequest().build();
}
if (gruppeEntity.getFinisher().size() >= 100) {
return ResponseEntity.status(409).build();
}
List<ToyEntity> toys = resolveToys(finisher.getBenoetigteToys());
FinisherEntity entity = FinisherEntity.create(finisher, gruppeEntity, toys);
finisherRepository.save(entity);
LOGGER.debug("Finisher {} '{}' in Gruppe {} erstellt", entity.getFinisherId(), entity.getKurzText(), finisher.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getFinisherId()).toUri()
).build();
}
@PutMapping("/{finisherId}")
public ResponseEntity<Void> update(@PathVariable UUID finisherId, @RequestBody Finisher finisher) {
if (finisher.getKurzText() == null || finisher.getText() == null || finisher.getGeschlecht() == null) {
return ResponseEntity.badRequest().build();
}
FinisherEntity entity = finisherRepository.findById(finisherId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
entity.setKurzText(finisher.getKurzText());
entity.setText(finisher.getText());
entity.setGeschlecht(finisher.getGeschlecht());
entity.setBenoetigtAktiv(finisher.getBenoetigtAktiv());
entity.setBenoetigtPassiv(finisher.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(finisher.getBenoetigteToys()));
finisherRepository.save(entity);
LOGGER.debug("Finisher {} aktualisiert", finisherId);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Finisher finisher) {
try {
finisherRepository.findById(finisher.getFinisherId()).ifPresent(finisherRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private List<ToyEntity> resolveToys(List<Toy> toys) {
if (toys == null || toys.isEmpty()) return new ArrayList<>();
List<UUID> ids = toys.stream()
.filter(t -> t.getToyId() != null)
.map(Toy::getToyId)
.toList();
if (ids.isEmpty()) return new ArrayList<>();
return toyRepository.findAllById(ids);
}
}

View File

@@ -0,0 +1,118 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Sperre;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.SperreEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.SperreRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController("aufgabenSperreController")
@RequestMapping("/sperre")
@Transactional
public class SperreController {
private static final Logger LOGGER = LoggerFactory.getLogger(SperreController.class);
private final SperreRepository sperreRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final ToyRepository toyRepository;
public SperreController(SperreRepository sperreRepository,
AufgabenGruppeRepository gruppeRepository,
ToyRepository toyRepository) {
this.sperreRepository = sperreRepository;
this.gruppeRepository = gruppeRepository;
this.toyRepository = toyRepository;
}
@GetMapping("/{sperreId}")
public ResponseEntity<Sperre> get(@PathVariable UUID sperreId) {
return sperreRepository.findById(sperreId)
.map(entity -> ResponseEntity.ok(entity.toSperre()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Sperre sperre) {
if (sperre.getKurzText() == null || sperre.getText() == null || sperre.getMinutenVon() == null
|| sperre.getGruppeId() == null || sperre.getSperreFuer() == null || sperre.getSperreFuer().isEmpty()) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(sperre.getGruppeId()).orElse(null);
if (gruppeEntity == null) {
return ResponseEntity.badRequest().build();
}
if (gruppeEntity.getSperren().size() >= 100) {
return ResponseEntity.status(409).build();
}
List<ToyEntity> toys = resolveToys(sperre.getBenoetigteToys());
SperreEntity entity = SperreEntity.create(sperre, gruppeEntity, toys);
sperreRepository.save(entity);
LOGGER.debug("Sperre {} '{}' in Gruppe {} erstellt", entity.getSperreId(), entity.getKurzText(), sperre.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getSperreId()).toUri()
).build();
}
@PutMapping("/{sperreId}")
public ResponseEntity<Void> update(@PathVariable UUID sperreId, @RequestBody Sperre sperre) {
if (sperre.getKurzText() == null || sperre.getText() == null || sperre.getMinutenVon() == null
|| sperre.getSperreFuer() == null || sperre.getSperreFuer().isEmpty()) {
return ResponseEntity.badRequest().build();
}
SperreEntity entity = sperreRepository.findById(sperreId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
entity.setKurzText(sperre.getKurzText());
entity.setText(sperre.getText());
entity.setReleaseText(sperre.getReleaseText());
entity.setMinutenVon(sperre.getMinutenVon());
entity.setMinutenBis(sperre.getMinutenBis());
entity.setSperreFuer(sperre.getSperreFuer());
entity.setBenoetigteToys(resolveToys(sperre.getBenoetigteToys()));
sperreRepository.save(entity);
LOGGER.debug("Sperre {} aktualisiert", sperreId);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Sperre sperre) {
try {
sperreRepository.findById(sperre.getSperreId()).ifPresent(sperreRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private List<ToyEntity> resolveToys(List<Toy> toys) {
if (toys == null || toys.isEmpty()) return new ArrayList<>();
List<UUID> ids = toys.stream()
.filter(t -> t.getToyId() != null)
.map(Toy::getToyId)
.toList();
if (ids.isEmpty()) return new ArrayList<>();
return toyRepository.findAllById(ids);
}
}

View File

@@ -0,0 +1,117 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Strafe;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.StrafeEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository;
import de.oaa.xxx.games.common.repository.StrafeRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/strafe")
@Transactional
public class StrafeController {
private static final Logger LOGGER = LoggerFactory.getLogger(StrafeController.class);
private final StrafeRepository strafeRepository;
private final AufgabenGruppeRepository gruppeRepository;
private final ToyRepository toyRepository;
public StrafeController(StrafeRepository strafeRepository,
AufgabenGruppeRepository gruppeRepository,
ToyRepository toyRepository) {
this.strafeRepository = strafeRepository;
this.gruppeRepository = gruppeRepository;
this.toyRepository = toyRepository;
}
@GetMapping("/{strafeId}")
public ResponseEntity<Strafe> get(@PathVariable UUID strafeId) {
return strafeRepository.findById(strafeId)
.map(entity -> ResponseEntity.ok(entity.toStrafe()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Strafe strafe) {
if (strafe.getKurzText() == null || strafe.getText() == null || strafe.getLevel() == null || strafe.getGruppeId() == null) {
return ResponseEntity.badRequest().build();
}
AufgabenGruppeEntity gruppeEntity = gruppeRepository.findById(strafe.getGruppeId()).orElse(null);
if (gruppeEntity == null) {
return ResponseEntity.badRequest().build();
}
if (gruppeEntity.getStrafen().size() >= 100) {
return ResponseEntity.status(409).build();
}
List<ToyEntity> toys = resolveToys(strafe.getBenoetigteToys());
StrafeEntity entity = StrafeEntity.create(strafe, gruppeEntity, toys);
strafeRepository.save(entity);
LOGGER.debug("Strafe {} '{}' in Gruppe {} erstellt", entity.getStrafeId(), entity.getKurzText(), strafe.getGruppeId());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getStrafeId()).toUri()
).build();
}
@PutMapping("/{strafeId}")
public ResponseEntity<Void> update(@PathVariable UUID strafeId, @RequestBody Strafe strafe) {
if (strafe.getKurzText() == null || strafe.getText() == null || strafe.getLevel() == null) {
return ResponseEntity.badRequest().build();
}
StrafeEntity entity = strafeRepository.findById(strafeId).orElse(null);
if (entity == null) return ResponseEntity.notFound().build();
entity.setKurzText(strafe.getKurzText());
entity.setText(strafe.getText());
entity.setLevel(strafe.getLevel());
entity.setSekundenVon(strafe.getSekundenVon());
entity.setSekundenBis(strafe.getSekundenBis());
entity.setBenoetigtAktiv(strafe.getBenoetigtAktiv());
entity.setBenoetigtPassiv(strafe.getBenoetigtPassiv());
entity.setBenoetigteToys(resolveToys(strafe.getBenoetigteToys()));
strafeRepository.save(entity);
LOGGER.debug("Strafe {} aktualisiert", strafeId);
return ResponseEntity.ok().build();
}
@DeleteMapping
public ResponseEntity<Void> delete(@RequestBody Strafe strafe) {
try {
strafeRepository.findById(strafe.getStrafeId()).ifPresent(strafeRepository::delete);
return ResponseEntity.accepted().build();
} catch (Exception exception) {
LOGGER.error(exception.getMessage(), exception);
return ResponseEntity.internalServerError().build();
}
}
private List<ToyEntity> resolveToys(List<Toy> toys) {
if (toys == null || toys.isEmpty()) return new ArrayList<>();
List<UUID> ids = toys.stream()
.filter(t -> t.getToyId() != null)
.map(Toy::getToyId)
.toList();
if (ids.isEmpty()) return new ArrayList<>();
return toyRepository.findAllById(ids);
}
}

View File

@@ -0,0 +1,243 @@
package de.oaa.xxx.games.bdsm.controller;
import de.oaa.xxx.games.common.aufgaben.Toy;
import de.oaa.xxx.games.common.aufgaben.ToyPage;
import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity;
import de.oaa.xxx.games.common.entity.ToyEntity;
import de.oaa.xxx.games.common.repository.GruppenAboRepository;
import de.oaa.xxx.games.common.repository.ToyRepository;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserEntity;
import de.oaa.xxx.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/toy")
@Transactional
public class ToyController {
private static final Logger LOGGER = LoggerFactory.getLogger(ToyController.class);
private static final int DEFAULT_PAGE_SIZE = 12;
private final ToyRepository toyRepository;
private final UserService userService;
private final GruppenAboRepository aboRepository;
private final SubscriptionLimitService limitService;
public ToyController(ToyRepository toyRepository,
UserService userService,
GruppenAboRepository aboRepository,
SubscriptionLimitService limitService) {
this.toyRepository = toyRepository;
this.userService = userService;
this.aboRepository = aboRepository;
this.limitService = limitService;
}
@GetMapping("/list/user")
public ResponseEntity<ToyPage> listUser(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size,
Principal principal) {
UserEntity user = userService.requireUser(principal);
Page<ToyEntity> result = toyRepository.findByUserId(
user.getUserId(), PageRequest.of(page, size, Sort.by("name")));
return ResponseEntity.ok(toToyPage(result));
}
@GetMapping("/list/system")
public ResponseEntity<ToyPage> listSystem(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) {
Page<ToyEntity> result = toyRepository.findByUserIdIsNull(
PageRequest.of(page, size, Sort.by("name")));
return ResponseEntity.ok(toToyPage(result));
}
/**
* Returns all toys available to the current user for assignment to items:
* own toys + system toys + toys referenced in subscribed groups' items.
*/
@GetMapping("/available")
public ResponseEntity<List<Toy>> available(Principal principal) {
UserEntity user = userService.requireUser(principal);
List<ToyEntity> own = toyRepository.findByUserId(user.getUserId(), PageRequest.of(0, 500, Sort.by("name"))).getContent();
List<ToyEntity> system = toyRepository.findByUserIdIsNull(PageRequest.of(0, 500, Sort.by("name"))).getContent();
Set<UUID> knownIds = new HashSet<>();
own.forEach(t -> knownIds.add(t.getToyId()));
system.forEach(t -> knownIds.add(t.getToyId()));
Set<ToyEntity> fromAbos = new HashSet<>();
aboRepository.findByUserId(user.getUserId()).forEach(abo -> {
AufgabenGruppeEntity gruppe = abo.getAufgabenGruppe();
gruppe.getAufgaben().forEach(a -> {
if (a.getBenoetigteToys() != null)
a.getBenoetigteToys().stream().filter(t -> !knownIds.contains(t.getToyId())).forEach(fromAbos::add);
});
gruppe.getStrafen().forEach(s -> {
if (s.getBenoetigteToys() != null)
s.getBenoetigteToys().stream().filter(t -> !knownIds.contains(t.getToyId())).forEach(fromAbos::add);
});
gruppe.getSperren().forEach(sp -> {
if (sp.getBenoetigteToys() != null)
sp.getBenoetigteToys().stream().filter(t -> !knownIds.contains(t.getToyId())).forEach(fromAbos::add);
});
});
List<Toy> result = new ArrayList<>();
result.addAll(own.stream().map(ToyEntity::toToy).toList());
result.addAll(system.stream().map(ToyEntity::toToy).toList());
result.addAll(fromAbos.stream()
.sorted(Comparator.comparing(ToyEntity::getName, String.CASE_INSENSITIVE_ORDER))
.map(ToyEntity::toToy).toList());
return ResponseEntity.ok(result);
}
@GetMapping("/{toyId}")
public ResponseEntity<Toy> get(@PathVariable UUID toyId) {
return toyRepository.findById(toyId)
.map(entity -> ResponseEntity.ok(entity.toToy()))
.orElse(ResponseEntity.noContent().build());
}
@PostMapping
public ResponseEntity<Void> create(@RequestBody Toy toy, Principal principal) {
if (toy.getName() == null || toy.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = userService.requireUser(principal);
if (toyRepository.existsByNameIgnoreCaseAndUserIdIsNull(toy.getName())
|| toyRepository.existsByNameIgnoreCaseAndUserId(toy.getName(), user.getUserId())) {
return ResponseEntity.status(409)
.header("X-Error", "duplicate-name")
.build();
}
if (toyRepository.countByUserId(user.getUserId()) >= limitService.maxToys(user.getUserId())) {
return ResponseEntity.status(409)
.header("X-Error", "limit-reached")
.build();
}
ToyEntity entity = ToyEntity.create(toy);
entity.setUserId(user.getUserId());
toyRepository.save(entity);
LOGGER.debug("User {} hat Toy {} '{}' erstellt", user.getUserId(), entity.getToyId(), entity.getName());
return ResponseEntity.created(
ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getToyId()).toUri()
).build();
}
@PostMapping("/copy/{toyId}")
public ResponseEntity<Void> copy(@PathVariable UUID toyId, Principal principal) {
UserEntity user = userService.requireUser(principal);
ToyEntity source = toyRepository.findById(toyId).orElse(null);
if (source == null) {
return ResponseEntity.notFound().build();
}
if (source.getUserId() != null) {
return ResponseEntity.status(403).build();
}
if (toyRepository.existsByNameIgnoreCaseAndUserId(source.getName(), user.getUserId())) {
return ResponseEntity.status(409)
.header("X-Error", "duplicate-name")
.build();
}
ToyEntity copy = new ToyEntity();
copy.setToyId(UUID.randomUUID());
copy.setName(source.getName());
copy.setBeschreibung(source.getBeschreibung());
copy.setUserId(user.getUserId());
copy.setBild(source.getBild());
toyRepository.save(copy);
LOGGER.debug("User {} hat System-Toy {} kopiert (Kopie: {})", user.getUserId(), toyId, copy.getToyId());
return ResponseEntity.status(201).build();
}
@PutMapping("/{toyId}")
public ResponseEntity<Void> update(@PathVariable UUID toyId, @RequestBody Toy toy, Principal principal) {
if (toy.getName() == null || toy.getName().isBlank()) {
return ResponseEntity.badRequest().build();
}
UserEntity user = userService.requireUser(principal);
ToyEntity entity = toyRepository.findById(toyId).orElse(null);
if (entity == null) {
return ResponseEntity.notFound().build();
}
if (!user.getUserId().equals(entity.getUserId())) {
return ResponseEntity.status(403).build();
}
if (toyRepository.existsByNameIgnoreCaseAndUserIdIsNullAndToyIdNot(toy.getName(), toyId)
|| toyRepository.existsByNameIgnoreCaseAndUserIdAndToyIdNot(toy.getName(), user.getUserId(), toyId)) {
return ResponseEntity.status(409)
.header("X-Error", "duplicate-name")
.build();
}
entity.setName(toy.getName().trim());
entity.setBeschreibung(toy.getBeschreibung());
if (toy.getBild() != null) {
entity.setBild(Base64.getDecoder().decode(toy.getBild()));
}
toyRepository.save(entity);
LOGGER.debug("User {} hat Toy {} aktualisiert", user.getUserId(), toyId);
return ResponseEntity.ok().build();
}
@DeleteMapping("/{toyId}")
public ResponseEntity<Void> delete(@PathVariable UUID toyId, Principal principal) {
UserEntity user = userService.requireUser(principal);
ToyEntity toy = toyRepository.findById(toyId).orElse(null);
if (toy == null) {
return ResponseEntity.noContent().build();
}
if (!user.getUserId().equals(toy.getUserId())) {
return ResponseEntity.status(403).build();
}
if (toyRepository.countAufgabeUsage(toyId) > 0
|| toyRepository.countStrafeUsage(toyId) > 0
|| toyRepository.countSperreUsage(toyId) > 0) {
return ResponseEntity.status(409).build();
}
try {
toyRepository.delete(toy);
return ResponseEntity.accepted().build();
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
return ResponseEntity.internalServerError().build();
}
}
private ToyPage toToyPage(Page<ToyEntity> page) {
ToyPage toyPage = new ToyPage();
toyPage.setContent(page.getContent().stream().map(ToyEntity::toToy).toList());
toyPage.setCurrentPage(page.getNumber());
toyPage.setTotalPages(page.getTotalPages());
toyPage.setTotalElements(page.getTotalElements());
return toyPage;
}
}

View File

@@ -0,0 +1,77 @@
package de.oaa.xxx.games.bdsm.entity;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import de.oaa.xxx.games.bdsm.AktiveSperre;
import de.oaa.xxx.games.bdsm.BdsmMitspieler;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "aktiveSperre")
public class AktiveSperreEntity {
@Id
@Column
private UUID aktiveSperreId;
@ManyToOne
@JoinColumn(name = "mitspielerId", nullable = false)
private MitspielerEntity mitspieler;
@Column
private Integer minuten;
@Column
private LocalDateTime startzeit;
@Column
private LocalDateTime endzeit;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class, fetch = FetchType.EAGER)
@CollectionTable(name = "aktiveSperre_fuer", joinColumns = @JoinColumn(name = "aktiveSperreId"))
@Column(name = "werkzeug")
private List<Werkzeug> fuer = new ArrayList<>();
@Column
private String releaseText;
@ManyToOne
@JoinColumn(name = "sessionId", nullable = false)
private BdsmGameEntity session;
public AktiveSperre toSperre(List<BdsmMitspieler> mitspielerList) {
AktiveSperre sperre = new AktiveSperre();
sperre.setAktiveSperreId(aktiveSperreId);
sperre.setEndzeit(endzeit);
sperre.setFuer(fuer);
sperre.setMinuten(minuten);
sperre.setMitspieler(getMitspielerFromList(mitspielerList, mitspieler.getMitspielerId()));
sperre.setReleaseText(releaseText);
sperre.setStartzeit(startzeit);
return sperre;
}
@Override
public String toString() {
return "AktiveSperreEntity[id=" + aktiveSperreId + ", mitspieler=" + (mitspieler != null ? mitspieler.getName() : null)
+ ", " + minuten + "min, von=" + startzeit + ", bis=" + endzeit + ", fuer=" + fuer + "]";
}
private BdsmMitspieler getMitspielerFromList(List<BdsmMitspieler> mitspielerList, UUID id) {
Optional<BdsmMitspieler> first = mitspielerList.stream().filter(m -> m.getId().equals(id)).findFirst();
return first.orElse(null);
}
}

View File

@@ -0,0 +1,30 @@
package de.oaa.xxx.games.bdsm.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "bdsm_defaults")
public class BdsmDefaultsEntity {
@Id
@Column(name = "user_id")
private UUID userId;
@Column(length = 100)
private String spieltMit;
@Column(length = 200)
private String rollen;
@Column(length = 200)
private String werkzeuge;
}

View File

@@ -0,0 +1,56 @@
package de.oaa.xxx.games.bdsm.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "bdsm_einladung")
public class BdsmEinladungEntity {
public enum Status {
PENDING, ACCEPTED_OWN, ACCEPTED_HOST, DECLINED, CANCELLED
}
@Id
@Column
private UUID einladungId;
@Column(nullable = false)
private UUID setupId;
@Column
private UUID sessionId;
@Column(nullable = false)
private UUID inviterId;
@Column(nullable = false)
private UUID inviteeId;
@Column(nullable = false)
private int slotIndex;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Status status;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(columnDefinition = "TEXT")
private String spielerDatenJson;
@Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT false")
private boolean bereit;
}

View File

@@ -0,0 +1,64 @@
package de.oaa.xxx.games.bdsm.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "bdsm_game")
public class BdsmGameEntity {
@Id
@Column
private UUID sessionId;
@Column(unique = true)
private UUID userId;
@Column
private LocalDateTime startZeit;
@Column
private LocalDateTime letzteAktivitaet;
@OneToMany(mappedBy = "session", fetch = FetchType.EAGER)
private List<MitspielerEntity> mitspieler = new ArrayList<>();
@OneToMany(mappedBy = "session", fetch = FetchType.EAGER)
private List<AktiveSperreEntity> aktiveSperren = new ArrayList<>();
@Column
private Integer wahrscheinlichkeitSperre;
@Column
private Integer wahrscheinlichkeitStrafe;
@Column
private Integer aufgabenProLevel;
@Column
private Integer level;
@Column
private Integer aufgabenAufAktuellemLevel;
@Column(columnDefinition = "TEXT")
private String aufgaben;
@Column
private Double zeitfaktorZeitstrafen;
@Column(columnDefinition = "TEXT")
private String activeTaskJson;
@Column
private LocalDateTime taskStartedAt;
@Column
private UUID setupId;
@Override
public String toString() {
return "BdsmGameEntity[sessionId=" + sessionId + ", userId=" + userId
+ ", level=" + level + ", aufgaben=" + aufgabenAufAktuellemLevel + "/" + aufgabenProLevel
+ ", pStrafe=" + wahrscheinlichkeitStrafe + "%, pSperre=" + wahrscheinlichkeitSperre + "%"
+ ", zeitfaktor=" + zeitfaktorZeitstrafen + ", start=" + startZeit + "]";
}
}

View File

@@ -0,0 +1,37 @@
package de.oaa.xxx.games.bdsm.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
@Getter
@Setter
@Entity
@Table(name = "bdsm_setup_draft")
public class BdsmSetupDraftEntity {
@Id
@Column(name = "user_id")
private UUID userId;
@Column(length = 36)
private String setupId;
@Column(columnDefinition = "TEXT")
private String settingsJson;
@Column(columnDefinition = "TEXT")
private String setupJson;
@Column(columnDefinition = "TEXT")
private String gruppenJson;
@Column
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,86 @@
package de.oaa.xxx.games.bdsm.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import de.oaa.xxx.games.bdsm.GeschlechtEnum;
import de.oaa.xxx.games.bdsm.BdsmMitspieler;
import de.oaa.xxx.games.bdsm.RolleEnum;
import de.oaa.xxx.games.common.aufgaben.Werkzeug;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@Table(name = "mitspieler")
public class MitspielerEntity {
@Id
@Column
private UUID mitspielerId;
@Column
private UUID userId;
@Column
private boolean eigenesGeraet;
@Column
private String name;
@Enumerated(EnumType.STRING)
@Column
private GeschlechtEnum geschlecht;
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = Werkzeug.class, fetch = FetchType.EAGER)
@CollectionTable(name = "mitspieler_werkzeuge", joinColumns = @JoinColumn(name = "mitspielerId"))
@Column(name = "werkzeug")
private List<Werkzeug> werkzeuge = new ArrayList<>();
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = GeschlechtEnum.class, fetch = FetchType.EAGER)
@CollectionTable(name = "mitspieler_spieltMit", joinColumns = @JoinColumn(name = "mitspielerId"))
@Column(name = "geschlecht")
private List<GeschlechtEnum> spieltMit = new ArrayList<>();
@Enumerated(EnumType.STRING)
@ElementCollection(targetClass = RolleEnum.class, fetch = FetchType.EAGER)
@CollectionTable(name = "mitspieler_rollen", joinColumns = @JoinColumn(name = "mitspielerId"))
@Column(name = "rolle")
private List<RolleEnum> rollen = new ArrayList<>();
@Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT true")
private boolean sperrenVorFinaleAufloesen = true;
@ManyToOne
@JoinColumn(name = "sessionId", nullable = false)
private BdsmGameEntity session;
@OneToMany(mappedBy = "mitspieler", fetch = FetchType.EAGER)
private List<AktiveSperreEntity> aktiveSperren = new ArrayList<>();
@Override
public String toString() {
return "MitspielerEntity[mitspielerId=" + mitspielerId + ", name=" + name
+ ", geschlecht=" + geschlecht + ", rollen=" + rollen + ", werkzeuge=" + werkzeuge + "]";
}
public BdsmMitspieler toMitspieler() {
BdsmMitspieler mitspieler = new BdsmMitspieler();
mitspieler.setGeschlecht(geschlecht);
mitspieler.setId(mitspielerId);
mitspieler.setUserId(userId);
mitspieler.setEigenesGeraet(eigenesGeraet);
mitspieler.setName(name);
mitspieler.setRollen(rollen);
mitspieler.setSpieltMit(spieltMit);
mitspieler.setVerfuegbareWerkzeuge(new ArrayList<>(werkzeuge));
mitspieler.setSperrenVorFinaleAufloesen(sperrenVorFinaleAufloesen);
return mitspieler;
}
}

View File

@@ -0,0 +1,19 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.AktiveSperreEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public interface AktiveSperreRepository extends JpaRepository<AktiveSperreEntity, UUID> {
@Query("select a from AktiveSperreEntity a join a.session s where a.endzeit < :now and s.sessionId = :sessionId")
List<AktiveSperreEntity> findAbgelaufene(@Param("sessionId") UUID sessionId, @Param("now") LocalDateTime now);
@Query("select a from AktiveSperreEntity a join a.mitspieler m where m.mitspielerId = :mitspielerId")
List<AktiveSperreEntity> findAktiveLocks(@Param("mitspielerId") UUID mitspielerId);
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.BdsmDefaultsEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface BdsmDefaultsRepository extends JpaRepository<BdsmDefaultsEntity, UUID> {
Optional<BdsmDefaultsEntity> findByUserId(UUID userId);
}

View File

@@ -0,0 +1,17 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmEinladungEntity.Status;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface BdsmEinladungRepository extends JpaRepository<BdsmEinladungEntity, UUID> {
List<BdsmEinladungEntity> findBySetupId(UUID setupId);
List<BdsmEinladungEntity> findByInviteeIdAndStatus(UUID inviteeId, Status status);
List<BdsmEinladungEntity> findByInviterIdAndStatus(UUID inviterId, Status status);
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface BdsmGameRepository extends JpaRepository<BdsmGameEntity, UUID> {
Optional<BdsmGameEntity> findByUserId(UUID userId);
}

View File

@@ -0,0 +1,12 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.BdsmSetupDraftEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface BdsmSetupDraftRepository extends JpaRepository<BdsmSetupDraftEntity, UUID> {
Optional<BdsmSetupDraftEntity> findByUserId(UUID userId);
Optional<BdsmSetupDraftEntity> findBySetupId(String setupId);
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.games.bdsm.repository;
import de.oaa.xxx.games.bdsm.entity.MitspielerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface MitspielerRepository extends JpaRepository<MitspielerEntity, UUID> {
}

View File

@@ -0,0 +1,26 @@
package de.oaa.xxx.games.bdsm.sperre;
import de.oaa.xxx.games.bdsm.Callback;
import java.util.UUID;
public class SperreCallback extends Callback {
private UUID sperreId;
private UUID spielerId;
private String releaseText;
public UUID getSperreId() { return sperreId; }
public void setSperreId(UUID sperreId) { this.sperreId = sperreId; }
public UUID getSpielerId() { return spielerId; }
public void setSpielerId(UUID spielerId) { this.spielerId = spielerId; }
public String getReleaseText() { return releaseText; }
public void setReleaseText(String releaseText) { this.releaseText = releaseText; }
@Override
public String toString() {
return "SperreCallback[sessionId=" + getSessionId() + ", sperreId=" + sperreId + ", spielerId=" + spielerId + "]";
}
}

View File

@@ -0,0 +1,91 @@
package de.oaa.xxx.games.bdsm.sperre;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.oaa.xxx.games.common.aufgaben.AufgabenList;
import de.oaa.xxx.games.common.aufgaben.Sperre;
import de.oaa.xxx.games.bdsm.entity.AktiveSperreEntity;
import de.oaa.xxx.games.bdsm.entity.BdsmGameEntity;
import de.oaa.xxx.games.bdsm.entity.MitspielerEntity;
import de.oaa.xxx.games.bdsm.repository.AktiveSperreRepository;
import de.oaa.xxx.games.bdsm.repository.BdsmGameRepository;
import de.oaa.xxx.games.bdsm.repository.MitspielerRepository;
public class SperreVerarbeiten {
private final ObjectMapper objectMapper = new ObjectMapper();
public void sperreAnwenden(SperreCallback callback, BdsmGameRepository sessionRepository,
MitspielerRepository mitspielerRepository, AktiveSperreRepository sperreRepository) throws Exception {
BdsmGameEntity session = sessionRepository.findById(callback.getSessionId()).orElse(null);
MitspielerEntity mitspieler = mitspielerRepository.findById(callback.getSpielerId()).orElse(null);
if (session != null) {
AufgabenList aufgaben = objectMapper.readValue(session.getAufgaben(), AufgabenList.class);
Optional<Sperre> first = aufgaben.getSperren().stream()
.filter(sperre -> sperre.getSperreId().equals(callback.getSperreId()))
.findFirst();
if (first.isPresent()) {
Sperre sperre = first.get();
AktiveSperreEntity aktiv = new AktiveSperreEntity();
fill(callback, session, mitspieler, sperre, aktiv);
sperreRepository.save(aktiv);
sperre.getSperreFuer().forEach(mitspieler.getWerkzeuge()::remove);
mitspielerRepository.save(mitspieler);
}
}
}
public String sperreAufheben(AktiveSperreEntity aufzuheben, AktiveSperreRepository sperreRepository,
MitspielerRepository mitspielerRepository) {
MitspielerEntity mitspieler = aufzuheben.getMitspieler();
aufzuheben.getFuer().forEach(mitspieler.getWerkzeuge()::add);
mitspielerRepository.save(mitspieler);
String releaseText = aufzuheben.getReleaseText();
sperreRepository.delete(aufzuheben);
return releaseText;
}
public void sperreVerlaengern(AktiveSperreEntity verlaengern, Integer faktor, AktiveSperreRepository sperreRepository) {
Integer neueDauer = verlaengern.getMinuten() * faktor;
verlaengern.setEndzeit(verlaengern.getStartzeit().plusMinutes(neueDauer));
verlaengern.setMinuten(neueDauer);
sperreRepository.save(verlaengern);
}
private void fill(SperreCallback callback, BdsmGameEntity session, MitspielerEntity mitspieler,
Sperre sperre, AktiveSperreEntity aktiv) {
aktiv.setAktiveSperreId(UUID.randomUUID());
LocalDateTime now = LocalDateTime.now();
Integer minuten = berechneDauer(session, sperre);
aktiv.setStartzeit(now);
aktiv.setEndzeit(now.plusMinutes(minuten));
aktiv.setMinuten(minuten);
aktiv.setMitspieler(mitspieler);
aktiv.setSession(session);
aktiv.setFuer(sperre.getSperreFuer());
aktiv.setReleaseText(callback.getReleaseText());
}
private Integer berechneDauer(BdsmGameEntity session, Sperre sperre) {
Integer minuten = 30;
if (sperre.getMinutenVon() != null) {
if (sperre.getMinutenBis() != null) {
minuten = new Random().nextInt(sperre.getMinutenVon(), sperre.getMinutenBis());
} else {
minuten = sperre.getMinutenVon();
}
}
if (session.getZeitfaktorZeitstrafen() != null) {
minuten = (int) (minuten * session.getZeitfaktorZeitstrafen());
}
if (minuten == 0) {
minuten = 1;
}
return minuten;
}
}

View File

@@ -0,0 +1,22 @@
package de.oaa.xxx.games.bdsm.sperre;
import de.oaa.xxx.games.bdsm.Callback;
import java.util.UUID;
public class SperrenVerlaengernCallback extends Callback {
private UUID spielerId;
private Integer faktor;
public UUID getSpielerId() { return spielerId; }
public void setSpielerId(UUID spielerId) { this.spielerId = spielerId; }
public Integer getFaktor() { return faktor; }
public void setFaktor(Integer faktor) { this.faktor = faktor; }
@Override
public String toString() {
return "SperrenVerlaengernCallback[sessionId=" + getSessionId() + ", spielerId=" + spielerId + ", faktor=" + faktor + "]";
}
}

View File

@@ -0,0 +1,6 @@
package de.oaa.xxx.games.chastity.cardlock;
public interface Card {
public CardDTO processCard(CardLockService lock);
}

View File

@@ -0,0 +1,35 @@
package de.oaa.xxx.games.chastity.cardlock;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.LinkedHashMap;
import java.util.Map;
@Converter
public class CardCountMapConverter implements AttributeConverter<Map<String, Integer>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, Integer> map) {
if (map == null || map.isEmpty()) return null;
try {
return mapper.writeValueAsString(map);
} catch (Exception e) {
return null;
}
}
@Override
public Map<String, Integer> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new LinkedHashMap<>();
try {
return mapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return new LinkedHashMap<>();
}
}
}

View File

@@ -0,0 +1,5 @@
package de.oaa.xxx.games.chastity.cardlock;
public record CardDTO (CardEnum card, String unlockCode){
}

View File

@@ -0,0 +1,63 @@
package de.oaa.xxx.games.chastity.cardlock;
public enum CardEnum {
RED {
@Override
public Card get() {
return new RedCard();
}
},
GREEN {
@Override
public Card get() {
return new GreenCard();
}
},
YELLOW {
@Override
public Card get() {
return new YellowCard();
}
},
TASK {
@Override
public Card get() {
return new TaskCard();
}
},
FREEZE {
@Override
public Card get() {
return new FreezeCard();
}
},
RESET {
@Override
public Card get() {
return new ResetCard();
}
},
DOUBLE_UP {
@Override
public Card get() {
return new DoubleUpCard();
}
},
CUM {
@Override
public Card get() {
return new CumCard();
}
},
CUM_IN_CAGE {
@Override
public Card get() {
return new CumInCageCard();
}
};
public abstract Card get();
}

View File

@@ -0,0 +1,36 @@
package de.oaa.xxx.games.chastity.cardlock;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.ArrayList;
import java.util.List;
@Converter
public class CardEnumListConverter implements AttributeConverter<List<CardEnum>, String> {
private static final ObjectMapper mapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(List<CardEnum> list) {
if (list == null || list.isEmpty()) return null;
try {
return mapper.writeValueAsString(list.stream().map(Enum::name).toList());
} catch (Exception e) {
return null;
}
}
@Override
public List<CardEnum> convertToEntityAttribute(String json) {
if (json == null || json.isBlank()) return new ArrayList<>();
try {
List<String> names = mapper.readValue(json, new TypeReference<>() {});
return new ArrayList<>(names.stream().map(CardEnum::valueOf).toList());
} catch (Exception e) {
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,47 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.time.LocalDateTime;
import java.util.List;
import de.oaa.xxx.games.chastity.common.BaseLockEntity;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskListConverter;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@DiscriminatorValue("CARDLOCK")
public class CardLockEntity extends BaseLockEntity {
@Convert(converter = CardEnumListConverter.class)
@Column(columnDefinition = "TEXT")
private List<CardEnum> initialCards;
@Column
private Integer pickEveryMinute;
@Column
private boolean accumulatePicks;
@Column
private boolean showRemainingCards;
@Column
private LocalDateTime latestOpeningtime;
// State
@Column
private LocalDateTime nextCardIn;
@Column
private Integer openPicks;
@Convert(converter = CardEnumListConverter.class)
@Column(columnDefinition = "TEXT")
private List<CardEnum> availableCards;
@Convert(converter = TaskListConverter.class)
@Column(columnDefinition = "TEXT")
private List<Task> tasksInQueue;
}

View File

@@ -0,0 +1,14 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
public interface CardLockRepository extends JpaRepository<CardLockEntity, UUID> {
@Modifying
@Query("DELETE FROM CardLockEntity c WHERE c.lockId = :lockId")
void deleteByLockId(UUID lockId);
}

View File

@@ -0,0 +1,275 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.oaa.xxx.games.chastity.common.BaseLockEntity;
import de.oaa.xxx.games.chastity.common.BaseLockService;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
import de.oaa.xxx.games.chastity.lockcontroll.LockControlCallback;
import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity;
import de.oaa.xxx.games.chastity.unlock.TempOpeningReason;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.games.history.GameHistoryRepository;
import de.oaa.xxx.games.history.GameType;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.user.UserRepository;
public class CardLockService extends BaseLockService implements LockControlCallback {
private static final Logger LOGGER = LoggerFactory.getLogger(CardLockService.class);
private final CardLockEntity lock;
private final CardLockRepository cardLockRepository;
private String pendingTaskMode;
public CardLockService(
CardLockEntity lock,
CommunityVerificationVoteRepository communityVerificationVoteRepository,
CommunityVerificationRepository communityVerificationRepository,
KeyholderVerificationRepository keyholderVerificationRepository,
GameHistoryRepository gameHistoryRepository,
UserRepository userRepository,
KeyholderNotificationRepository keyholderNotificationRepository,
SystemMessageService systemMessageService,
UnlockCodeHistoryService unlockCodeHistoryService,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
CardLockRepository cardLockRepository,
LockControlFactory lockControlFactory) {
super(communityVerificationVoteRepository, communityVerificationRepository, keyholderVerificationRepository,
gameHistoryRepository, userRepository, keyholderNotificationRepository, systemMessageService,
unlockCodeHistoryService, keyholderTaskChoiceRepository, communityTaskVoteRepository);
this.lock = lock;
this.cardLockRepository = cardLockRepository;
// lockControl aus Entity-Typ wiederherstellen (für bereits laufende Locks)
if (lock.getControllType() != null) {
this.lockControl = lockControlFactory.create(lock.getControllType(), this, lock.getLockee());
}
}
// ── LockControl Setup ─────────────────────────────────────────────────────
/** Wird von CardLockServiceFactory gesetzt (package-private). */
void initLockControl(de.oaa.xxx.games.chastity.lockcontroll.LockControl lc) {
this.lockControl = lc;
}
// ── LockControlCallback ───────────────────────────────────────────────────
@Override
public void setUnlockCode(String code) {
lock.setUnlockCode(code);
cardLockRepository.save(lock);
}
@Override
public int getUnlockcodeLenght() {
return lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5;
}
// ── Abstract method implementations ──────────────────────────────────────
@Override
protected BaseLockEntity getLock() {
return lock;
}
@Override
protected void saveLock() {
cardLockRepository.save(lock);
}
@Override
protected GameType getGameType() {
return GameType.CARDLOCK;
}
@Override
protected void applyHygieneOvertime(Long overtime) {
LOGGER.debug("Apply {} Minutes Overtime");
if (lock.getFrozenUntil() != null) {
lock.setFrozenUntil(lock.getFrozenUntil().plusMinutes(overtime * 4));
} else {
lock.setFrozenUntil(LocalDateTime.now().plusMinutes(overtime * 4));
}
LOGGER.debug("Frozen until {}", lock.getFrozenUntil());
}
// ── Card drawing ──────────────────────────────────────────────────────────
public CardDTO getNextCard() {
LOGGER.debug("New Card requested by user {}", lock.getLockee());
CardDTO card = null;
if (lock.isKeyholderRequestedUnlock()
|| (lock.getLatestOpeningtime() != null && lock.getLatestOpeningtime().isAfter(LocalDateTime.now()))) {
card = getGreenCard();
} else if (lock.isAccumulatePicks()) {
if (lock.getNextCardIn().isBefore(LocalDateTime.now())) {
lock.setOpenPicks(lock.getOpenPicks() == null ? 1 : lock.getOpenPicks() + 1);
}
if (lock.getOpenPicks() != null && lock.getOpenPicks() > 0) {
lock.setOpenPicks(lock.getOpenPicks() - 1);
card = getRandomCard();
}
} else {
if (lock.getNextCardIn().isBefore(LocalDateTime.now())) {
lock.setNextCardIn(LocalDateTime.now().plusMinutes(lock.getPickEveryMinute()));
card = getRandomCard();
}
}
cardLockRepository.save(lock);
return card;
}
private CardDTO getRandomCard() {
var cards = lock.getAvailableCards();
if (!cards.isEmpty()) {
var card = cards.get(new Random().nextInt(cards.size()));
LOGGER.debug("Card drafted: {}", card);
lock.getAvailableCards().remove(card);
return card.get().processCard(this);
}
LOGGER.error("Keine Karten mehr im Lock - generiere Notfall Grüne Karte");
return getGreenCard();
}
private CardDTO getGreenCard() {
return new CardDTO(CardEnum.GREEN, lock.getUnlockCode());
}
// ── Card effects ──────────────────────────────────────────────────────────
public String doubleUp() {
var cards = lock.getAvailableCards();
LOGGER.debug("Double up {} cards", cards.size());
lock.getAvailableCards().addAll(cards);
LOGGER.debug("Now {} cards", lock.getAvailableCards().size());
return "";
}
public String reset() {
LOGGER.debug("Reset to initial cards");
lock.setAvailableCards(lock.getInitialCards());
return "";
}
public String green() {
LOGGER.debug("Green Card drafted");
return lock.getUnlockCode();
}
public String freeze() {
var multiplier = lock.getPickEveryMinute() * new Random().nextDouble(1.0, 4.0);
freeze(multiplier);
return "";
}
private String freeze(double multiplier) {
LocalDateTime frozenTill = LocalDateTime.now().plus((long) multiplier, ChronoUnit.MINUTES);
lock.setFrozenUntil(frozenTill);
lock.setNextCardIn(frozenTill);
LOGGER.debug("Frozen until {}", lock.getFrozenUntil());
return "";
}
/** Called by TaskCard. Dispatches based on TaskMode and stores result for controller. */
public String task() {
switch (lock.getTaskMode()) {
case RANDOM -> applyRandomTask();
case KEYHOLDER -> {
if (lock.isTestLock()) applyRandomTask();
else startKeyholderVote();
}
case COMMUNITY -> {
if (lock.isTestLock()) applyRandomTask();
else startCommunityVote();
}
}
pendingTaskMode = lock.getTaskMode().name();
return "";
}
/** Returns the TaskMode that was triggered by the last task() call, or null if no task card was drawn. */
public String getPendingTaskMode() {
return pendingTaskMode;
}
public String redCard() {
return "";
}
public String yellowCard() {
Random random = new Random();
if (random.nextBoolean()) {
for (int i = 0; i < random.nextInt(1, 3); i++) {
LOGGER.debug("Adding Red card");
lock.getAvailableCards().add(CardEnum.RED);
}
} else {
for (int i = 0; i < random.nextInt(1, 3); i++) {
LOGGER.debug("Removing Red card if possible");
lock.getAvailableCards().remove(CardEnum.RED);
}
}
return "";
}
public void putBackGreen() {
LOGGER.debug("Green Card was put Back");
lock.getAvailableCards().add(CardEnum.GREEN);
cardLockRepository.save(lock);
}
// ── Hygiene opening ───────────────────────────────────────────────────────
@Override
protected void afterHygieneClosing() {
if (lockControl != null) lockControl.lock();
}
public void startHygieneOpening() {
startTempOpening(TempOpeningReason.HYGIENE, lock.getHygineOpeningDurationMinutes());
}
// ── Cum cards ─────────────────────────────────────────────────────────────
public String cum(boolean tempUnlock) {
if (tempUnlock) {
startTempOpening(TempOpeningReason.CARD, 0);
}
return lock.getUnlockCode();
}
// ── Assigned task penalty ─────────────────────────────────────────────────
public void applyAssignedTaskPenalty(AssignedTaskEntity task) {
if (task.getPenaltyFreezeMinutes() != null && task.getPenaltyFreezeMinutes() > 0) {
LocalDateTime until = LocalDateTime.now().plusMinutes(task.getPenaltyFreezeMinutes());
if (lock.getFrozenUntil() == null || until.isAfter(lock.getFrozenUntil())) {
lock.setFrozenUntil(until);
lock.setNextCardIn(until);
}
}
if (task.getPenaltyRedCards() != null && task.getPenaltyRedCards() > 0) {
List<CardEnum> cards = new ArrayList<>(
lock.getAvailableCards() != null ? lock.getAvailableCards() : List.of());
for (int i = 0; i < task.getPenaltyRedCards(); i++) {
cards.add(CardEnum.RED);
}
lock.setAvailableCards(cards);
}
cardLockRepository.save(lock);
}
}

View File

@@ -0,0 +1,101 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.Optional;
import java.util.UUID;
import de.oaa.xxx.games.history.GameHistoryRepository;
import de.oaa.xxx.games.chastity.common.BaseLockService;
import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository;
import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository;
import de.oaa.xxx.games.chastity.timelock.TimeLockRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository;
import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository;
import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory;
import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService;
import de.oaa.xxx.social.SystemMessageService;
import de.oaa.xxx.user.UserRepository;
import org.springframework.stereotype.Service;
/**
* Factory für CardLockService-Instanzen.
*
* CardLockService hält pro Instanz den Zustand eines konkreten CardLockEntity
* und kann daher kein Singleton-Bean sein. Diese Factory zentralisiert die
* Erzeugung und verwaltet alle Abhängigkeiten als injizierte Singletons.
*/
@Service
public class CardLockServiceFactory {
private final CardLockRepository cardLockRepository;
private final CommunityVerificationRepository communityVerificationRepository;
private final CommunityVerificationVoteRepository communityVerificationVoteRepository;
private final GameHistoryRepository gameHistoryRepository;
private final UserRepository userRepository;
private final UnlockCodeHistoryService unlockCodeHistoryService;
private final KeyholderNotificationRepository keyholderNotificationRepository;
private final KeyholderVerificationRepository keyholderVerificationRepository;
private final SystemMessageService systemMessageService;
private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository;
private final CommunityTaskVoteRepository communityTaskVoteRepository;
private final LockControlFactory lockControlFactory;
private final CardlockRepository cardlockRepository;
private final TimeLockRepository timeLockRepository;
public CardLockServiceFactory(
CommunityVerificationRepository communityVerificationRepository,
CommunityVerificationVoteRepository communityVerificationVoteRepository,
CardLockRepository cardLockRepository,
CardlockRepository cardlockRepository,
GameHistoryRepository gameHistoryRepository,
UserRepository userRepository,
KeyholderNotificationRepository keyholderNotificationRepository,
KeyholderVerificationRepository keyholderVerificationRepository,
UnlockCodeHistoryService unlockCodeHistoryService,
SystemMessageService systemMessageService,
KeyholderTaskChoiceRepository keyholderTaskChoiceRepository,
CommunityTaskVoteRepository communityTaskVoteRepository,
LockControlFactory lockControlFactory,
TimeLockRepository timeLockRepository) {
this.cardLockRepository = cardLockRepository;
this.cardlockRepository = cardlockRepository;
this.communityVerificationRepository = communityVerificationRepository;
this.communityVerificationVoteRepository = communityVerificationVoteRepository;
this.gameHistoryRepository = gameHistoryRepository;
this.userRepository = userRepository;
this.keyholderNotificationRepository = keyholderNotificationRepository;
this.unlockCodeHistoryService = unlockCodeHistoryService;
this.keyholderVerificationRepository = keyholderVerificationRepository;
this.systemMessageService = systemMessageService;
this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository;
this.communityTaskVoteRepository = communityTaskVoteRepository;
this.lockControlFactory = lockControlFactory;
this.timeLockRepository = timeLockRepository;
}
public boolean hasActiveLock(UUID lockeeId) {
return BaseLockService.hasActiveLock(lockeeId, cardlockRepository, timeLockRepository);
}
public Optional<UUID> findActiveLockId(UUID lockeeId) {
var cardLock = cardlockRepository.findByLockee(lockeeId).stream()
.filter(l -> l.getUnlockTime() == null).findFirst();
if (cardLock.isPresent()) return Optional.of(cardLock.get().getLockId());
return timeLockRepository.findFirstByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId)
.map(l -> l.getLockId());
}
/**
* Erstellt eine neue CardLockService-Instanz für das gegebene Lock.
* Setzt den lockControl anhand des gespeicherten controllType.
*/
public CardLockService create(CardLockEntity lock) {
CardLockService service = new CardLockService(lock, communityVerificationVoteRepository,
communityVerificationRepository, keyholderVerificationRepository, gameHistoryRepository,
userRepository, keyholderNotificationRepository, systemMessageService, unlockCodeHistoryService,
keyholderTaskChoiceRepository, communityTaskVoteRepository, cardLockRepository, lockControlFactory);
return service;
}
}

View File

@@ -0,0 +1,13 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CardlockRepository extends JpaRepository<CardLockEntity, UUID> {
List<CardLockEntity> findByLockee(UUID lockee);
List<CardLockEntity> findByKeyholderAndUnlockTimeIsNull(UUID keyholder);
boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee);
}

View File

@@ -0,0 +1,141 @@
package de.oaa.xxx.games.chastity.cardlock;
import de.oaa.xxx.games.chastity.tasks.Task;
import de.oaa.xxx.games.chastity.tasks.TaskMode;
import de.oaa.xxx.subscription.SubscriptionLimitService;
import de.oaa.xxx.user.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateRepository;
import java.security.Principal;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/cardlock/templates")
public class CardlockTemplateController {
private final CardlockTemplateRepository templateRepository;
private final UserService userService;
private final TimeLockTemplateRepository timeLockTemplateRepository;
private final SubscriptionLimitService limitService;
public CardlockTemplateController(CardlockTemplateRepository templateRepository,
UserService userService,
TimeLockTemplateRepository timeLockTemplateRepository,
SubscriptionLimitService limitService) {
this.templateRepository = templateRepository;
this.userService = userService;
this.timeLockTemplateRepository = timeLockTemplateRepository;
this.limitService = limitService;
}
record TemplateRequest(
String name,
Map<String, Integer> cardCountsMin,
Map<String, Integer> cardCountsMax,
Integer pickEveryMinute,
boolean accumulatePicks,
boolean showRemainingCards,
Integer hygineOpeningDurationMinutes,
Integer hygineOpeningEveryMinites,
List<Task> tasks,
boolean requiresVerification,
TaskMode taskMode
) {}
private Map<String, Object> toDto(CardlockTemplateEntity t) {
Map<String, Object> dto = new LinkedHashMap<>();
dto.put("templateId", t.getTemplateId());
dto.put("name", t.getName());
dto.put("cardCountsMin", t.getCardCountsMin() != null ? t.getCardCountsMin() : Map.of());
dto.put("cardCountsMax", t.getCardCountsMax() != null ? t.getCardCountsMax() : Map.of());
dto.put("pickEveryMinute", t.getPickEveryMinute());
dto.put("accumulatePicks", t.isAccumulatePicks());
dto.put("showRemainingCards", t.isShowRemainingCards());
dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites());
dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes());
dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of());
dto.put("requiresVerification", t.isRequiresVerification());
dto.put("taskCardMode", t.getTaskCardMode());
return dto;
}
@GetMapping
public ResponseEntity<List<Map<String, Object>>> list(Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
List<Map<String, Object>> result = templateRepository.findByOwner(myId)
.stream().map(this::toDto).collect(Collectors.toList());
return ResponseEntity.ok(result);
}
@PostMapping
public ResponseEntity<Map<String, Object>> create(@RequestBody TemplateRequest req, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
if (req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
if (req.cardCountsMin() == null || req.cardCountsMin().isEmpty())
return ResponseEntity.badRequest().build();
long totalTemplates = templateRepository.countByOwner(myId)
+ timeLockTemplateRepository.countByOwner(myId);
if (totalTemplates >= limitService.maxLockTemplates(myId))
return ResponseEntity.status(409).header("X-Error", "limit-reached").build();
CardlockTemplateEntity t = new CardlockTemplateEntity();
t.setOwner(myId);
applyRequest(t, req);
templateRepository.save(t);
return ResponseEntity.ok(toDto(t));
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> update(@PathVariable UUID id,
@RequestBody TemplateRequest req,
Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = templateRepository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
CardlockTemplateEntity t = opt.get();
if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build();
if (req.pickEveryMinute() == null || req.pickEveryMinute() < 1)
return ResponseEntity.badRequest().build();
if (req.cardCountsMin() == null || req.cardCountsMin().isEmpty())
return ResponseEntity.badRequest().build();
applyRequest(t, req);
templateRepository.save(t);
return ResponseEntity.ok(toDto(t));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable UUID id, Principal principal) {
UUID myId = userService.requireUser(principal).getUserId();
var opt = templateRepository.findById(id);
if (opt.isEmpty()) return ResponseEntity.notFound().build();
if (!opt.get().getOwner().equals(myId)) return ResponseEntity.status(403).build();
templateRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
private void applyRequest(CardlockTemplateEntity t, TemplateRequest req) {
t.setName(req.name());
t.setCardCountsMin(req.cardCountsMin());
t.setCardCountsMax(req.cardCountsMax() != null ? req.cardCountsMax() : req.cardCountsMin());
t.setPickEveryMinute(req.pickEveryMinute());
t.setAccumulatePicks(req.accumulatePicks());
t.setShowRemainingCards(req.showRemainingCards());
t.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites());
t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes());
t.setTasks(req.tasks() != null ? req.tasks() : List.of());
t.setRequiresVerification(req.requiresVerification());
t.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM);
}
}

View File

@@ -0,0 +1,34 @@
package de.oaa.xxx.games.chastity.cardlock;
import java.util.Map;
import de.oaa.xxx.games.chastity.common.BaseLockTemplateEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Entity
@DiscriminatorValue("CARDLOCK")
public class CardlockTemplateEntity extends BaseLockTemplateEntity {
@Convert(converter = CardCountMapConverter.class)
@Column(columnDefinition = "TEXT")
private Map<String, Integer> cardCountsMin;
@Convert(converter = CardCountMapConverter.class)
@Column(columnDefinition = "TEXT")
private Map<String, Integer> cardCountsMax;
@Column
private Integer pickEveryMinute;
@Column
private boolean accumulatePicks;
@Column
private boolean showRemainingCards;
@Column
private boolean requiresVerification;
}

View File

@@ -0,0 +1,11 @@
package de.oaa.xxx.games.chastity.cardlock;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface CardlockTemplateRepository extends JpaRepository<CardlockTemplateEntity, UUID> {
List<CardlockTemplateEntity> findByOwner(UUID owner);
long countByOwner(UUID owner);
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class CumCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.CUM, lock.cum(true));
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class CumInCageCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.CUM_IN_CAGE, lock.cum(false));
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.games.chastity.cardlock;
public class DoubleUpCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.DOUBLE_UP, lock.doubleUp());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class FreezeCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.FREEZE, lock.freeze());
}
}

View File

@@ -0,0 +1,9 @@
package de.oaa.xxx.games.chastity.cardlock;
public class GreenCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.GREEN, lock.green());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class RedCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.RED, lock.redCard());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class ResetCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.RESET, lock.reset());
}
}

View File

@@ -0,0 +1,10 @@
package de.oaa.xxx.games.chastity.cardlock;
public class TaskCard implements Card {
@Override
public CardDTO processCard(CardLockService lock) {
return new CardDTO(CardEnum.TASK, lock.task());
}
}

Some files were not shown because too many files have changed in this diff Show More