commit 7b9eda1d62a51b2e31c90ac2b6f215b3d64b355c Author: Mario Date: Wed Apr 1 10:41:19 2026 +0200 Verschiebung nach anderem RePo - nun pro Projekt getrennt diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..fe5de2c --- /dev/null +++ b/.classpath @@ -0,0 +1,580 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b6985c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build diff --git a/.project b/.project new file mode 100644 index 0000000..3a71ae4 --- /dev/null +++ b/.project @@ -0,0 +1,28 @@ + + + xxxthegame + + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.springframework.ide.eclipse.boot.validation.springbootbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + org.eclipse.jdt.core.javanature + + diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..e479558 --- /dev/null +++ b/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,13 @@ +arguments= +auto.sync=false +build.scans.enabled=false +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER) +connection.project.dir= +eclipse.preferences.version=1 +gradle.user.home= +java.home= +jvm.arguments= +offline.mode=false +override.workspace.settings=false +show.console.view=false +show.executions.view=false diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..a986c5c --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,13 @@ +# +#Wed Apr 01 08:12:10 CEST 2026 +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=21 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=21 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.source=21 diff --git a/.settings/org.springframework.ide.eclipse.prefs b/.settings/org.springframework.ide.eclipse.prefs new file mode 100644 index 0000000..a12794d --- /dev/null +++ b/.settings/org.springframework.ide.eclipse.prefs @@ -0,0 +1,2 @@ +boot.validation.initialized=true +eclipse.preferences.version=1 diff --git a/bin/main/Ideen.txt b/bin/main/Ideen.txt new file mode 100644 index 0000000..851ad46 --- /dev/null +++ b/bin/main/Ideen.txt @@ -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. + + \ No newline at end of file diff --git a/bin/main/application.properties b/bin/main/application.properties new file mode 100644 index 0000000..5432f20 --- /dev/null +++ b/bin/main/application.properties @@ -0,0 +1,64 @@ +# Datasource +spring.datasource.url=jdbc:mysql://localhost:3306/xxx_sphere?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC +spring.datasource.username=${DB_USER:xxx} +spring.datasource.password=${DB_PASSWORD:xxx} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA / Hibernate +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR + +# Mail +#spring.mail.host=${MAIL_HOST:localhost} +#spring.mail.port=${MAIL_PORT:25} +#spring.mail.username=${MAIL_USER:} +#spring.mail.password=${MAIL_PASSWORD:} +#spring.mail.properties.mail.smtp.auth=false +#spring.mail.properties.mail.smtp.starttls.enable=false + +# Mailpit +spring.mail.host=smtp-relay.brevo.com +spring.mail.port=587 +spring.mail.username=a6b17a001@smtp-brevo.com +spring.mail.password=xsmtpsib-77b691d562154574133d12b09d44a06e166d30091aac6642480771a0ae463a79-8yH3jHOd4nMMAwuS +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# JWT Keystore +jwt.keystore.path=classpath:xxx.jks +jwt.keystore.password=${JWT_KEYSTORE_PASSWORD:XUR!Rv&f$j3UsqD&} +jwt.keystore.alias=xxx + +# App +app.base-url=http://localhost:8080 + +# Theme – alle Farben hier ändern, Email-Style passt sich automatisch an +app.theme.color-bg=#1a1a2e +app.theme.color-card=#16213e +app.theme.color-primary=#e94560 +app.theme.color-secondary=#0f3460 +app.theme.color-text=#eeeeee +app.theme.color-muted=#888888 +app.theme.color-success=#2ecc71 + +# Logging +logging.level.de.oaa.xxx=DEBUG +# Spring 6.2.3 Bug: NPE in DisconnectedClientHelper bei AsyncRequestTimeoutException (SSE-Reconnect) – harmlos +logging.level.org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver=ERROR +logging.level.org.apache.catalina.core.AsyncContextImpl=ERROR + +# Server +server.port=8080 +server.servlet.context-path=/ +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=5s + +# Jackson – Datumsformat als ISO-8601 String statt numerischem Array +spring.jackson.serialization.write-dates-as-timestamps=false + +# Multipart upload +spring.servlet.multipart.max-file-size=20MB +spring.servlet.multipart.max-request-size=20MB diff --git a/bin/main/de/oaa/xxx/XxxThegameApplication.class b/bin/main/de/oaa/xxx/XxxThegameApplication.class new file mode 100644 index 0000000..4d56647 Binary files /dev/null and b/bin/main/de/oaa/xxx/XxxThegameApplication.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$AdminDto.class b/bin/main/de/oaa/xxx/admin/AdminController$AdminDto.class new file mode 100644 index 0000000..1b1ef8d Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$AdminDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$CreateAdminRequest.class b/bin/main/de/oaa/xxx/admin/AdminController$CreateAdminRequest.class new file mode 100644 index 0000000..a5eae3e Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$CreateAdminRequest.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$FeedbackAntwortRequest.class b/bin/main/de/oaa/xxx/admin/AdminController$FeedbackAntwortRequest.class new file mode 100644 index 0000000..65c2d5f Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$FeedbackAntwortRequest.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$FeedbackDto.class b/bin/main/de/oaa/xxx/admin/AdminController$FeedbackDto.class new file mode 100644 index 0000000..d76a22d Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$FeedbackDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$GiftSubscriptionRequest.class b/bin/main/de/oaa/xxx/admin/AdminController$GiftSubscriptionRequest.class new file mode 100644 index 0000000..fbfa245 Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$GiftSubscriptionRequest.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$MeldungDto.class b/bin/main/de/oaa/xxx/admin/AdminController$MeldungDto.class new file mode 100644 index 0000000..70353f8 Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$MeldungDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$StatusRequest.class b/bin/main/de/oaa/xxx/admin/AdminController$StatusRequest.class new file mode 100644 index 0000000..1a7643b Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$StatusRequest.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$SubscriptionStatusDto.class b/bin/main/de/oaa/xxx/admin/AdminController$SubscriptionStatusDto.class new file mode 100644 index 0000000..4267760 Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$SubscriptionStatusDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigDto.class b/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigDto.class new file mode 100644 index 0000000..909df4b Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigRequest.class b/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigRequest.class new file mode 100644 index 0000000..ae8360e Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$TtlockConfigRequest.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController$UserSearchDto.class b/bin/main/de/oaa/xxx/admin/AdminController$UserSearchDto.class new file mode 100644 index 0000000..da071ca Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController$UserSearchDto.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminController.class b/bin/main/de/oaa/xxx/admin/AdminController.class new file mode 100644 index 0000000..6577b93 Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminController.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminEntity.class b/bin/main/de/oaa/xxx/admin/AdminEntity.class new file mode 100644 index 0000000..ad90fce Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminEntity.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminRepository.class b/bin/main/de/oaa/xxx/admin/AdminRepository.class new file mode 100644 index 0000000..910ae7a Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminRepository.class differ diff --git a/bin/main/de/oaa/xxx/admin/AdminRolle.class b/bin/main/de/oaa/xxx/admin/AdminRolle.class new file mode 100644 index 0000000..4f28ec9 Binary files /dev/null and b/bin/main/de/oaa/xxx/admin/AdminRolle.class differ diff --git a/bin/main/de/oaa/xxx/config/JwtFilter.class b/bin/main/de/oaa/xxx/config/JwtFilter.class new file mode 100644 index 0000000..a872735 Binary files /dev/null and b/bin/main/de/oaa/xxx/config/JwtFilter.class differ diff --git a/bin/main/de/oaa/xxx/config/JwtService.class b/bin/main/de/oaa/xxx/config/JwtService.class new file mode 100644 index 0000000..eb742ca Binary files /dev/null and b/bin/main/de/oaa/xxx/config/JwtService.class differ diff --git a/bin/main/de/oaa/xxx/config/SchemaMigration.class b/bin/main/de/oaa/xxx/config/SchemaMigration.class new file mode 100644 index 0000000..522247b Binary files /dev/null and b/bin/main/de/oaa/xxx/config/SchemaMigration.class differ diff --git a/bin/main/de/oaa/xxx/config/SecurityConfig.class b/bin/main/de/oaa/xxx/config/SecurityConfig.class new file mode 100644 index 0000000..06c219f Binary files /dev/null and b/bin/main/de/oaa/xxx/config/SecurityConfig.class differ diff --git a/bin/main/de/oaa/xxx/config/StringListConverter$1.class b/bin/main/de/oaa/xxx/config/StringListConverter$1.class new file mode 100644 index 0000000..0560e72 Binary files /dev/null and b/bin/main/de/oaa/xxx/config/StringListConverter$1.class differ diff --git a/bin/main/de/oaa/xxx/config/StringListConverter.class b/bin/main/de/oaa/xxx/config/StringListConverter.class new file mode 100644 index 0000000..c909d3d Binary files /dev/null and b/bin/main/de/oaa/xxx/config/StringListConverter.class differ diff --git a/bin/main/de/oaa/xxx/config/ThemeController.class b/bin/main/de/oaa/xxx/config/ThemeController.class new file mode 100644 index 0000000..dddbe38 Binary files /dev/null and b/bin/main/de/oaa/xxx/config/ThemeController.class differ diff --git a/bin/main/de/oaa/xxx/emailchange/EmailChangeController$EmailChangeRequest.class b/bin/main/de/oaa/xxx/emailchange/EmailChangeController$EmailChangeRequest.class new file mode 100644 index 0000000..bbbdc94 Binary files /dev/null and b/bin/main/de/oaa/xxx/emailchange/EmailChangeController$EmailChangeRequest.class differ diff --git a/bin/main/de/oaa/xxx/emailchange/EmailChangeController.class b/bin/main/de/oaa/xxx/emailchange/EmailChangeController.class new file mode 100644 index 0000000..25978e2 Binary files /dev/null and b/bin/main/de/oaa/xxx/emailchange/EmailChangeController.class differ diff --git a/bin/main/de/oaa/xxx/emailchange/EmailChangeEntity.class b/bin/main/de/oaa/xxx/emailchange/EmailChangeEntity.class new file mode 100644 index 0000000..0c1fe3c Binary files /dev/null and b/bin/main/de/oaa/xxx/emailchange/EmailChangeEntity.class differ diff --git a/bin/main/de/oaa/xxx/emailchange/EmailChangeRepository.class b/bin/main/de/oaa/xxx/emailchange/EmailChangeRepository.class new file mode 100644 index 0000000..ba3eabf Binary files /dev/null and b/bin/main/de/oaa/xxx/emailchange/EmailChangeRepository.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class new file mode 100644 index 0000000..fa4406c Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/FeedController$FeedPage.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class new file mode 100644 index 0000000..1e18477 Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/FeedController$VoteRequest.class differ diff --git a/bin/main/de/oaa/xxx/feed/FeedController.class b/bin/main/de/oaa/xxx/feed/FeedController.class new file mode 100644 index 0000000..d98da5d Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/FeedController.class differ diff --git a/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class b/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class new file mode 100644 index 0000000..1fc50ea Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/dto/FeedItemDto.class differ diff --git a/bin/main/de/oaa/xxx/feed/dto/FeedPostRequest.class b/bin/main/de/oaa/xxx/feed/dto/FeedPostRequest.class new file mode 100644 index 0000000..a26eedf Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/dto/FeedPostRequest.class differ diff --git a/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class b/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class new file mode 100644 index 0000000..0fced62 Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/entity/FeedPostEntity.class differ diff --git a/bin/main/de/oaa/xxx/feed/entity/FeedPostLikeEntity.class b/bin/main/de/oaa/xxx/feed/entity/FeedPostLikeEntity.class new file mode 100644 index 0000000..0e50a44 Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/entity/FeedPostLikeEntity.class differ diff --git a/bin/main/de/oaa/xxx/feed/entity/FeedPostOptionEntity.class b/bin/main/de/oaa/xxx/feed/entity/FeedPostOptionEntity.class new file mode 100644 index 0000000..e1c95ef Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/entity/FeedPostOptionEntity.class differ diff --git a/bin/main/de/oaa/xxx/feed/entity/FeedPostVoteEntity.class b/bin/main/de/oaa/xxx/feed/entity/FeedPostVoteEntity.class new file mode 100644 index 0000000..d34b795 Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/entity/FeedPostVoteEntity.class differ diff --git a/bin/main/de/oaa/xxx/feed/repository/FeedPostLikeRepository.class b/bin/main/de/oaa/xxx/feed/repository/FeedPostLikeRepository.class new file mode 100644 index 0000000..884509d Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/repository/FeedPostLikeRepository.class differ diff --git a/bin/main/de/oaa/xxx/feed/repository/FeedPostOptionRepository.class b/bin/main/de/oaa/xxx/feed/repository/FeedPostOptionRepository.class new file mode 100644 index 0000000..abbd7e9 Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/repository/FeedPostOptionRepository.class differ diff --git a/bin/main/de/oaa/xxx/feed/repository/FeedPostRepository.class b/bin/main/de/oaa/xxx/feed/repository/FeedPostRepository.class new file mode 100644 index 0000000..1af5cfb Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/repository/FeedPostRepository.class differ diff --git a/bin/main/de/oaa/xxx/feed/repository/FeedPostVoteRepository.class b/bin/main/de/oaa/xxx/feed/repository/FeedPostVoteRepository.class new file mode 100644 index 0000000..ba6ac0c Binary files /dev/null and b/bin/main/de/oaa/xxx/feed/repository/FeedPostVoteRepository.class differ diff --git a/bin/main/de/oaa/xxx/feedback/FeedbackController$FeedbackRequest.class b/bin/main/de/oaa/xxx/feedback/FeedbackController$FeedbackRequest.class new file mode 100644 index 0000000..e99c5e6 Binary files /dev/null and b/bin/main/de/oaa/xxx/feedback/FeedbackController$FeedbackRequest.class differ diff --git a/bin/main/de/oaa/xxx/feedback/FeedbackController.class b/bin/main/de/oaa/xxx/feedback/FeedbackController.class new file mode 100644 index 0000000..a0d2bc6 Binary files /dev/null and b/bin/main/de/oaa/xxx/feedback/FeedbackController.class differ diff --git a/bin/main/de/oaa/xxx/feedback/FeedbackEntity.class b/bin/main/de/oaa/xxx/feedback/FeedbackEntity.class new file mode 100644 index 0000000..6369017 Binary files /dev/null and b/bin/main/de/oaa/xxx/feedback/FeedbackEntity.class differ diff --git a/bin/main/de/oaa/xxx/feedback/FeedbackRepository.class b/bin/main/de/oaa/xxx/feedback/FeedbackRepository.class new file mode 100644 index 0000000..8d16ab6 Binary files /dev/null and b/bin/main/de/oaa/xxx/feedback/FeedbackRepository.class differ diff --git a/bin/main/de/oaa/xxx/feedback/FeedbackStatus.class b/bin/main/de/oaa/xxx/feedback/FeedbackStatus.class new file mode 100644 index 0000000..1307490 Binary files /dev/null and b/bin/main/de/oaa/xxx/feedback/FeedbackStatus.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/AktiveSperre.class b/bin/main/de/oaa/xxx/games/bdsm/AktiveSperre.class new file mode 100644 index 0000000..c316b6e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/AktiveSperre.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/AufgabeAnzeige.class b/bin/main/de/oaa/xxx/games/bdsm/AufgabeAnzeige.class new file mode 100644 index 0000000..b975e69 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/AufgabeAnzeige.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/AufgabeArt.class b/bin/main/de/oaa/xxx/games/bdsm/AufgabeArt.class new file mode 100644 index 0000000..45cecc9 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/AufgabeArt.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/BdsmGame.class b/bin/main/de/oaa/xxx/games/bdsm/BdsmGame.class new file mode 100644 index 0000000..c0da2af Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/BdsmGame.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.class b/bin/main/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.class new file mode 100644 index 0000000..86e7cf5 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/BdsmGameService.class b/bin/main/de/oaa/xxx/games/bdsm/BdsmGameService.class new file mode 100644 index 0000000..aba1887 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/BdsmGameService.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/BdsmMitspieler.class b/bin/main/de/oaa/xxx/games/bdsm/BdsmMitspieler.class new file mode 100644 index 0000000..cb4146c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/BdsmMitspieler.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/Callback.class b/bin/main/de/oaa/xxx/games/bdsm/Callback.class new file mode 100644 index 0000000..55f5157 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/Callback.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/GeschlechtEnum.class b/bin/main/de/oaa/xxx/games/bdsm/GeschlechtEnum.class new file mode 100644 index 0000000..b3ce90c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/GeschlechtEnum.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/RolleEnum.class b/bin/main/de/oaa/xxx/games/bdsm/RolleEnum.class new file mode 100644 index 0000000..aae3b2c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/RolleEnum.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/AboController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/AboController.class new file mode 100644 index 0000000..d5c37c5 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/AboController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabeController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabeController.class new file mode 100644 index 0000000..1e6a355 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabeController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.class new file mode 100644 index 0000000..5290ec2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$AntwortRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$AntwortRequest.class new file mode 100644 index 0000000..ad4d534 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$AntwortRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$EinladungRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$EinladungRequest.class new file mode 100644 index 0000000..39989e3 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$EinladungRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$SpielerDatenRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$SpielerDatenRequest.class new file mode 100644 index 0000000..68063e1 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController$SpielerDatenRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.class new file mode 100644 index 0000000..61c63da Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenRequest.class new file mode 100644 index 0000000..42d3aff Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenResponse.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenResponse.class new file mode 100644 index 0000000..faefe3f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$AbschliessenResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskRequest.class new file mode 100644 index 0000000..36144b3 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskResponse.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskResponse.class new file mode 100644 index 0000000..e560bb2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ActiveTaskResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$SperreFreigabe.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$SperreFreigabe.class new file mode 100644 index 0000000..6a1e045 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$SperreFreigabe.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ZuChastityRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ZuChastityRequest.class new file mode 100644 index 0000000..5dd16d4 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController$ZuChastityRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController.class new file mode 100644 index 0000000..ac81811 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmGameController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController$DraftRequest.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController$DraftRequest.class new file mode 100644 index 0000000..13df58a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController$DraftRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.class new file mode 100644 index 0000000..eaf036d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/FavoritController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/FavoritController.class new file mode 100644 index 0000000..150b3fe Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/FavoritController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/FillerController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/FillerController.class new file mode 100644 index 0000000..bcd3dc1 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/FillerController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/FinisherController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/FinisherController.class new file mode 100644 index 0000000..03dc51f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/FinisherController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/SperreController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/SperreController.class new file mode 100644 index 0000000..598550b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/SperreController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/StrafeController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/StrafeController.class new file mode 100644 index 0000000..a88f034 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/StrafeController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/controller/ToyController.class b/bin/main/de/oaa/xxx/games/bdsm/controller/ToyController.class new file mode 100644 index 0000000..71ccab7 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/controller/ToyController.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.class new file mode 100644 index 0000000..8b8697f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.class new file mode 100644 index 0000000..5eedc34 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity$Status.class b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity$Status.class new file mode 100644 index 0000000..ba696d1 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity$Status.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.class new file mode 100644 index 0000000..e291e9f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.class new file mode 100644 index 0000000..b814b58 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.class new file mode 100644 index 0000000..c54c614 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.class b/bin/main/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.class new file mode 100644 index 0000000..77514ba Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.class new file mode 100644 index 0000000..794965d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.class new file mode 100644 index 0000000..24b65b9 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.class new file mode 100644 index 0000000..a308a0d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.class new file mode 100644 index 0000000..e0dcf05 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.class new file mode 100644 index 0000000..e9bb5ad Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.class b/bin/main/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.class new file mode 100644 index 0000000..1f5c04a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreCallback.class b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreCallback.class new file mode 100644 index 0000000..43d84d0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreCallback.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.class b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.class new file mode 100644 index 0000000..e480d51 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.class differ diff --git a/bin/main/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.class b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.class new file mode 100644 index 0000000..9f4f1d9 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/Card.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/Card.class new file mode 100644 index 0000000..0219569 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/Card.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter$1.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter$1.class new file mode 100644 index 0000000..5818ee8 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.class new file mode 100644 index 0000000..f3beb20 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardDTO.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardDTO.class new file mode 100644 index 0000000..e1d3392 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$1.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$1.class new file mode 100644 index 0000000..47a2468 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$2.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$2.class new file mode 100644 index 0000000..85b96fe Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$2.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$3.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$3.class new file mode 100644 index 0000000..9b4d91d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$3.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$4.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$4.class new file mode 100644 index 0000000..1612b35 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$4.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$5.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$5.class new file mode 100644 index 0000000..6256af8 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$5.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$6.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$6.class new file mode 100644 index 0000000..fc19d52 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$6.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$7.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$7.class new file mode 100644 index 0000000..28ab8d4 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$7.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$8.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$8.class new file mode 100644 index 0000000..9702c56 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$8.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$9.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$9.class new file mode 100644 index 0000000..47a07fe Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum$9.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum.class new file mode 100644 index 0000000..414188e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnum.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter$1.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter$1.class new file mode 100644 index 0000000..d700d6e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.class new file mode 100644 index 0000000..806c630 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$AssignTaskRequest.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$AssignTaskRequest.class new file mode 100644 index 0000000..1d38ada Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$AssignTaskRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$CreateCardLockRequest.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$CreateCardLockRequest.class new file mode 100644 index 0000000..b3a8052 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$CreateCardLockRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$FreezeRequest.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$FreezeRequest.class new file mode 100644 index 0000000..3376133 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$FreezeRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$ModifyCardsRequest.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$ModifyCardsRequest.class new file mode 100644 index 0000000..118f39c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController$ModifyCardsRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController.class new file mode 100644 index 0000000..9202d14 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.class new file mode 100644 index 0000000..83d5349 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.class new file mode 100644 index 0000000..0562ca2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockService.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockService.class new file mode 100644 index 0000000..cb08044 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.class new file mode 100644 index 0000000..9b38845 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.class new file mode 100644 index 0000000..1068084 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController$TemplateRequest.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController$TemplateRequest.class new file mode 100644 index 0000000..6f021a2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController$TemplateRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.class new file mode 100644 index 0000000..39a354d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.class new file mode 100644 index 0000000..f8c1ded Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.class new file mode 100644 index 0000000..f5c3ae0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CumCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CumCard.class new file mode 100644 index 0000000..65c9b4e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CumCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.class new file mode 100644 index 0000000..c44a186 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.class new file mode 100644 index 0000000..b73e6fa Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/FreezeCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/FreezeCard.class new file mode 100644 index 0000000..f712901 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/FreezeCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/GreenCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/GreenCard.class new file mode 100644 index 0000000..f4a9b1c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/GreenCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/RedCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/RedCard.class new file mode 100644 index 0000000..01ccbc9 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/RedCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/ResetCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/ResetCard.class new file mode 100644 index 0000000..10af186 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/ResetCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/TaskCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/TaskCard.class new file mode 100644 index 0000000..4a84fd8 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/TaskCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/cardlock/YellowCard.class b/bin/main/de/oaa/xxx/games/chastity/cardlock/YellowCard.class new file mode 100644 index 0000000..bc90ea6 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/cardlock/YellowCard.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockController.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockController.class new file mode 100644 index 0000000..38d0a53 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockEntity.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockEntity.class new file mode 100644 index 0000000..3bc1bfc Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockRepository.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockRepository.class new file mode 100644 index 0000000..f0c8f21 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockService.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockService.class new file mode 100644 index 0000000..2d8836d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.class new file mode 100644 index 0000000..364bbd2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.class new file mode 100644 index 0000000..a4f7277 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.class b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.class new file mode 100644 index 0000000..330fad6 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/CodeCreator.class b/bin/main/de/oaa/xxx/games/chastity/common/CodeCreator.class new file mode 100644 index 0000000..7c28551 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/CodeCreator.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/LockType.class b/bin/main/de/oaa/xxx/games/chastity/common/LockType.class new file mode 100644 index 0000000..b9d08bd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/LockType.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/PenaltyType.class b/bin/main/de/oaa/xxx/games/chastity/common/PenaltyType.class new file mode 100644 index 0000000..4b269d2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/PenaltyType.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/TemplateExploreController.class b/bin/main/de/oaa/xxx/games/chastity/common/TemplateExploreController.class new file mode 100644 index 0000000..afaf822 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/TemplateExploreController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.class b/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.class new file mode 100644 index 0000000..f380a72 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.class b/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.class new file mode 100644 index 0000000..5a4aa73 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/Verification.class b/bin/main/de/oaa/xxx/games/chastity/common/Verification.class new file mode 100644 index 0000000..e4c2612 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/Verification.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.class b/bin/main/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.class new file mode 100644 index 0000000..0211211 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.class b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.class new file mode 100644 index 0000000..11f28e5 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.class new file mode 100644 index 0000000..707f4dd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.class new file mode 100644 index 0000000..a78f727 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.class new file mode 100644 index 0000000..4425260 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryController.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryController.class new file mode 100644 index 0000000..ba89395 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.class new file mode 100644 index 0000000..9aa1930 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.class new file mode 100644 index 0000000..26e886a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.class new file mode 100644 index 0000000..794417b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.class new file mode 100644 index 0000000..3f17e7d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.class new file mode 100644 index 0000000..d78cbab Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.class new file mode 100644 index 0000000..a6d5773 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.class new file mode 100644 index 0000000..302a455 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.class new file mode 100644 index 0000000..b1d6e7d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.class new file mode 100644 index 0000000..7a32aaf Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.class new file mode 100644 index 0000000..e03488f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.class new file mode 100644 index 0000000..8e952f2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.class new file mode 100644 index 0000000..85c5863 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.class new file mode 100644 index 0000000..dbe5b1b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.class new file mode 100644 index 0000000..f813ea9 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationController.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationController.class new file mode 100644 index 0000000..a4d2d37 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.class new file mode 100644 index 0000000..f980ad2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.class new file mode 100644 index 0000000..c05bc90 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.class new file mode 100644 index 0000000..b3e2c0d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.class new file mode 100644 index 0000000..cc809dd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.class new file mode 100644 index 0000000..1b9f3ad Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.class b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.class new file mode 100644 index 0000000..3f15c4d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.class new file mode 100644 index 0000000..8bcba70 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.class new file mode 100644 index 0000000..cd3b154 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.class new file mode 100644 index 0000000..e51264b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.class new file mode 100644 index 0000000..f8ed529 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$CreateOfferRequest.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$CreateOfferRequest.class new file mode 100644 index 0000000..112c48f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$CreateOfferRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$JoinOfferRequest.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$JoinOfferRequest.class new file mode 100644 index 0000000..f2e1856 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController$JoinOfferRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.class new file mode 100644 index 0000000..7d4f386 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.class new file mode 100644 index 0000000..af781ef Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.class new file mode 100644 index 0000000..5dbe824 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController$PenaltyRequest.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController$PenaltyRequest.class new file mode 100644 index 0000000..c9e900a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController$PenaltyRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.class new file mode 100644 index 0000000..0adfdf0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.class new file mode 100644 index 0000000..70cb6e6 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.class new file mode 100644 index 0000000..1db5697 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.class new file mode 100644 index 0000000..ef51110 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.class new file mode 100644 index 0000000..22bc13e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.class b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.class new file mode 100644 index 0000000..6d8a2f0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControl.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControl.class new file mode 100644 index 0000000..9169f80 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControl.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.class new file mode 100644 index 0000000..2281d33 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.class new file mode 100644 index 0000000..dc77f70 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.class new file mode 100644 index 0000000..f848b18 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.class new file mode 100644 index 0000000..f2e1b5b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.class new file mode 100644 index 0000000..36f876d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.class new file mode 100644 index 0000000..23e715a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.class b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.class new file mode 100644 index 0000000..30576d2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController$AcceptRequest.class b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController$AcceptRequest.class new file mode 100644 index 0000000..877a1fd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController$AcceptRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.class b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.class new file mode 100644 index 0000000..a8c0335 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.class b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.class new file mode 100644 index 0000000..117fef3 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.class b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.class new file mode 100644 index 0000000..558f0ac Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$1.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$1.class new file mode 100644 index 0000000..13c264b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$2.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$2.class new file mode 100644 index 0000000..ba940de Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$2.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$3.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$3.class new file mode 100644 index 0000000..e943b96 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$3.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$4.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$4.class new file mode 100644 index 0000000..8c5092e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$4.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$5.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$5.class new file mode 100644 index 0000000..2c0e18e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$5.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$6.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$6.class new file mode 100644 index 0000000..48ab838 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$6.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$7.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$7.class new file mode 100644 index 0000000..03de276 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType$7.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType.class new file mode 100644 index 0000000..7f0d40d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/EntryType.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter$1.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter$1.class new file mode 100644 index 0000000..7e0b5ed Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.class new file mode 100644 index 0000000..e7dc772 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.class new file mode 100644 index 0000000..5f87d91 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.class new file mode 100644 index 0000000..4fe0575 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.class b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.class new file mode 100644 index 0000000..0045f36 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.class b/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.class new file mode 100644 index 0000000..48ba31f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.class b/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.class new file mode 100644 index 0000000..bf12fd5 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/Task.class b/bin/main/de/oaa/xxx/games/chastity/tasks/Task.class new file mode 100644 index 0000000..7548476 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/Task.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter$1.class b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter$1.class new file mode 100644 index 0000000..4635412 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter.class b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter.class new file mode 100644 index 0000000..9e3a38e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskListConverter.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/tasks/TaskMode.class b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskMode.class new file mode 100644 index 0000000..c51a4bd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/tasks/TaskMode.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.class new file mode 100644 index 0000000..d16eb07 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$CreateTimeLockRequest.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$CreateTimeLockRequest.class new file mode 100644 index 0000000..9d49d31 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$CreateTimeLockRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$FreezeRequest.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$FreezeRequest.class new file mode 100644 index 0000000..528920f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController$FreezeRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController.class new file mode 100644 index 0000000..bec4328 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.class new file mode 100644 index 0000000..11da049 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.class new file mode 100644 index 0000000..befaf37 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockService.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockService.class new file mode 100644 index 0000000..abdb790 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.class new file mode 100644 index 0000000..d2bc1cd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.class new file mode 100644 index 0000000..a25db8a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController$TemplateRequest.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController$TemplateRequest.class new file mode 100644 index 0000000..fb7b800 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController$TemplateRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.class new file mode 100644 index 0000000..ee92635 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.class new file mode 100644 index 0000000..7e505ff Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.class new file mode 100644 index 0000000..bd79ee0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.class b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.class new file mode 100644 index 0000000..4d4cd0c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTAuthService.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTAuthService.class new file mode 100644 index 0000000..536ab61 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTAuthService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$1.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$1.class new file mode 100644 index 0000000..416ea82 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$1.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$LockRecord.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$LockRecord.class new file mode 100644 index 0000000..9343627 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$LockRecord.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$TTLockCallbackWrapper.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$TTLockCallbackWrapper.class new file mode 100644 index 0000000..5760d81 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback$TTLockCallbackWrapper.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.class new file mode 100644 index 0000000..6a7647f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.class new file mode 100644 index 0000000..f0a2985 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.class new file mode 100644 index 0000000..1c167ec Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockAddPasscodeResponse.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockAddPasscodeResponse.class new file mode 100644 index 0000000..8747a0b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockAddPasscodeResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockDetailResponse.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockDetailResponse.class new file mode 100644 index 0000000..05c2d55 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService$TTLockDetailResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService.class new file mode 100644 index 0000000..e42eced Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockService.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockTest.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockTest.class new file mode 100644 index 0000000..a42ae14 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockTest.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.class new file mode 100644 index 0000000..3b4a6c7 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.class b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.class new file mode 100644 index 0000000..0a55079 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/ttlock/unlocktypes b/bin/main/de/oaa/xxx/games/chastity/ttlock/unlocktypes new file mode 100644 index 0000000..0d5b861 --- /dev/null +++ b/bin/main/de/oaa/xxx/games/chastity/ttlock/unlocktypes @@ -0,0 +1,121 @@ +1-unlock by app + +4-unlock by passcode + +5-Rise the lock (for parking lock) + +6-Lower the lock (for parking lock) + +7-unlock by IC card + +8-unlock by fingerprint + +9-unlock by wrist strap + +10-unlock by Mechanical key + +11-lock by app + +12-unlock by gateway + +29-apply some force on the Lock + +30-Door sensor closed + +31-Door sensor open + +32-open from inside + +33-lock by fingerprint + +34-lock by passcode + +35-lock by IC card + +36-lock by Mechanical key + +37-Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door + +42-received new local mail + +43-received new other cities' mail + +44-Tamper alert + +45-Auto Lock + +46-unlock by unlock key + +47-lock by lock key + +48-System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times) + +49-unlock by hotel card + +50-Unlocked due to the high temperature + +51-Try to unlock with a deleted card + +52-Dead lock with APP + +53-Dead lock with passcode + +54-The car left (for parking lock) + +55-Use remote control lock or unlock lock + +57-Unlock with QR code success + +58-Unlock with QR code failed, it's expired + +59-Double locked + +60-Cancel double lock + +61-Lock with QR code success + +62-Lock with QR code failed, the lock is double locked + +63-Auto unlock at passage mode + +64-Door unclosed alarm + +65-Failed to unlock + +66-Failed to lock + +67-Face unlock success + +68-Face unlock failed - door locked from inside + +69-Lock with face + +71-Face unlock failed - expired or ineffective + +75-Unlocked by App granting + +76-Unlocked by remote granting + +77-Dual authentication Bluetooth unlock verification success, waiting for second user + +78-Dual authentication password unlock verification success, waiting for second user + +79-Dual authentication fingerprint unlock verification success, waiting for second user + +80-Dual authentication IC card unlock verification success, waiting for second user + +81-Dual authentication face card unlock verification success, waiting for second user + +82-Dual authentication wireless key unlock verification success, waiting for second user + +83-Dual authentication palm vein unlock verification success, waiting for second user + +84-Palm vein unlock success + +85-Palm vein unlock success + +86-Lock with palm vein + +88-Palm vein unlock failed - expired or ineffective + +92-Administrator password to unlock \ No newline at end of file diff --git a/bin/main/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.class b/bin/main/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.class new file mode 100644 index 0000000..d3b2e90 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.class b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.class new file mode 100644 index 0000000..20fc129 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.class b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.class new file mode 100644 index 0000000..cf01a0f Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.class b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.class new file mode 100644 index 0000000..d7bb9c0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Aufgabe.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Aufgabe.class new file mode 100644 index 0000000..1a7787e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Aufgabe.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.class new file mode 100644 index 0000000..e6efc36 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.class new file mode 100644 index 0000000..05b5eea Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.class new file mode 100644 index 0000000..162292b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.class new file mode 100644 index 0000000..7a1b618 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.class new file mode 100644 index 0000000..755e836 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenList.class b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenList.class new file mode 100644 index 0000000..909c9c7 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/AufgabenList.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.class b/bin/main/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.class new file mode 100644 index 0000000..a4ab86c Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/DefaultFiller.class b/bin/main/de/oaa/xxx/games/common/aufgaben/DefaultFiller.class new file mode 100644 index 0000000..de2650b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/DefaultFiller.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Favorit.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Favorit.class new file mode 100644 index 0000000..a52caea Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Favorit.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/FavoritList.class b/bin/main/de/oaa/xxx/games/common/aufgaben/FavoritList.class new file mode 100644 index 0000000..d832787 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/FavoritList.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Finisher.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Finisher.class new file mode 100644 index 0000000..673f49d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Finisher.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/ImageScaler.class b/bin/main/de/oaa/xxx/games/common/aufgaben/ImageScaler.class new file mode 100644 index 0000000..2343042 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/ImageScaler.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Sperre.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Sperre.class new file mode 100644 index 0000000..51b436a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Sperre.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Strafe.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Strafe.class new file mode 100644 index 0000000..46f0fae Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Strafe.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Toy.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Toy.class new file mode 100644 index 0000000..9c5b6fd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Toy.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/ToyList.class b/bin/main/de/oaa/xxx/games/common/aufgaben/ToyList.class new file mode 100644 index 0000000..da35b71 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/ToyList.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/ToyPage.class b/bin/main/de/oaa/xxx/games/common/aufgaben/ToyPage.class new file mode 100644 index 0000000..d94c6eb Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/ToyPage.class differ diff --git a/bin/main/de/oaa/xxx/games/common/aufgaben/Werkzeug.class b/bin/main/de/oaa/xxx/games/common/aufgaben/Werkzeug.class new file mode 100644 index 0000000..5edcec4 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/aufgaben/Werkzeug.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/AufgabeEntity.class b/bin/main/de/oaa/xxx/games/common/entity/AufgabeEntity.class new file mode 100644 index 0000000..3651846 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/AufgabeEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.class b/bin/main/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.class new file mode 100644 index 0000000..6539737 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/FavoritEntity.class b/bin/main/de/oaa/xxx/games/common/entity/FavoritEntity.class new file mode 100644 index 0000000..9888783 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/FavoritEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/FinisherEntity.class b/bin/main/de/oaa/xxx/games/common/entity/FinisherEntity.class new file mode 100644 index 0000000..6e4dfdd Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/FinisherEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/GruppenAboEntity.class b/bin/main/de/oaa/xxx/games/common/entity/GruppenAboEntity.class new file mode 100644 index 0000000..5a1aa3e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/GruppenAboEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/SperreEntity.class b/bin/main/de/oaa/xxx/games/common/entity/SperreEntity.class new file mode 100644 index 0000000..b96b688 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/SperreEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/StrafeEntity.class b/bin/main/de/oaa/xxx/games/common/entity/StrafeEntity.class new file mode 100644 index 0000000..4675a21 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/StrafeEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/entity/ToyEntity.class b/bin/main/de/oaa/xxx/games/common/entity/ToyEntity.class new file mode 100644 index 0000000..ad856fa Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/entity/ToyEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/AufgabeRepository.class b/bin/main/de/oaa/xxx/games/common/repository/AufgabeRepository.class new file mode 100644 index 0000000..5f354fe Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/AufgabeRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.class b/bin/main/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.class new file mode 100644 index 0000000..e830744 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/FavoritRepository.class b/bin/main/de/oaa/xxx/games/common/repository/FavoritRepository.class new file mode 100644 index 0000000..c4b4206 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/FavoritRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/FinisherRepository.class b/bin/main/de/oaa/xxx/games/common/repository/FinisherRepository.class new file mode 100644 index 0000000..b2a20d8 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/FinisherRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/GruppenAboRepository.class b/bin/main/de/oaa/xxx/games/common/repository/GruppenAboRepository.class new file mode 100644 index 0000000..5290628 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/GruppenAboRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/SperreRepository.class b/bin/main/de/oaa/xxx/games/common/repository/SperreRepository.class new file mode 100644 index 0000000..1b2df30 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/SperreRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/StrafeRepository.class b/bin/main/de/oaa/xxx/games/common/repository/StrafeRepository.class new file mode 100644 index 0000000..990bdac Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/StrafeRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/common/repository/ToyRepository.class b/bin/main/de/oaa/xxx/games/common/repository/ToyRepository.class new file mode 100644 index 0000000..da93a4a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/common/repository/ToyRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryController.class b/bin/main/de/oaa/xxx/games/history/GameHistoryController.class new file mode 100644 index 0000000..d1345de Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryController.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryDTO$ParticipantDTO.class b/bin/main/de/oaa/xxx/games/history/GameHistoryDTO$ParticipantDTO.class new file mode 100644 index 0000000..d087424 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryDTO$ParticipantDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryDTO.class b/bin/main/de/oaa/xxx/games/history/GameHistoryDTO.class new file mode 100644 index 0000000..7c28494 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryDTO.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryEntity.class b/bin/main/de/oaa/xxx/games/history/GameHistoryEntity.class new file mode 100644 index 0000000..bc57426 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantEntity.class b/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantEntity.class new file mode 100644 index 0000000..fa30101 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantRepository.class b/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantRepository.class new file mode 100644 index 0000000..f2deab3 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryParticipantRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameHistoryRepository.class b/bin/main/de/oaa/xxx/games/history/GameHistoryRepository.class new file mode 100644 index 0000000..8b1f025 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameHistoryRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameRole.class b/bin/main/de/oaa/xxx/games/history/GameRole.class new file mode 100644 index 0000000..0afe8e3 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameRole.class differ diff --git a/bin/main/de/oaa/xxx/games/history/GameType.class b/bin/main/de/oaa/xxx/games/history/GameType.class new file mode 100644 index 0000000..15885ee Binary files /dev/null and b/bin/main/de/oaa/xxx/games/history/GameType.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.class b/bin/main/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.class new file mode 100644 index 0000000..d4cd2cc Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/VanillaGame.class b/bin/main/de/oaa/xxx/games/vanilla/VanillaGame.class new file mode 100644 index 0000000..801ae52 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/VanillaGame.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.class b/bin/main/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.class new file mode 100644 index 0000000..5e298cb Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/VanillaMitspieler.class b/bin/main/de/oaa/xxx/games/vanilla/VanillaMitspieler.class new file mode 100644 index 0000000..54f949e Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/VanillaMitspieler.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAboController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAboController.class new file mode 100644 index 0000000..39c0b9d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAboController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.class new file mode 100644 index 0000000..a739204 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.class new file mode 100644 index 0000000..58962ae Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$AntwortRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$AntwortRequest.class new file mode 100644 index 0000000..6b82ef1 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$AntwortRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$EinladungRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$EinladungRequest.class new file mode 100644 index 0000000..7eefd4b Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$EinladungRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$SpielerDatenRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$SpielerDatenRequest.class new file mode 100644 index 0000000..74af3b1 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController$SpielerDatenRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.class new file mode 100644 index 0000000..ba73f0d Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.class new file mode 100644 index 0000000..356e5b5 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.class new file mode 100644 index 0000000..4880c36 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.class new file mode 100644 index 0000000..8711fc6 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenRequest.class new file mode 100644 index 0000000..9821102 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenResponse.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenResponse.class new file mode 100644 index 0000000..105f898 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$AbschliessenResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskRequest.class new file mode 100644 index 0000000..8dc6a68 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskResponse.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskResponse.class new file mode 100644 index 0000000..9335982 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController$ActiveTaskResponse.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController.class new file mode 100644 index 0000000..ac73b42 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaGameController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController$DraftRequest.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController$DraftRequest.class new file mode 100644 index 0000000..2dfd3b7 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController$DraftRequest.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.class new file mode 100644 index 0000000..1f4e1b6 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaToyController.class b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaToyController.class new file mode 100644 index 0000000..d369834 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/controller/VanillaToyController.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity$Status.class b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity$Status.class new file mode 100644 index 0000000..0d001e0 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity$Status.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.class b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.class new file mode 100644 index 0000000..4f38d11 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.class b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.class new file mode 100644 index 0000000..3a17dcc Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.class b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.class new file mode 100644 index 0000000..9636bbc Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.class b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.class new file mode 100644 index 0000000..c43f660 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.class b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.class new file mode 100644 index 0000000..5729681 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.class b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.class new file mode 100644 index 0000000..78105e2 Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.class b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.class new file mode 100644 index 0000000..69e5fcc Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.class differ diff --git a/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.class b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.class new file mode 100644 index 0000000..a4be87a Binary files /dev/null and b/bin/main/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/AnfrageStatus.class b/bin/main/de/oaa/xxx/gruppe/AnfrageStatus.class new file mode 100644 index 0000000..5166441 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/AnfrageStatus.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/BeitragTyp.class b/bin/main/de/oaa/xxx/gruppe/BeitragTyp.class new file mode 100644 index 0000000..62fb2f7 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/BeitragTyp.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppeController$CreateGruppeRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppeController$CreateGruppeRequest.class new file mode 100644 index 0000000..2ff4261 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppeController$CreateGruppeRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppeController$JoinRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppeController$JoinRequest.class new file mode 100644 index 0000000..e898d8c Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppeController$JoinRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppeController$UpdateGruppeRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppeController$UpdateGruppeRequest.class new file mode 100644 index 0000000..93078a3 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppeController$UpdateGruppeRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppeController.class b/bin/main/de/oaa/xxx/gruppe/GruppeController.class new file mode 100644 index 0000000..a0e2413 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppeController.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenRolle.class b/bin/main/de/oaa/xxx/gruppe/GruppenRolle.class new file mode 100644 index 0000000..6bd7d4c Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenRolle.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class new file mode 100644 index 0000000..18d9ea8 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$CreateBeitragRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class new file mode 100644 index 0000000..97f995f Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$PostsPage.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class new file mode 100644 index 0000000..03932cb Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$ReportRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class new file mode 100644 index 0000000..f241976 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController$VoteRequest.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class new file mode 100644 index 0000000..162ec0e Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/GruppenbeitragController.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.class b/bin/main/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.class new file mode 100644 index 0000000..88f4a6d Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.class b/bin/main/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.class new file mode 100644 index 0000000..1b5a19c Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/dto/GruppeDto.class b/bin/main/de/oaa/xxx/gruppe/dto/GruppeDto.class new file mode 100644 index 0000000..9480957 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/dto/GruppeDto.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.class b/bin/main/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.class new file mode 100644 index 0000000..158b12a Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.class b/bin/main/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.class new file mode 100644 index 0000000..ee49f4a Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.class new file mode 100644 index 0000000..99a8c9d Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.class new file mode 100644 index 0000000..725e3bb Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/GruppeEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/GruppeEntity.class new file mode 100644 index 0000000..6e2ea04 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/GruppeEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.class new file mode 100644 index 0000000..465f67a Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.class new file mode 100644 index 0000000..54c3625 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.class new file mode 100644 index 0000000..146cc3e Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.class new file mode 100644 index 0000000..9941e32 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.class b/bin/main/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.class new file mode 100644 index 0000000..87dc5ef Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.class new file mode 100644 index 0000000..f265c7c Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.class new file mode 100644 index 0000000..2dbf464 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/GruppeRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/GruppeRepository.class new file mode 100644 index 0000000..c70a544 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/GruppeRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.class new file mode 100644 index 0000000..6a20dd8 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.class new file mode 100644 index 0000000..ed2c91a Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.class new file mode 100644 index 0000000..f33147f Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.class new file mode 100644 index 0000000..2905c6f Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.class differ diff --git a/bin/main/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.class b/bin/main/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.class new file mode 100644 index 0000000..cb78474 Binary files /dev/null and b/bin/main/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.class differ diff --git a/bin/main/de/oaa/xxx/mail/Email.class b/bin/main/de/oaa/xxx/mail/Email.class new file mode 100644 index 0000000..b2c7ac0 Binary files /dev/null and b/bin/main/de/oaa/xxx/mail/Email.class differ diff --git a/bin/main/de/oaa/xxx/mail/MailService.class b/bin/main/de/oaa/xxx/mail/MailService.class new file mode 100644 index 0000000..9042dff Binary files /dev/null and b/bin/main/de/oaa/xxx/mail/MailService.class differ diff --git a/bin/main/de/oaa/xxx/mail/MailTemplateService.class b/bin/main/de/oaa/xxx/mail/MailTemplateService.class new file mode 100644 index 0000000..d677588 Binary files /dev/null and b/bin/main/de/oaa/xxx/mail/MailTemplateService.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungController$MeldungRequest.class b/bin/main/de/oaa/xxx/meldung/MeldungController$MeldungRequest.class new file mode 100644 index 0000000..afd9396 Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungController$MeldungRequest.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungController.class b/bin/main/de/oaa/xxx/meldung/MeldungController.class new file mode 100644 index 0000000..b02eb3e Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungController.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungEntity.class b/bin/main/de/oaa/xxx/meldung/MeldungEntity.class new file mode 100644 index 0000000..fa5413c Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungEntity.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungRepository.class b/bin/main/de/oaa/xxx/meldung/MeldungRepository.class new file mode 100644 index 0000000..15598b1 Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungRepository.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungStatus.class b/bin/main/de/oaa/xxx/meldung/MeldungStatus.class new file mode 100644 index 0000000..17abe7f Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungStatus.class differ diff --git a/bin/main/de/oaa/xxx/meldung/MeldungZielTyp.class b/bin/main/de/oaa/xxx/meldung/MeldungZielTyp.class new file mode 100644 index 0000000..2078ec1 Binary files /dev/null and b/bin/main/de/oaa/xxx/meldung/MeldungZielTyp.class differ diff --git a/bin/main/de/oaa/xxx/passwordreset/PasswordResetConfirm.class b/bin/main/de/oaa/xxx/passwordreset/PasswordResetConfirm.class new file mode 100644 index 0000000..e6060cd Binary files /dev/null and b/bin/main/de/oaa/xxx/passwordreset/PasswordResetConfirm.class differ diff --git a/bin/main/de/oaa/xxx/passwordreset/PasswordResetController.class b/bin/main/de/oaa/xxx/passwordreset/PasswordResetController.class new file mode 100644 index 0000000..181fbf6 Binary files /dev/null and b/bin/main/de/oaa/xxx/passwordreset/PasswordResetController.class differ diff --git a/bin/main/de/oaa/xxx/passwordreset/PasswordResetEntity.class b/bin/main/de/oaa/xxx/passwordreset/PasswordResetEntity.class new file mode 100644 index 0000000..f7f5c40 Binary files /dev/null and b/bin/main/de/oaa/xxx/passwordreset/PasswordResetEntity.class differ diff --git a/bin/main/de/oaa/xxx/passwordreset/PasswordResetRepository.class b/bin/main/de/oaa/xxx/passwordreset/PasswordResetRepository.class new file mode 100644 index 0000000..da485ba Binary files /dev/null and b/bin/main/de/oaa/xxx/passwordreset/PasswordResetRepository.class differ diff --git a/bin/main/de/oaa/xxx/passwordreset/PasswordResetRequest.class b/bin/main/de/oaa/xxx/passwordreset/PasswordResetRequest.class new file mode 100644 index 0000000..806b966 Binary files /dev/null and b/bin/main/de/oaa/xxx/passwordreset/PasswordResetRequest.class differ diff --git a/bin/main/de/oaa/xxx/registration/ActivationController.class b/bin/main/de/oaa/xxx/registration/ActivationController.class new file mode 100644 index 0000000..3a08ab9 Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/ActivationController.class differ diff --git a/bin/main/de/oaa/xxx/registration/Registration.class b/bin/main/de/oaa/xxx/registration/Registration.class new file mode 100644 index 0000000..dbaa2cc Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/Registration.class differ diff --git a/bin/main/de/oaa/xxx/registration/RegistrationController.class b/bin/main/de/oaa/xxx/registration/RegistrationController.class new file mode 100644 index 0000000..ec1d3a2 Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/RegistrationController.class differ diff --git a/bin/main/de/oaa/xxx/registration/RegistrationEntity.class b/bin/main/de/oaa/xxx/registration/RegistrationEntity.class new file mode 100644 index 0000000..244d7f8 Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/RegistrationEntity.class differ diff --git a/bin/main/de/oaa/xxx/registration/RegistrationRepository.class b/bin/main/de/oaa/xxx/registration/RegistrationRepository.class new file mode 100644 index 0000000..4cd682e Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/RegistrationRepository.class differ diff --git a/bin/main/de/oaa/xxx/registration/RegistrationService.class b/bin/main/de/oaa/xxx/registration/RegistrationService.class new file mode 100644 index 0000000..442beee Binary files /dev/null and b/bin/main/de/oaa/xxx/registration/RegistrationService.class differ diff --git a/bin/main/de/oaa/xxx/social/EventController.class b/bin/main/de/oaa/xxx/social/EventController.class new file mode 100644 index 0000000..ecb1a27 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/EventController.class differ diff --git a/bin/main/de/oaa/xxx/social/KommentarController$CreateKommentarRequest.class b/bin/main/de/oaa/xxx/social/KommentarController$CreateKommentarRequest.class new file mode 100644 index 0000000..2de4155 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/KommentarController$CreateKommentarRequest.class differ diff --git a/bin/main/de/oaa/xxx/social/KommentarController.class b/bin/main/de/oaa/xxx/social/KommentarController.class new file mode 100644 index 0000000..9257a94 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/KommentarController.class differ diff --git a/bin/main/de/oaa/xxx/social/LikeService.class b/bin/main/de/oaa/xxx/social/LikeService.class new file mode 100644 index 0000000..19abf13 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/LikeService.class differ diff --git a/bin/main/de/oaa/xxx/social/NotificationController.class b/bin/main/de/oaa/xxx/social/NotificationController.class new file mode 100644 index 0000000..9f8b7a4 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/NotificationController.class differ diff --git a/bin/main/de/oaa/xxx/social/PinnwandController$CreateEintragRequest.class b/bin/main/de/oaa/xxx/social/PinnwandController$CreateEintragRequest.class new file mode 100644 index 0000000..28a1a5f Binary files /dev/null and b/bin/main/de/oaa/xxx/social/PinnwandController$CreateEintragRequest.class differ diff --git a/bin/main/de/oaa/xxx/social/PinnwandController.class b/bin/main/de/oaa/xxx/social/PinnwandController.class new file mode 100644 index 0000000..e610f1f Binary files /dev/null and b/bin/main/de/oaa/xxx/social/PinnwandController.class differ diff --git a/bin/main/de/oaa/xxx/social/ProfileImageController$UploadRequest.class b/bin/main/de/oaa/xxx/social/ProfileImageController$UploadRequest.class new file mode 100644 index 0000000..6914e5a Binary files /dev/null and b/bin/main/de/oaa/xxx/social/ProfileImageController$UploadRequest.class differ diff --git a/bin/main/de/oaa/xxx/social/ProfileImageController.class b/bin/main/de/oaa/xxx/social/ProfileImageController.class new file mode 100644 index 0000000..01ac30b Binary files /dev/null and b/bin/main/de/oaa/xxx/social/ProfileImageController.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class b/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class new file mode 100644 index 0000000..8fdef2a Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SocialController$FriendRequestBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class b/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class new file mode 100644 index 0000000..0e52a0e Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SocialController$FriendshipActionBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class b/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class new file mode 100644 index 0000000..9ce4a4f Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SocialController$SendMessageBody.class differ diff --git a/bin/main/de/oaa/xxx/social/SocialController.class b/bin/main/de/oaa/xxx/social/SocialController.class new file mode 100644 index 0000000..83e3ffb Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SocialController.class differ diff --git a/bin/main/de/oaa/xxx/social/SseService.class b/bin/main/de/oaa/xxx/social/SseService.class new file mode 100644 index 0000000..4344ee3 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SseService.class differ diff --git a/bin/main/de/oaa/xxx/social/SystemMessageService.class b/bin/main/de/oaa/xxx/social/SystemMessageService.class new file mode 100644 index 0000000..10fcd1d Binary files /dev/null and b/bin/main/de/oaa/xxx/social/SystemMessageService.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/ConversationSummary.class b/bin/main/de/oaa/xxx/social/dto/ConversationSummary.class new file mode 100644 index 0000000..5f7e15b Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/ConversationSummary.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/FriendshipDto.class b/bin/main/de/oaa/xxx/social/dto/FriendshipDto.class new file mode 100644 index 0000000..4838eb3 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/FriendshipDto.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/KommentarDto.class b/bin/main/de/oaa/xxx/social/dto/KommentarDto.class new file mode 100644 index 0000000..48423a0 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/KommentarDto.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/MessageDto.class b/bin/main/de/oaa/xxx/social/dto/MessageDto.class new file mode 100644 index 0000000..df542b1 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/MessageDto.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/PinnwandEintragDto.class b/bin/main/de/oaa/xxx/social/dto/PinnwandEintragDto.class new file mode 100644 index 0000000..61bc808 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/PinnwandEintragDto.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/ProfileImageDto.class b/bin/main/de/oaa/xxx/social/dto/ProfileImageDto.class new file mode 100644 index 0000000..4e75191 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/ProfileImageDto.class differ diff --git a/bin/main/de/oaa/xxx/social/dto/UserProfile.class b/bin/main/de/oaa/xxx/social/dto/UserProfile.class new file mode 100644 index 0000000..6e8e23f Binary files /dev/null and b/bin/main/de/oaa/xxx/social/dto/UserProfile.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/FriendshipEntity$Status.class b/bin/main/de/oaa/xxx/social/entity/FriendshipEntity$Status.class new file mode 100644 index 0000000..d94191d Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/FriendshipEntity$Status.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/FriendshipEntity.class b/bin/main/de/oaa/xxx/social/entity/FriendshipEntity.class new file mode 100644 index 0000000..b6de84e Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/FriendshipEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/KommentarEntity.class b/bin/main/de/oaa/xxx/social/entity/KommentarEntity.class new file mode 100644 index 0000000..6749005 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/KommentarEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/KommentarLikeEntity.class b/bin/main/de/oaa/xxx/social/entity/KommentarLikeEntity.class new file mode 100644 index 0000000..3492af3 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/KommentarLikeEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/MessageCause.class b/bin/main/de/oaa/xxx/social/entity/MessageCause.class new file mode 100644 index 0000000..68c31e5 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/MessageCause.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/MessageEntity.class b/bin/main/de/oaa/xxx/social/entity/MessageEntity.class new file mode 100644 index 0000000..576c1a3 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/MessageEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/NotificationPreferenceEntity.class b/bin/main/de/oaa/xxx/social/entity/NotificationPreferenceEntity.class new file mode 100644 index 0000000..5fdf3bc Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/NotificationPreferenceEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/PinnwandEintragEntity.class b/bin/main/de/oaa/xxx/social/entity/PinnwandEintragEntity.class new file mode 100644 index 0000000..6c5ccf9 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/PinnwandEintragEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/PinnwandLikeEntity.class b/bin/main/de/oaa/xxx/social/entity/PinnwandLikeEntity.class new file mode 100644 index 0000000..c793dd2 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/PinnwandLikeEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/ProfileImageEntity.class b/bin/main/de/oaa/xxx/social/entity/ProfileImageEntity.class new file mode 100644 index 0000000..5409b59 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/ProfileImageEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/entity/ProfileImageLikeEntity.class b/bin/main/de/oaa/xxx/social/entity/ProfileImageLikeEntity.class new file mode 100644 index 0000000..4ebb304 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/entity/ProfileImageLikeEntity.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/FriendshipRepository.class b/bin/main/de/oaa/xxx/social/repository/FriendshipRepository.class new file mode 100644 index 0000000..eb9a7d9 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/FriendshipRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/KommentarLikeRepository.class b/bin/main/de/oaa/xxx/social/repository/KommentarLikeRepository.class new file mode 100644 index 0000000..e72c2c7 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/KommentarLikeRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/KommentarRepository.class b/bin/main/de/oaa/xxx/social/repository/KommentarRepository.class new file mode 100644 index 0000000..dc6415c Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/KommentarRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/MessageRepository.class b/bin/main/de/oaa/xxx/social/repository/MessageRepository.class new file mode 100644 index 0000000..6817bae Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/MessageRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/NotificationPreferenceRepository.class b/bin/main/de/oaa/xxx/social/repository/NotificationPreferenceRepository.class new file mode 100644 index 0000000..6327d08 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/NotificationPreferenceRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/PinnwandEintragRepository.class b/bin/main/de/oaa/xxx/social/repository/PinnwandEintragRepository.class new file mode 100644 index 0000000..2e6c776 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/PinnwandEintragRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/PinnwandLikeRepository.class b/bin/main/de/oaa/xxx/social/repository/PinnwandLikeRepository.class new file mode 100644 index 0000000..c2ab51b Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/PinnwandLikeRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/ProfileImageLikeRepository.class b/bin/main/de/oaa/xxx/social/repository/ProfileImageLikeRepository.class new file mode 100644 index 0000000..692b8e6 Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/ProfileImageLikeRepository.class differ diff --git a/bin/main/de/oaa/xxx/social/repository/ProfileImageRepository.class b/bin/main/de/oaa/xxx/social/repository/ProfileImageRepository.class new file mode 100644 index 0000000..fc80cde Binary files /dev/null and b/bin/main/de/oaa/xxx/social/repository/ProfileImageRepository.class differ diff --git a/bin/main/de/oaa/xxx/subscription/SubscriptionController.class b/bin/main/de/oaa/xxx/subscription/SubscriptionController.class new file mode 100644 index 0000000..7af3887 Binary files /dev/null and b/bin/main/de/oaa/xxx/subscription/SubscriptionController.class differ diff --git a/bin/main/de/oaa/xxx/subscription/SubscriptionLimitService.class b/bin/main/de/oaa/xxx/subscription/SubscriptionLimitService.class new file mode 100644 index 0000000..a94fa98 Binary files /dev/null and b/bin/main/de/oaa/xxx/subscription/SubscriptionLimitService.class differ diff --git a/bin/main/de/oaa/xxx/subscription/SubscriptionType.class b/bin/main/de/oaa/xxx/subscription/SubscriptionType.class new file mode 100644 index 0000000..91cbf70 Binary files /dev/null and b/bin/main/de/oaa/xxx/subscription/SubscriptionType.class differ diff --git a/bin/main/de/oaa/xxx/subscription/UserSubscriptionEntity.class b/bin/main/de/oaa/xxx/subscription/UserSubscriptionEntity.class new file mode 100644 index 0000000..2f51f29 Binary files /dev/null and b/bin/main/de/oaa/xxx/subscription/UserSubscriptionEntity.class differ diff --git a/bin/main/de/oaa/xxx/subscription/UserSubscriptionRepository.class b/bin/main/de/oaa/xxx/subscription/UserSubscriptionRepository.class new file mode 100644 index 0000000..d8fbd74 Binary files /dev/null and b/bin/main/de/oaa/xxx/subscription/UserSubscriptionRepository.class differ diff --git a/bin/main/de/oaa/xxx/support/SupportUserService.class b/bin/main/de/oaa/xxx/support/SupportUserService.class new file mode 100644 index 0000000..121de43 Binary files /dev/null and b/bin/main/de/oaa/xxx/support/SupportUserService.class differ diff --git a/bin/main/de/oaa/xxx/user/Beziehungsstatus.class b/bin/main/de/oaa/xxx/user/Beziehungsstatus.class new file mode 100644 index 0000000..ecba8b0 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/Beziehungsstatus.class differ diff --git a/bin/main/de/oaa/xxx/user/Geschlecht.class b/bin/main/de/oaa/xxx/user/Geschlecht.class new file mode 100644 index 0000000..8d1a4ca Binary files /dev/null and b/bin/main/de/oaa/xxx/user/Geschlecht.class differ diff --git a/bin/main/de/oaa/xxx/user/LoginController$LoginRequest.class b/bin/main/de/oaa/xxx/user/LoginController$LoginRequest.class new file mode 100644 index 0000000..3468070 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/LoginController$LoginRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/LoginController.class b/bin/main/de/oaa/xxx/user/LoginController.class new file mode 100644 index 0000000..4f7b2e1 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/LoginController.class differ diff --git a/bin/main/de/oaa/xxx/user/Neigung.class b/bin/main/de/oaa/xxx/user/Neigung.class new file mode 100644 index 0000000..c068f0a Binary files /dev/null and b/bin/main/de/oaa/xxx/user/Neigung.class differ diff --git a/bin/main/de/oaa/xxx/user/Sichtbarkeit.class b/bin/main/de/oaa/xxx/user/Sichtbarkeit.class new file mode 100644 index 0000000..db51ed7 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/Sichtbarkeit.class differ diff --git a/bin/main/de/oaa/xxx/user/User.class b/bin/main/de/oaa/xxx/user/User.class new file mode 100644 index 0000000..1368a04 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/User.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$BdsmDefaultsRequest.class b/bin/main/de/oaa/xxx/user/UserController$BdsmDefaultsRequest.class new file mode 100644 index 0000000..11419f9 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$BdsmDefaultsRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$GeburtsdatumChangeRequest.class b/bin/main/de/oaa/xxx/user/UserController$GeburtsdatumChangeRequest.class new file mode 100644 index 0000000..e5fe5b3 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$GeburtsdatumChangeRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$NameChangeRequest.class b/bin/main/de/oaa/xxx/user/UserController$NameChangeRequest.class new file mode 100644 index 0000000..e4b4eac Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$NameChangeRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$NotificationPreferenceRequest.class b/bin/main/de/oaa/xxx/user/UserController$NotificationPreferenceRequest.class new file mode 100644 index 0000000..d3eeb6c Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$NotificationPreferenceRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$PrivacyRequest.class b/bin/main/de/oaa/xxx/user/UserController$PrivacyRequest.class new file mode 100644 index 0000000..27927bf Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$PrivacyRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$ProfilePictureRequest.class b/bin/main/de/oaa/xxx/user/UserController$ProfilePictureRequest.class new file mode 100644 index 0000000..32982c5 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$ProfilePictureRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$ProfileRequest.class b/bin/main/de/oaa/xxx/user/UserController$ProfileRequest.class new file mode 100644 index 0000000..1bfccee Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$ProfileRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigDto.class b/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigDto.class new file mode 100644 index 0000000..147ca3c Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigDto.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigRequest.class b/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigRequest.class new file mode 100644 index 0000000..506967b Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController$TtlockUserConfigRequest.class differ diff --git a/bin/main/de/oaa/xxx/user/UserController.class b/bin/main/de/oaa/xxx/user/UserController.class new file mode 100644 index 0000000..30852de Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserController.class differ diff --git a/bin/main/de/oaa/xxx/user/UserEntity.class b/bin/main/de/oaa/xxx/user/UserEntity.class new file mode 100644 index 0000000..da80189 Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserEntity.class differ diff --git a/bin/main/de/oaa/xxx/user/UserRepository.class b/bin/main/de/oaa/xxx/user/UserRepository.class new file mode 100644 index 0000000..bfa56cf Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserRepository.class differ diff --git a/bin/main/de/oaa/xxx/user/UserService.class b/bin/main/de/oaa/xxx/user/UserService.class new file mode 100644 index 0000000..a95ac5d Binary files /dev/null and b/bin/main/de/oaa/xxx/user/UserService.class differ diff --git a/bin/main/de/oaa/xxx/util/ValidationResult.class b/bin/main/de/oaa/xxx/util/ValidationResult.class new file mode 100644 index 0000000..ff28a27 Binary files /dev/null and b/bin/main/de/oaa/xxx/util/ValidationResult.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/UserVorliebeEntity.class b/bin/main/de/oaa/xxx/vorlieben/UserVorliebeEntity.class new file mode 100644 index 0000000..06ddca9 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/UserVorliebeEntity.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/UserVorliebeRepository.class b/bin/main/de/oaa/xxx/vorlieben/UserVorliebeRepository.class new file mode 100644 index 0000000..9f575a8 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/UserVorliebeRepository.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebeBewertung.class b/bin/main/de/oaa/xxx/vorlieben/VorliebeBewertung.class new file mode 100644 index 0000000..e947e7a Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebeBewertung.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebeItemEntity.class b/bin/main/de/oaa/xxx/vorlieben/VorliebeItemEntity.class new file mode 100644 index 0000000..a3aee96 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebeItemEntity.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebeItemRepository.class b/bin/main/de/oaa/xxx/vorlieben/VorliebeItemRepository.class new file mode 100644 index 0000000..8fa68bc Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebeItemRepository.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.class b/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.class new file mode 100644 index 0000000..f377e6d Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.class b/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.class new file mode 100644 index 0000000..68eb4ed Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportItem.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportItem.class new file mode 100644 index 0000000..cf7895c Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportItem.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportKategorie.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportKategorie.class new file mode 100644 index 0000000..de2aa3f Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ExportKategorie.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportItem.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportItem.class new file mode 100644 index 0000000..b0dce20 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportItem.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportKategorie.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportKategorie.class new file mode 100644 index 0000000..46c0449 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportKategorie.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportResult.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportResult.class new file mode 100644 index 0000000..7fcc8e8 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ImportResult.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemDto.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemDto.class new file mode 100644 index 0000000..9676c17 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemDto.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemRequest.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemRequest.class new file mode 100644 index 0000000..dcfcae9 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$ItemRequest.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieDto.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieDto.class new file mode 100644 index 0000000..db7b6da Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieDto.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieRequest.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieRequest.class new file mode 100644 index 0000000..07f8ddf Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController$KategorieRequest.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController.class new file mode 100644 index 0000000..9ea5af8 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenAdminController.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenController$ItemDto.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenController$ItemDto.class new file mode 100644 index 0000000..53ce00a Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenController$ItemDto.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenController$KategorieWithItems.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenController$KategorieWithItems.class new file mode 100644 index 0000000..c527919 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenController$KategorieWithItems.class differ diff --git a/bin/main/de/oaa/xxx/vorlieben/VorliebenController.class b/bin/main/de/oaa/xxx/vorlieben/VorliebenController.class new file mode 100644 index 0000000..d3e6b30 Binary files /dev/null and b/bin/main/de/oaa/xxx/vorlieben/VorliebenController.class differ diff --git a/bin/main/sql/admin.sql b/bin/main/sql/admin.sql new file mode 100644 index 0000000..45675b3 --- /dev/null +++ b/bin/main/sql/admin.sql @@ -0,0 +1,13 @@ +● -- Person zur admin-Tabelle als SUPERADMIN hinzufügen (über E-Mail-Adresse) + INSERT INTO admin (admin_id, user_id, rolle, created_at) + SELECT UUID(), u.user_id, 'SUPERADMIN', NOW() + FROM user u + WHERE u.email = 'email@beispiel.de'; + + -- Falls der User bereits ein (normaler) Admin ist, Rolle upgraden: + UPDATE admin a + JOIN user u ON a.user_id = u.user_id + SET a.rolle = 'SUPERADMIN' + WHERE u.email = 'email@beispiel.de'; + + --Einfach email@beispiel.de durch die Ziel-E-Mail ersetzen. Das erste Statement fügt einen neuen Admin-Eintrag ein, das zweite upgraded einen bestehenden. Nur eines von beiden ausführen je nach Fall. diff --git a/bin/main/sql/testdata_aufgabengruppen.sql b/bin/main/sql/testdata_aufgabengruppen.sql new file mode 100644 index 0000000..a87052a --- /dev/null +++ b/bin/main/sql/testdata_aufgabengruppen.sql @@ -0,0 +1,588 @@ +-- ============================================================ +-- Testdaten: Aufgabengruppen (generiert aus DefaultFiller) +-- Toys und *Toy-Join-Tabellen werden ignoriert. +-- UUID-Speicherung: varchar(36) als plain UUID-String +-- Spaltennamen: SpringPhysicalNamingStrategy → snake_case +-- ============================================================ + +SET NAMES utf8mb4; + +-- ── Aufgabengruppen ────────────────────────────────────────── +INSERT IGNORE INTO aufgaben_gruppe (gruppen_id, name, beschreibung, user_id, private_gruppe, bild, von) VALUES +('10000000-0000-0000-0000-000000000001', 'Keuschhaltung weiblich', 'Enthält verschiedene Aufgaben für Keuschhaltung von weiblichen Spielpartnern', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000002', 'Keuschhaltung männlich', 'Enthält verschiedene Aufgaben für Keuschhaltung von männlichen Spielpartnern', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000003', 'Plugs', 'Enthält verschiedene Aufgaben für das Tragen von Buttplugs über einen gewissen Zeitraum.', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000004', 'Knebel', 'Enthält verschiedene Aufgaben für das Tragen von Knebeln über einen gewissen Zeitraum.', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000005', 'Strafen', 'Enthält verschiedene Bestrafungen', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000006', 'Aufgaben', 'Enthält verschiedene Sex-Aufgaben.', NULL, 0, NULL, NULL); + + +-- ── Sperren ────────────────────────────────────────────────── +-- Gruppe: Keuschhaltung weiblich +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000001', 'Voll-KG', + '{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000002', 'Voll-KG + Vaginaldildo', + '{PASSIV} trägt fortan einen Voll-KG mit Vaginaldildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000003', 'Voll-KG + Analdildo', + '{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000004', 'Voll-KG + Doubleplugged', + '{PASSIV} trägt fortan einen Voll-KG mit Vaginal- und Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'); + +-- Gruppe: Keuschhaltung männlich +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000005', 'Peniskäfig', + '{PASSIV} trägt fortan einen Peniskäfig, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem Peniskäfig zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'), + +('20000000-0000-0000-0000-000000000006', 'Voll-KG', + '{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'), + +('20000000-0000-0000-0000-000000000007', 'Voll-KG + Analdildo', + '{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'); + +-- Gruppe: Plugs +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000008', 'Plug klein', + '{AKTIV} führt {PASSIV} einen kleinen Buttplug in anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000009', 'Plug mittel', + '{AKTIV} führt {PASSIV} einen mittelgroßen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000010', 'Plug groß', + '{AKTIV} führt {PASSIV} einen großen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000011', 'Elektro-Plug anal', + '{AKTIV} führt {PASSIV} einen Elekro-Plug anal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000012', 'Elektro-Plug vaginal', + '{AKTIV} führt {PASSIV} einen Elekto-Plug vaginal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'); + +-- Gruppe: Knebel +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000013', 'Ballknebel', + '{AKTIV}, lege {PASSIV} einen Ballknebel an, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 10, 30, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000014', 'Penisknebel', + '{AKTIV}, lege {PASSIV} einen Dildoknebel an, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 10, 30, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000015', 'Aufblasbarer Knebel', + '{AKTIV}, lege {PASSIV} einen aufblasbaren Knebel an und pumpe diesen soweit auf, dass {PASSIV} noch halbwegs gut atmen kann, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 5, 15, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000016', 'Isolationsmaske', + '{AKTIV}, lege {PASSIV} eine Isolationsmaske an, diese ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 5, 15, '10000000-0000-0000-0000-000000000004'); + +-- sperre_sperre_fuer (war @CollectionTable name="sperre_sperreFuer" → snake_case) +INSERT IGNORE INTO sperre_sperre_fuer (sperre_id, werkzeug) VALUES +('20000000-0000-0000-0000-000000000001', 'VAGINA'), +('20000000-0000-0000-0000-000000000002', 'VAGINA'), +('20000000-0000-0000-0000-000000000003', 'VAGINA'), +('20000000-0000-0000-0000-000000000003', 'ANUS'), +('20000000-0000-0000-0000-000000000004', 'VAGINA'), +('20000000-0000-0000-0000-000000000004', 'ANUS'), +('20000000-0000-0000-0000-000000000005', 'PENIS'), +('20000000-0000-0000-0000-000000000006', 'PENIS'), +('20000000-0000-0000-0000-000000000007', 'PENIS'), +('20000000-0000-0000-0000-000000000007', 'ANUS'), +('20000000-0000-0000-0000-000000000008', 'ANUS'), +('20000000-0000-0000-0000-000000000009', 'ANUS'), +('20000000-0000-0000-0000-000000000010', 'ANUS'), +('20000000-0000-0000-0000-000000000011', 'ANUS'), +('20000000-0000-0000-0000-000000000012', 'VAGINA'), +('20000000-0000-0000-0000-000000000013', 'MUND'), +('20000000-0000-0000-0000-000000000014', 'MUND'), +('20000000-0000-0000-0000-000000000015', 'MUND'), +('20000000-0000-0000-0000-000000000016', 'MUND'); + + +-- ── Strafen ────────────────────────────────────────────────── +INSERT IGNORE INTO strafe (strafe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES +('30000000-0000-0000-0000-000000000001', '5 Schläge mit flachen Hand', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der flachen Hand auf das Gesäß.', + 1, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000002', '15 Schläge mit flachen Hand', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der flachen Hand auf das Gesäß, {PASSIV} zählt laut mit', + 3, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000003', '5 Schläge mit Gerte', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf das Gesäß.', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000004', '15 Schläge mit Gerte', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Gerte auf das Gesäß, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000005', '5 Schläge mit Paddel', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf das Gesäß.', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000006', '15 Schläge mit Paddel', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit dem Paddel auf das Gesäß, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000007', '5 Schläge mit Peitsche', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf das Gesäß.', + 3, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000008', '15 Schläge mit Peitsche', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Peitsche auf das Gesäß, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000009', 'Schläge auf Klitoris mit Hand', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Hand auf die Klitoris, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000010', 'Schläge auf Klitoris mit Peitsche', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000011', 'Schläge auf Klitoris mit Paddel', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000012', 'Schläge auf Klitoris mit Gerte', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000013', '5 Ohrfeigen', + '{PASSIV} stellt sich mit dem Rücken zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Ohrfeigen, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000014', 'Elektroplug anal', + '{AKTIV} führt {PASSIV} anal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000015', 'Elektroplug vaginal', + '{AKTIV} führt {PASSIV} vaginal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000016', 'Pumpplug anal', + '{AKTIV} führt {PASSIV} anal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000017', 'Pumpplug vaginal', + '{AKTIV} führt {PASSIV} vaginal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000018', 'Facesitting (Vagina)', + '{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen', + 2, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000019', 'Facesitting gefesselt (Vagina)', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen', + 4, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000020', 'Facesitting (Penis)', + '{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen', + 2, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000021', 'Facesitting gefesselt (Penis)', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen', + 4, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000022', 'Facesitting Doppelpenisknebel', + '{PASSIV} liegt auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000023', 'Facesitting Doppelpenisknebel gefesselt', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000024', 'Nippelklemmen', + '{AKTIV} legt {PASSIV} Nippelklemmen an, {AKTIV} zieht an der Kette und erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 3, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000025', 'Nippelbehandlung', + '{AKTIV} nimmt die Nippel von {PASSIV} zwischen die Finger und erhöht langsam den Druck bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000026', 'Hilflos liegen lassen', + '{AKTIV} fesselt, knebelt und verbindet die Augen von {PASSIV}. {AKTIV} lässt {PASSIV} wehrlos liegen, bei Ablauf der Zeit erlöst {AKTIV} {PASSIV} mit einem beherzten Platsch auf den Po', + 4, 300, 600, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000027', 'Strapon reiten', + '{PASSIV} liegt auf dem Rücken und trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}', + 3, 60, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000028', 'Strapon reiten gefesselt', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}', + 4, 60, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000029', 'Teaseblowjob mit dem Strapon', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} gibt dem Umschnalldildo einen Blowjob in 69er Position und präsentiert {PASSIV} dabei den Intimbereich', + 5, 180, 300, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000030', 'Teasereiten mit Strapon', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} reitet den Umschnalldildo von {PASSIV}.', + 5, 180, 300, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000031', 'Tease mit Selbstbefriedigung (Mann KG)', + '{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber', + 4, 240, 360, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000032', 'Tease mit Selbstbefriedigung (Frau KG)', + '{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber', + 4, 240, 360, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000033', 'Blowjob auf allen vieren', + '{AKTIV}, zwinge {PASSIV} vor dir auf die Knie, führe dein Glied (oder Strap on) in den Mund von {PASSIV} ein und zeig mit einem Deepthroat, wer das sagen hat', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000034', 'Oralsex mit kleinem Dildo in der Vagina', + '{PASSIV}, geh auf die Knie und reite vaginal einen kleinen Dildo, befriedige dabei {AKTIV} oral.', + 2, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000035', 'Oralsex mit großen Dildo in der Vagina', + '{PASSIV}, geh auf die Knie und reite vaginal einen großen Dildo, befriedige dabei {AKTIV} oral.', + 4, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000036', 'Oralsex mit kleinem Dildo im Anus', + '{PASSIV}, geh auf die Knie und reite anal einen kleinen Dildo, befriedige dabei {AKTIV} oral.', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000037', 'Oralsex mit großen Dildo im Anus', + '{PASSIV}, geh auf die Knie und reite anal einen großen Dildo, befriedige dabei {AKTIV} oral.', + 4, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000038', 'Vagina dehnen', + '{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Vagina von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000039', 'Anus dehnen', + '{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Anus von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000040', 'Vaginalsex in Missionarstellung und Breathplay', + '{AKTIV} dringt in Missionarsstellung in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu', + 4, 30, 60, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000041', 'Analsex in Missionarstellung und Breathplay', + '{AKTIV} dringt in Missionarsstellung anal in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu', + 4, 30, 60, '10000000-0000-0000-0000-000000000005'); + +-- strafe_benoetigt_passiv (war @CollectionTable name="strafe_benoetigtPassiv") +INSERT IGNORE INTO strafe_benoetigt_passiv (strafe_id, werkzeug) VALUES +('30000000-0000-0000-0000-000000000009', 'VAGINA'), +('30000000-0000-0000-0000-000000000010', 'VAGINA'), +('30000000-0000-0000-0000-000000000011', 'VAGINA'), +('30000000-0000-0000-0000-000000000012', 'VAGINA'), +('30000000-0000-0000-0000-000000000014', 'ANUS'), +('30000000-0000-0000-0000-000000000015', 'VAGINA'), +('30000000-0000-0000-0000-000000000016', 'ANUS'), +('30000000-0000-0000-0000-000000000017', 'VAGINA'), +('30000000-0000-0000-0000-000000000018', 'MUND'), +('30000000-0000-0000-0000-000000000019', 'MUND'), +('30000000-0000-0000-0000-000000000020', 'MUND'), +('30000000-0000-0000-0000-000000000021', 'MUND'), +('30000000-0000-0000-0000-000000000022', 'MUND'), +('30000000-0000-0000-0000-000000000023', 'MUND'), +('30000000-0000-0000-0000-000000000033', 'MUND'), +('30000000-0000-0000-0000-000000000034', 'VAGINA'), +('30000000-0000-0000-0000-000000000035', 'VAGINA'), +('30000000-0000-0000-0000-000000000036', 'ANUS'), +('30000000-0000-0000-0000-000000000037', 'ANUS'), +('30000000-0000-0000-0000-000000000038', 'VAGINA'), +('30000000-0000-0000-0000-000000000039', 'ANUS'), +('30000000-0000-0000-0000-000000000040', 'VAGINA'), +('30000000-0000-0000-0000-000000000041', 'ANUS'); + +-- strafe_benoetigt_aktiv (war @CollectionTable name="strafe_benoetigtAktiv") +INSERT IGNORE INTO strafe_benoetigt_aktiv (strafe_id, werkzeug) VALUES +('30000000-0000-0000-0000-000000000018', 'VAGINA'), +('30000000-0000-0000-0000-000000000018', 'ANUS'), +('30000000-0000-0000-0000-000000000019', 'VAGINA'), +('30000000-0000-0000-0000-000000000019', 'ANUS'), +('30000000-0000-0000-0000-000000000020', 'PENIS'), +('30000000-0000-0000-0000-000000000020', 'ANUS'), +('30000000-0000-0000-0000-000000000021', 'VAGINA'), +('30000000-0000-0000-0000-000000000021', 'PENIS'), +('30000000-0000-0000-0000-000000000022', 'VAGINA'), +('30000000-0000-0000-0000-000000000023', 'VAGINA'), +('30000000-0000-0000-0000-000000000027', 'VAGINA'), +('30000000-0000-0000-0000-000000000027', 'ANUS'), +('30000000-0000-0000-0000-000000000028', 'VAGINA'), +('30000000-0000-0000-0000-000000000028', 'ANUS'), +('30000000-0000-0000-0000-000000000029', 'VAGINA'), +('30000000-0000-0000-0000-000000000030', 'VAGINA'), +('30000000-0000-0000-0000-000000000031', 'VAGINA'), +('30000000-0000-0000-0000-000000000032', 'PENIS'), +('30000000-0000-0000-0000-000000000033', 'PENIS'), +('30000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'), +('30000000-0000-0000-0000-000000000034', 'VAGINA'), +('30000000-0000-0000-0000-000000000034', 'PENIS'), +('30000000-0000-0000-0000-000000000035', 'VAGINA'), +('30000000-0000-0000-0000-000000000035', 'PENIS'), +('30000000-0000-0000-0000-000000000036', 'VAGINA'), +('30000000-0000-0000-0000-000000000036', 'PENIS'), +('30000000-0000-0000-0000-000000000037', 'VAGINA'), +('30000000-0000-0000-0000-000000000037', 'PENIS'), +('30000000-0000-0000-0000-000000000040', 'PENIS'), +('30000000-0000-0000-0000-000000000040', 'UMSCHNALLDILDO'), +('30000000-0000-0000-0000-000000000041', 'PENIS'), +('30000000-0000-0000-0000-000000000041', 'UMSCHNALLDILDO'); + + +-- ── Aufgaben ───────────────────────────────────────────────── +INSERT IGNORE INTO aufgabe (aufgabe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES +('40000000-0000-0000-0000-000000000001', 'Hintern präsentieren', + '{AKTIV}, zeig {PASSIV} deinen Hintern, gib dir selber dabei ein oder zwei Klappse auf den Po', + 1, NULL, NULL, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000002', 'Hals küssen', + '{AKTIV}, küsse den Hals von {PASSIV} leidenschaftlich', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000003', 'Bauchnabel küssen', + '{AKTIV}, zeichne mit Küssen den Bauchnabel von {PASSIV} nach', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000004', 'Ohren knabbern', + '{AKTIV}, knabber leidenschaftlich an den Ohrläppchen von {PASSIV}', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000005', 'Berühren ohne anfassen', + '{AKTIV}, berühre den gesamten Körper von {PASSIV} ohne die Hände zu verwenden', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000006', 'Nacken küssen', + '{PASSIV} sitzt vor {AKTIV}, {AKTIV} küsste leidenschaftlich den Nacken von {PASSIV}', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000007', 'Brust küssen', + '{AKTIV}, küsse die Brust von {PASSIV} ohne die Nippel zu berühren', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000008', 'Nippel verwöhnen', + '{AKTIV}, verwöhne die Nippel von {PASSIV} mit Küssen', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000009', 'Hintern küssen', + '{AKTIV}, küsse den Hintern von {PASSIV} ohne den Anus zu berühren', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000010', 'Intimkuss durch Unterwäsche', + '{AKTIV}, küsse den Intimbereich von {PASSIV} durch die Unterwäsche', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000011', 'Brustmassage', + '{AKTIV}, massiere die Brust von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000012', 'Hinternmassage', + '{AKTIV}, massiere den Hintern von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000013', 'Rückenmassage', + '{AKTIV}, massiere den Rücken von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000014', 'Oberschenkelmassage', + '{AKTIV}, massiere die Oberschenkel von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000015', 'Klitoris mit Vibrator verwöhnen', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit einem Vibrator', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000016', 'Cunnilingus und Finger in Vagina', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit dem Mund, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000017', 'Klitoris mit Fingern verwöhnen und Finger in Vagina', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit der Hand, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein', + 4, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000018', 'Eichel mit Vibrator verwöhnen', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit einem Vibrator', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000019', 'Felatio', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit dem Mund', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000020', 'Handjob', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit der Hand', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000021', 'Facesitting', + '{AKTIV} liegt auf dem Rücken, {PASSIV} sitzt auf seinem Gesicht. {AKTIV}, verwöhne die Vagina von {PASSIV} mit dem Mund', + 4, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000022', '69er-Position', + '69er-Zeit: {AKTIV} liegt oben. {PASSIV}, falls du verschlossen bist, ziehe einen Strap on an, damit {AKTIV} auch was zu tun hat.', + 4, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000023', 'Kleiner Dildo vaginal', + '{AKTIV}, führe {PASSIV} einen kleinen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000024', 'Großer Dildo vaginal', + '{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem', + 4, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000025', 'Großer Dildo vaginal schnell', + '{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und bewege selbigen möglichst schnell rein und raus', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000026', 'Missionarstellung langsam', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000027', 'Missionarstellung schnell', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 30, 90, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000028', 'Missionarstellung Vollgas', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000029', 'Reiterstellung langsam', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} bestimmt das Tempo', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000030', 'Reiterstellung schnell', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} versucht das Tempo hoch zu halten', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000031', 'Reiterstellung vollgas', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV} und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000032', 'Doggystyle langsam', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000033', 'Doggystyle schnell', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000034', 'Doggystyle vollgas', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000035', 'Doggystyle vollgas keinen Mucks', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas. {PASSIV} darf dabei keinen Laut von sich geben.', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000036', 'Doggystyle Tempo bestimmt die ''gefickte'' Person', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein. {AKTIV} hält still und {PASSIV} gibt das Tempo vor', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000037', 'Löffelchen langsam', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000038', 'Löffelchen schnell', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000039', 'Löffelchen vollgas', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'); + +-- aufgabe_benoetigt_aktiv (war @CollectionTable name="aufgabe_benoetigtAktiv") +INSERT IGNORE INTO aufgabe_benoetigt_aktiv (aufgabe_id, werkzeug) VALUES +('40000000-0000-0000-0000-000000000002', 'MUND'), +('40000000-0000-0000-0000-000000000003', 'MUND'), +('40000000-0000-0000-0000-000000000004', 'MUND'), +('40000000-0000-0000-0000-000000000006', 'MUND'), +('40000000-0000-0000-0000-000000000007', 'MUND'), +('40000000-0000-0000-0000-000000000008', 'MUND'), +('40000000-0000-0000-0000-000000000009', 'MUND'), +('40000000-0000-0000-0000-000000000010', 'MUND'), +('40000000-0000-0000-0000-000000000016', 'MUND'), +('40000000-0000-0000-0000-000000000019', 'MUND'), +('40000000-0000-0000-0000-000000000021', 'MUND'), +('40000000-0000-0000-0000-000000000022', 'VAGINA'), +('40000000-0000-0000-0000-000000000022', 'MUND'), +('40000000-0000-0000-0000-000000000026', 'PENIS'), +('40000000-0000-0000-0000-000000000026', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000027', 'PENIS'), +('40000000-0000-0000-0000-000000000027', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000028', 'PENIS'), +('40000000-0000-0000-0000-000000000028', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000029', 'PENIS'), +('40000000-0000-0000-0000-000000000029', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000030', 'PENIS'), +('40000000-0000-0000-0000-000000000030', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000031', 'PENIS'), +('40000000-0000-0000-0000-000000000031', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000032', 'PENIS'), +('40000000-0000-0000-0000-000000000032', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000033', 'PENIS'), +('40000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000034', 'PENIS'), +('40000000-0000-0000-0000-000000000034', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000035', 'PENIS'), +('40000000-0000-0000-0000-000000000035', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000036', 'PENIS'), +('40000000-0000-0000-0000-000000000036', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000037', 'PENIS'), +('40000000-0000-0000-0000-000000000037', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000038', 'PENIS'), +('40000000-0000-0000-0000-000000000038', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000039', 'PENIS'), +('40000000-0000-0000-0000-000000000039', 'UMSCHNALLDILDO'); + +-- aufgabe_benoetigt_passiv (war @CollectionTable name="aufgabe_benoetigtPassiv") +INSERT IGNORE INTO aufgabe_benoetigt_passiv (aufgabe_id, werkzeug) VALUES +('40000000-0000-0000-0000-000000000015', 'VAGINA'), +('40000000-0000-0000-0000-000000000016', 'VAGINA'), +('40000000-0000-0000-0000-000000000017', 'VAGINA'), +('40000000-0000-0000-0000-000000000018', 'PENIS'), +('40000000-0000-0000-0000-000000000019', 'PENIS'), +('40000000-0000-0000-0000-000000000020', 'PENIS'), +('40000000-0000-0000-0000-000000000021', 'VAGINA'), +('40000000-0000-0000-0000-000000000022', 'MUND'), +('40000000-0000-0000-0000-000000000023', 'VAGINA'), +('40000000-0000-0000-0000-000000000024', 'VAGINA'), +('40000000-0000-0000-0000-000000000025', 'VAGINA'), +('40000000-0000-0000-0000-000000000026', 'VAGINA'), +('40000000-0000-0000-0000-000000000027', 'VAGINA'), +('40000000-0000-0000-0000-000000000028', 'VAGINA'), +('40000000-0000-0000-0000-000000000029', 'VAGINA'), +('40000000-0000-0000-0000-000000000030', 'VAGINA'), +('40000000-0000-0000-0000-000000000031', 'VAGINA'), +('40000000-0000-0000-0000-000000000032', 'VAGINA'), +('40000000-0000-0000-0000-000000000033', 'VAGINA'), +('40000000-0000-0000-0000-000000000034', 'VAGINA'), +('40000000-0000-0000-0000-000000000035', 'VAGINA'), +('40000000-0000-0000-0000-000000000036', 'VAGINA'), +('40000000-0000-0000-0000-000000000037', 'VAGINA'), +('40000000-0000-0000-0000-000000000038', 'VAGINA'), +('40000000-0000-0000-0000-000000000039', 'VAGINA'); diff --git a/bin/main/sql/testdaten.sql b/bin/main/sql/testdaten.sql new file mode 100644 index 0000000..6fb7835 --- /dev/null +++ b/bin/main/sql/testdaten.sql @@ -0,0 +1,504 @@ +-- ============================================================= +-- XXX The Game – Testdaten +-- ============================================================= +-- Passwort für alle User: Test1234! +-- SHA-256("Test1234!") = 11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a +-- Hinweis: Login erwartet SHA-256-Hash vom Client +-- ============================================================= + +SET FOREIGN_KEY_CHECKS = 0; + +-- Aufräumen (Reihenfolge wegen FK) +DELETE FROM kommentar_like; +DELETE FROM kommentar; +DELETE FROM pinnwand_like; +DELETE FROM pinnwand_eintrag; +DELETE FROM feed_post_vote; +DELETE FROM feed_post_option; +DELETE FROM feed_post_like; +DELETE FROM feed_post; +DELETE FROM umfrage_stimme; +DELETE FROM umfrage_option; +DELETE FROM gruppe_beitrag_like; +DELETE FROM gruppe_beitrag; +DELETE FROM beitrittsanfrage; +DELETE FROM gruppe_mitglied; +DELETE FROM gruppe; +DELETE FROM profile_image_like; +DELETE FROM profile_image; +DELETE FROM friendship; +DELETE FROM registration; +DELETE FROM `user`; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================= +-- BENUTZER (5 User mit unterschiedlichen Profilen) +-- ============================================================= + +INSERT INTO `user` ( + user_id, name, email, password, geburtsdatum, + groesse, gewicht, geschlecht, neigung, beziehungsstatus, beschreibung, + lockee_xp, keyholder_xp, bdsm_xp, + sichtbarkeit_grunddaten, sichtbarkeit_galerie, sichtbarkeit_freunde, + sichtbarkeit_feed, sichtbarkeit_pinnwand, sichtbarkeit_xp, sichtbarkeit_lockhistorie +) VALUES + +-- 1. MaxMuster – dominant, Single +('11111111-1111-1111-1111-000000000001', + 'MaxMuster', 'max@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1990-05-15', + 182, 80, 'MAENNLICH', 'DOMINANT', 'SINGLE', + 'Erfahrener Keyholder, der auf striktes aber faires Spiel steht. Immer offen für neue Spielpartner.', + 120, 850, 300, + 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'), + +-- 2. LisaLust – devot, Single +('11111111-1111-1111-1111-000000000002', + 'LisaLust', 'lisa@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1995-08-22', + 165, 58, 'WEIBLICH', 'DEVOT', 'SINGLE', + 'Neugierigie Lockee auf der Suche nach einem verlässlichen Keyholder. Mag lange Sperren und herausfordernde Aufgaben.', + 740, 0, 150, + 'ALLE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE'), + +-- 3. SamSwitcher – Switcher, in Beziehung +('11111111-1111-1111-1111-000000000003', + 'SamSwitcher', 'sam@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1988-11-03', + 175, 70, 'DIVERS', 'SWITCHER', 'IN_EINER_BEZIEHUNG', + 'Mal oben, mal unten – kommt auf die Stimmung an. Spiele gerne mit meinem Partner zusammen.', + 430, 390, 600, + 'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE', 'ALLE', 'NUR_FREUNDE', 'ALLE'), + +-- 4. KajaKette – eher devot, Single +('11111111-1111-1111-1111-000000000004', + 'KajaKette', 'kaja@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1998-02-14', + 170, 62, 'WEIBLICH', 'EHER_DEVOT', 'SINGLE', + 'Chastity-Enthusiastin mit Fokus auf Community-Locks. Schreibe gerne auf Pinnwände!', + 920, 50, 80, + 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'), + +-- 5. TomTop – eher dominant, verheiratet +('11111111-1111-1111-1111-000000000005', + 'TomTop', 'tom@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1985-07-30', + 178, 85, 'MAENNLICH', 'EHER_DOMINANT', 'VERHEIRATET', + 'Verheiratet, spielen als Paar. Biete Keyholder-Service für seriöse Anfragen.', + 200, 560, 410, + 'ALLE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'NUR_FREUNDE'); + +-- ============================================================= +-- NICHT AKTIVIERTE REGISTRIERUNG (für Registrierungs-Tests) +-- ============================================================= + +INSERT INTO registration ( + registration_id, name, email, password, activated, activation_code, geburtsdatum +) VALUES +('99999999-9999-9999-9999-000000000001', + 'NeuerUser', 'neu@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + FALSE, '347821', '2000-01-01'); + +-- ============================================================= +-- FREUNDSCHAFTEN +-- ============================================================= + +INSERT INTO friendship (friendship_id, sender_id, receiver_id, status, created_at) VALUES +-- Max ↔ Lisa (akzeptiert) +('22222222-2222-2222-2222-000000000001', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000002', + 'ACCEPTED', '2025-11-01 10:00:00'), +-- Max ↔ Sam (akzeptiert) +('22222222-2222-2222-2222-000000000002', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000003', + 'ACCEPTED', '2025-11-15 14:30:00'), +-- Lisa ↔ Kaja (akzeptiert) +('22222222-2222-2222-2222-000000000003', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000004', + 'ACCEPTED', '2025-12-03 09:15:00'), +-- Tom → Kaja (ausstehend) +('22222222-2222-2222-2222-000000000004', + '11111111-1111-1111-1111-000000000005', + '11111111-1111-1111-1111-000000000004', + 'PENDING', '2026-01-10 18:45:00'), +-- Sam ↔ Kaja (akzeptiert) +('22222222-2222-2222-2222-000000000005', + '11111111-1111-1111-1111-000000000003', + '11111111-1111-1111-1111-000000000004', + 'ACCEPTED', '2026-01-20 11:00:00'); + +-- ============================================================= +-- PINNWAND-EINTRÄGE +-- ============================================================= + +INSERT INTO pinnwand_eintrag (eintrag_id, profil_user_id, author_id, text, created_at) VALUES +-- Auf Lisas Pinnwand +('33333333-3333-3333-3333-000000000001', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000001', + 'Hey Lisa! Schön, dich hier zu sehen. Viel Spaß beim Spielen 🔒', + '2025-12-10 16:00:00'), +('33333333-3333-3333-3333-000000000002', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000004', + 'Wir sollten mal ein gemeinsames Lock starten! Meld dich 😊', + '2026-01-05 12:30:00'), +-- Auf Maxs Pinnwand +('33333333-3333-3333-3333-000000000003', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000002', + 'Danke für den tollen Keyholder-Service letzte Woche!', + '2026-01-08 20:00:00'), +-- Auf Kajas Pinnwand +('33333333-3333-3333-3333-000000000004', + '11111111-1111-1111-1111-000000000004', + '11111111-1111-1111-1111-000000000003', + 'Kaja, du bist die Community-Queen! Immer so aktiv hier.', + '2026-02-14 09:00:00'); + +-- Pinnwand-Likes +INSERT INTO pinnwand_like (like_id, eintrag_id, user_id, liked_at) VALUES +('33333333-3333-3333-3333-000000000101', + '33333333-3333-3333-3333-000000000001', + '11111111-1111-1111-1111-000000000002', + '2025-12-10 16:05:00'), +('33333333-3333-3333-3333-000000000102', + '33333333-3333-3333-3333-000000000002', + '11111111-1111-1111-1111-000000000001', + '2026-01-05 13:00:00'), +('33333333-3333-3333-3333-000000000103', + '33333333-3333-3333-3333-000000000003', + '11111111-1111-1111-1111-000000000004', + '2026-01-09 10:00:00'); + +-- ============================================================= +-- KOMMENTARE +-- ============================================================= + +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +-- Kommentar auf Pinnwand-Eintrag +('44444444-4444-4444-4444-000000000001', + '11111111-1111-1111-1111-000000000002', + 'PINNWAND', + '33333333-3333-3333-3333-000000000001', + 'Danke Max! Ich freu mich auch 😊', + '2025-12-10 17:00:00'), +('44444444-4444-4444-4444-000000000002', + '11111111-1111-1111-1111-000000000003', + 'PINNWAND', + '33333333-3333-3333-3333-000000000001', + '+1, willkommen in der Community!', + '2025-12-10 18:30:00'), +-- Reply auf Kommentar +('44444444-4444-4444-4444-000000000003', + '11111111-1111-1111-1111-000000000001', + 'KOMMENTAR', + '44444444-4444-4444-4444-000000000001', + 'Na logo! Wir machen das 😄', + '2025-12-10 17:15:00'); + +-- Kommentar-Likes +INSERT INTO kommentar_like (like_id, kommentar_id, user_id, liked_at) VALUES +('44444444-4444-4444-4444-000000000101', + '44444444-4444-4444-4444-000000000001', + '11111111-1111-1111-1111-000000000001', + '2025-12-10 17:10:00'), +('44444444-4444-4444-4444-000000000102', + '44444444-4444-4444-4444-000000000002', + '11111111-1111-1111-1111-000000000002', + '2025-12-10 19:00:00'); + +-- ============================================================= +-- FEED-POSTS (Text + Umfrage) +-- ============================================================= + +INSERT INTO feed_post (post_id, author_id, text, beitrag_typ, multi_choice, is_public, created_at) VALUES +-- Öffentlicher Text-Post von Max +('55555555-5555-5555-5555-000000000001', + '11111111-1111-1111-1111-000000000001', + 'Wer hat Lust auf ein Cardlock-Turnier nächsten Monat? Community vs. Keyholder! 🃏', + 'TEXT', NULL, TRUE, '2026-02-01 10:00:00'), + +-- Öffentlicher Text-Post von Lisa +('55555555-5555-5555-5555-000000000002', + '11111111-1111-1111-1111-000000000002', + '48 Stunden geschafft! Das war mein bisher längstes Lock. Ich bin so stolz auf mich! 🔐✨', + 'TEXT', NULL, TRUE, '2026-02-05 14:30:00'), + +-- Öffentliche Umfrage von Kaja (Single-Choice) +('55555555-5555-5555-5555-000000000003', + '11111111-1111-1111-1111-000000000004', + 'Was bevorzugt ihr: Cardlock oder Timelock?', + 'UMFRAGE', FALSE, TRUE, '2026-02-10 09:00:00'), + +-- Öffentliche Umfrage von Sam (Multi-Choice) +('55555555-5555-5555-5555-000000000004', + '11111111-1111-1111-1111-000000000003', + 'Welche Features wollt ihr als nächstes sehen? (Mehrfachauswahl möglich)', + 'UMFRAGE', TRUE, TRUE, '2026-02-15 20:00:00'), + +-- Nicht-öffentlicher Post von Tom +('55555555-5555-5555-5555-000000000005', + '11111111-1111-1111-1111-000000000005', + 'Spielen heute Abend mit meiner Frau eine Runde BDSM. Sie darf den Keyholder spielen!', + 'TEXT', NULL, FALSE, '2026-02-20 18:00:00'); + +-- Umfrage-Optionen +INSERT INTO feed_post_option (option_id, post_id, text, reihenfolge) VALUES +-- Kajas Umfrage +('55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', 'Cardlock – ich liebe die Ungewissheit!', 0), +('55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', 'Timelock – Struktur ist alles.', 1), +('55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', 'Beides gleich gerne.', 2), +-- Sams Umfrage +('55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', 'Mobile App', 0), +('55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', 'Mehr Aufgaben-Vorlagen', 1), +('55555555-5555-5555-5555-000000000106', '55555555-5555-5555-5555-000000000004', 'Dark/Light Theme Toggle', 2), +('55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', 'Push-Benachrichtigungen', 3); + +-- Umfrage-Stimmen +INSERT INTO feed_post_vote (stimme_id, option_id, post_id, user_id) VALUES +-- Kajas Umfrage +('55555555-5555-5555-5555-000000000201', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000202', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000203', '55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000005'), +('55555555-5555-5555-5555-000000000204', '55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000003'), +-- Sams Umfrage (Multi-Choice) +('55555555-5555-5555-5555-000000000205', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000206', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000207', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000208', '55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000209', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000004'); + +-- Feed-Likes +INSERT INTO feed_post_like (like_id, post_id, user_id, liked_at) VALUES +('55555555-5555-5555-5555-000000000301', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000002', '2026-02-01 10:30:00'), +('55555555-5555-5555-5555-000000000302', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000003', '2026-02-01 11:00:00'), +('55555555-5555-5555-5555-000000000303', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000004', '2026-02-01 11:15:00'), +('55555555-5555-5555-5555-000000000304', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000001', '2026-02-05 15:00:00'), +('55555555-5555-5555-5555-000000000305', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000004', '2026-02-05 15:30:00'), +('55555555-5555-5555-5555-000000000306', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000003', '2026-02-05 16:00:00'); + +-- Kommentare unter Feed-Posts +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +('66666666-6666-6666-6666-000000000001', + '11111111-1111-1111-1111-000000000002', + 'FEED_POST', + '55555555-5555-5555-5555-000000000001', + 'Bin dabei! Wann genau? 🙋‍♀️', + '2026-02-01 11:00:00'), +('66666666-6666-6666-6666-000000000002', + '11111111-1111-1111-1111-000000000003', + 'FEED_POST', + '55555555-5555-5555-5555-000000000001', + 'Klingt mega! Ich schlage vor: 1 Woche Mindestlaufzeit.', + '2026-02-01 11:30:00'), +('66666666-6666-6666-6666-000000000003', + '11111111-1111-1111-1111-000000000001', + 'FEED_POST', + '55555555-5555-5555-5555-000000000002', + 'Respekt! 48h ist eine echte Leistung 👏', + '2026-02-05 15:00:00'); + +-- ============================================================= +-- GRUPPEN +-- ============================================================= + +INSERT INTO gruppe (gruppe_id, name, beschreibung, bild, is_private, created_at, created_by_user_id) VALUES +-- Öffentliche Gruppe +('77777777-7777-7777-7777-000000000001', + 'Cardlock Community', + 'Die Gruppe für alle Cardlock-Fans! Hier tauschen wir Erfahrungen aus, veranstalten Turniere und helfen Neulingen beim Einstieg.', + NULL, FALSE, '2025-10-01 12:00:00', + '11111111-1111-1111-1111-000000000001'), + +-- Private Gruppe +('77777777-7777-7777-7777-000000000002', + 'Keyholder-Stammtisch', + 'Privater Austausch unter erfahrenen Keyholdern. Nur auf Einladung.', + NULL, TRUE, '2025-11-15 18:00:00', + '11111111-1111-1111-1111-000000000005'), + +-- Öffentliche Gruppe +('77777777-7777-7777-7777-000000000003', + 'Anfänger & Fragen', + 'Neuling? Frag einfach! Hier ist jede Frage willkommen. Keine Scheu.', + NULL, FALSE, '2026-01-01 00:00:00', + '11111111-1111-1111-1111-000000000004'); + +-- ============================================================= +-- GRUPPENMITGLIEDER +-- ============================================================= + +INSERT INTO gruppe_mitglied (mitglied_id, gruppe_id, user_id, rolle, joined_at) VALUES +-- Cardlock Community +('77777777-7777-7777-7777-000000000101', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000001', 'ADMIN', '2025-10-01 12:00:00'), +('77777777-7777-7777-7777-000000000102', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2025-10-05 09:00:00'), +('77777777-7777-7777-7777-000000000103', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2025-10-10 14:00:00'), +('77777777-7777-7777-7777-000000000104', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000004', 'MITGLIED', '2025-10-20 11:00:00'), +-- Keyholder-Stammtisch +('77777777-7777-7777-7777-000000000105', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000005', 'ADMIN', '2025-11-15 18:00:00'), +('77777777-7777-7777-7777-000000000106', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000001', 'MITGLIED', '2025-11-20 10:00:00'), +-- Anfänger & Fragen +('77777777-7777-7777-7777-000000000107', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000004', 'ADMIN', '2026-01-01 00:00:00'), +('77777777-7777-7777-7777-000000000108', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2026-01-03 08:00:00'), +('77777777-7777-7777-7777-000000000109', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2026-01-05 12:00:00'); + +-- Ausstehende Beitrittsanfrage zur privaten Gruppe +INSERT INTO beitrittsanfrage (anfrage_id, gruppe_id, user_id, nachricht, angefragt_at, status) VALUES +('77777777-7777-7777-7777-000000000201', + '77777777-7777-7777-7777-000000000002', + '11111111-1111-1111-1111-000000000003', + 'Hallo! Ich bin seit 2 Jahren aktiver Keyholder und würde gerne dazugehören.', + '2026-02-01 15:00:00', 'AUSSTEHEND'), +('77777777-7777-7777-7777-000000000202', + '77777777-7777-7777-7777-000000000002', + '11111111-1111-1111-1111-000000000004', + 'Bitte nehmt mich auf! Habe schon ein paar Monate Erfahrung als Keyholderin.', + '2026-02-10 09:00:00', 'ABGELEHNT'); + +-- ============================================================= +-- GRUPPEN-BEITRÄGE (Text + Umfrage) +-- ============================================================= + +INSERT INTO gruppe_beitrag (beitrag_id, gruppe_id, author_id, beitrag_typ, text, multi_choice, bild, created_at) VALUES +-- Cardlock Community +('88888888-8888-8888-8888-000000000001', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000001', + 'TEXT', + 'Willkommen in der Cardlock Community! Stellt euch kurz vor und erzählt, wie ihr zum Cardlock gekommen seid.', + NULL, NULL, '2025-10-01 12:05:00'), + +('88888888-8888-8888-8888-000000000002', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000002', + 'TEXT', + 'Ich bin Lisa und liebe Cardlocks seit über einem Jahr! Mein Rekord sind 5 Tage – habt ihr Tipps für längere Sperren?', + NULL, NULL, '2025-10-05 10:00:00'), + +('88888888-8888-8888-8888-000000000003', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000004', + 'UMFRAGE', + 'Wie viele Karten startet ihr typischerweise mit?', + FALSE, NULL, '2025-10-20 14:00:00'), + +-- Anfänger & Fragen +('88888888-8888-8888-8888-000000000004', + '77777777-7777-7777-7777-000000000003', + '11111111-1111-1111-1111-000000000002', + 'TEXT', + 'Frage: Wie erkläre ich Cardlocks am besten meinem Partner, der noch nie davon gehört hat?', + NULL, NULL, '2026-01-10 19:00:00'), + +('88888888-8888-8888-8888-000000000005', + '77777777-7777-7777-7777-000000000003', + '11111111-1111-1111-1111-000000000001', + 'TEXT', + 'Gute Frage! Ich würde empfehlen, erst mit einem kurzen Timelock anzufangen. So kann der Partner das Grundkonzept verstehen, ohne direkt mit der Karten-Mechanik überfordert zu werden.', + NULL, NULL, '2026-01-10 19:30:00'); + +-- Umfrage-Optionen für Gruppen-Beitrag +INSERT INTO umfrage_option (option_id, beitrag_id, text, reihenfolge) VALUES +('88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', 'Unter 20 Karten', 0), +('88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '20–40 Karten', 1), +('88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '40–60 Karten', 2), +('88888888-8888-8888-8888-000000000104', '88888888-8888-8888-8888-000000000003', 'Über 60 Karten', 3); + +-- Umfrage-Stimmen (Gruppen) +INSERT INTO umfrage_stimme (stimme_id, option_id, beitrag_id, user_id) VALUES +('88888888-8888-8888-8888-000000000201', '88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000002'), +('88888888-8888-8888-8888-000000000202', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000001'), +('88888888-8888-8888-8888-000000000203', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000003'), +('88888888-8888-8888-8888-000000000204', '88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000004'); + +-- Gruppen-Beitrag-Likes +INSERT INTO gruppe_beitrag_like (like_id, beitrag_id, user_id, liked_at) VALUES +('88888888-8888-8888-8888-000000000301', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000002', '2025-10-01 12:10:00'), +('88888888-8888-8888-8888-000000000302', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000003', '2025-10-01 13:00:00'), +('88888888-8888-8888-8888-000000000303', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000004', '2025-10-01 14:00:00'), +('88888888-8888-8888-8888-000000000304', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000001', '2025-10-05 10:30:00'), +('88888888-8888-8888-8888-000000000305', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000004', '2025-10-05 11:00:00'), +('88888888-8888-8888-8888-000000000306', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000002', '2026-01-10 19:45:00'), +('88888888-8888-8888-8888-000000000307', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000004', '2026-01-10 20:00:00'); + +-- Kommentare auf Gruppen-Beiträge +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +('99999999-0000-0000-0000-000000000001', + '11111111-1111-1111-1111-000000000003', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000002', + 'Hi Lisa! Mein Tipp: Fang mit mehr Green Cards an als du denkst. Du wirst es brauchen 😄', + '2025-10-05 11:00:00'), +('99999999-0000-0000-0000-000000000002', + '11111111-1111-1111-1111-000000000001', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000002', + 'Mentale Vorbereitung ist alles. Schreib dir vorher auf, warum du es tust.', + '2025-10-05 12:00:00'), +('99999999-0000-0000-0000-000000000003', + '11111111-1111-1111-1111-000000000004', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000004', + 'Ich würde sagen: zeig ihm/ihr einfach die App! Das visuelle Konzept erklärt sich fast von selbst.', + '2026-01-10 19:15:00'); + +-- ============================================================= +-- CHASTITY LOCK (ein aktives Cardlock: Lisa gesperrt von Max) +-- ============================================================= + +INSERT INTO current_lock ( + lock_id, lock_type, name, lockee, keyholder, + test_lock, requires_verification, + unlock_code_length, unlock_code, + start_time, unlock_time, + hygine_opening_duration_minutes, hygine_opening_everyminites, + task_mode, + keyholder_requested_unlock, emergency_auto_unlocked, + -- CARDLOCK-spezifisch + initial_cards, pick_every_minute, accumulate_picks, + show_remaining_cards, open_picks, + available_cards +) VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', + 'CARDLOCK', + 'Lisas Frühlings-Lock', + '11111111-1111-1111-1111-000000000002', -- lockee: Lisa + '11111111-1111-1111-1111-000000000001', -- keyholder: Max + FALSE, FALSE, + 6, NULL, + '2026-03-20 10:00:00', NULL, + 30, 1440, -- Hygiene alle 24h, 30 Min offen + 'KEYHOLDER', + FALSE, FALSE, + -- 30 Karten: 5×GREEN, 15×RED, 5×YELLOW, 3×TASK, 2×FREEZE + '["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]', + 240, FALSE, -- Karte alle 4h ziehen, kein Akkumulieren + TRUE, 0, + '["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]' +); + +-- ============================================================= +-- FERTIG +-- ============================================================= +-- Überblick: +-- 5 User (max@test.de, lisa@test.de, sam@test.de, kaja@test.de, tom@test.de) +-- 1 nicht aktivierte Registrierung (neu@test.de, Code: 347821) +-- 5 Freundschaften (4 akzeptiert, 1 ausstehend) +-- 4 Pinnwand-Einträge + 3 Likes +-- 3 Kommentare auf Pinnwand + 3 auf Feed + 3 auf Gruppen-Beiträge +-- 5 Feed-Posts (3 Text, 2 Umfragen) + 6 Likes +-- 3 Gruppen (2 öffentlich, 1 privat) mit je 4-6 Mitgliedern +-- 5 Gruppen-Beiträge (4 Text, 1 Umfrage) + 7 Likes +-- 1 aktives Cardlock (Lisa ← Max) +-- ============================================================= diff --git a/bin/main/static/activate.html b/bin/main/static/activate.html new file mode 100644 index 0000000..0fa0d01 --- /dev/null +++ b/bin/main/static/activate.html @@ -0,0 +1,94 @@ + + + + + + + Aktivierung – xXx Sphere + + + + +
+

XXX The Game

+

E-Mail-Adresse bestätigen

+ +

+ Du hast eine E-Mail mit einem Aktivierungslink erhalten.
+ Falls der Link nicht funktioniert, gib hier deinen Aktivierungscode ein. +

+ + + + + + +
+
+ + + + diff --git a/bin/main/static/admin/admin.html b/bin/main/static/admin/admin.html new file mode 100644 index 0000000..9cc1860 --- /dev/null +++ b/bin/main/static/admin/admin.html @@ -0,0 +1,2604 @@ + + + + + + + Administration – xXx Sphere + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + +
+ + +
+
+ + +
+
+ + + + + + + + + + +
MelderTypZiel-IDGrundGemeldetStatus
Wird geladen…
+
+
+ + +
+
+
+

📬 Ungelesen

+
+ +
+
+
Wird geladen…
+
+
+
+

🔧 In Arbeit

+
+
Wird geladen…
+
+
+
+

✅ Beantwortet

+
+
Wird geladen…
+
+
+ + + + + +
+
+
+

System-Aufgabengruppen

+
+ + + + + + + +
+
+
+
Wird geladen…
+
+
+
+ + +
+
+
+

System-Toys

+
+ + + + + + + + +
+
+
+
+ +
+
+ + +
+
+
+

Vorlieben

+
+ + + + +
+
+
+ + + + + +

Wird geladen…

+
+
+ + +
+
+

Admin hinzufügen

+
+ + +
+ +
+
+
+ + + + + + + +
BenutzernameRolleSeit
Wird geladen…
+
+
+ + +
+ + +
+
+

Aktive Abonnements

+ +
+
+ + + + + + + + + + + + +
BenutzerTypGestartetGültig bis
Laden…
+
+
+ + +
+
+

Abonnement verschenken

+
+
+

+ Suche einen Benutzer und schenke ihm 1 Monat Premium. Hat der Benutzer bereits ein + aktives Abo, wird die Laufzeit um 1 Monat verlängert. +

+ + +
+ + + +
+ + + + +
+ +
+ +
+
+
+
+ + +
+
+
+

TTLock-Konfiguration

+
+
+

API-Zugangsdaten

+ + + + + + +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + diff --git a/bin/main/static/audio/alarm.mp3 b/bin/main/static/audio/alarm.mp3 new file mode 100644 index 0000000..b183e18 Binary files /dev/null and b/bin/main/static/audio/alarm.mp3 differ diff --git a/bin/main/static/audio/lvlup.mp3 b/bin/main/static/audio/lvlup.mp3 new file mode 100644 index 0000000..de69f88 Binary files /dev/null and b/bin/main/static/audio/lvlup.mp3 differ diff --git a/bin/main/static/audio/message.mp3 b/bin/main/static/audio/message.mp3 new file mode 100644 index 0000000..263d334 Binary files /dev/null and b/bin/main/static/audio/message.mp3 differ diff --git a/bin/main/static/audio/notification.mp3 b/bin/main/static/audio/notification.mp3 new file mode 100644 index 0000000..0b94f07 Binary files /dev/null and b/bin/main/static/audio/notification.mp3 differ diff --git a/bin/main/static/audio/ping.mp3 b/bin/main/static/audio/ping.mp3 new file mode 100644 index 0000000..8c765d0 Binary files /dev/null and b/bin/main/static/audio/ping.mp3 differ diff --git a/bin/main/static/audio/release.mp3 b/bin/main/static/audio/release.mp3 new file mode 100644 index 0000000..d846cb8 Binary files /dev/null and b/bin/main/static/audio/release.mp3 differ diff --git a/bin/main/static/community/abonnements.html b/bin/main/static/community/abonnements.html new file mode 100644 index 0000000..6e0c8cf --- /dev/null +++ b/bin/main/static/community/abonnements.html @@ -0,0 +1,38 @@ + + + + + + + Abonnements – xXx Sphere + + + + + +
+
+

⭐ Abonnements

+

Übersicht der verfügbaren Abo-Modelle

+ +
+ 🚧 +

Demnächst verfügbar

+

Hier werden bald die verschiedenen Abo-Modelle beschrieben und abschließbar sein.

+
+
+
+ + + + + diff --git a/bin/main/static/community/benachrichtigungen.html b/bin/main/static/community/benachrichtigungen.html new file mode 100644 index 0000000..7dcdd70 --- /dev/null +++ b/bin/main/static/community/benachrichtigungen.html @@ -0,0 +1,252 @@ + + + + + + + Benachrichtigungen – xXx Sphere + + + + + +
+
+
+

🔔 Benachrichtigungen

+ +
+ +
+ +
+
+
+ + + + + + + diff --git a/bin/main/static/community/benutzer.html b/bin/main/static/community/benutzer.html new file mode 100644 index 0000000..004ca70 --- /dev/null +++ b/bin/main/static/community/benutzer.html @@ -0,0 +1,1410 @@ + + + + + + + Profil – xXx Sphere + + + + + + +
+
+ +

Wird geladen…

+ + +
+
+ + + + + + + + + + + + + diff --git a/bin/main/static/community/feed.html b/bin/main/static/community/feed.html new file mode 100644 index 0000000..2713d1a --- /dev/null +++ b/bin/main/static/community/feed.html @@ -0,0 +1,541 @@ + + + + + + + Feed – xXx Sphere + + + + + +
+
+ +
+ + +
+ + +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + + + + + + + + + diff --git a/bin/main/static/community/freunde.html b/bin/main/static/community/freunde.html new file mode 100644 index 0000000..f2bbd71 --- /dev/null +++ b/bin/main/static/community/freunde.html @@ -0,0 +1,351 @@ + + + + + + + Freunde – xXx Sphere + + + + + + +
+
+

Freunde

+ +
+ + +
+ + +
+
    + +
    + + +
    +
      + +
      +
      +
      + + +
      +
      +

      Freundschaft beenden

      +

      Möchtest du diese Freundschaft wirklich beenden?

      +
      + + +
      +
      +
      + + + + + + + diff --git a/bin/main/static/community/gruppe.html b/bin/main/static/community/gruppe.html new file mode 100644 index 0000000..7e7fcec --- /dev/null +++ b/bin/main/static/community/gruppe.html @@ -0,0 +1,1097 @@ + + + + + + + Gruppe – xXx Sphere + + + + + +
      +
      + +
      +
      👥
      +
      +

      Wird geladen…

      +

      +
      +
      +
      + +
      + + + +
      + + +
      + + +
      + +
      + +
      +
      + + +
      +
        +
        + + +
        +
        +

        Gruppe bearbeiten

        +
        + + + + + + + + +
        + + +
        + + +
        +
        + +
        +

        Beitrittsanfragen

        +
        +

        Keine ausstehenden Anfragen.

        +
        +
        + +
        +

        Gemeldete Beiträge

        +
        +

        Keine Meldungen.

        +
        +
        + +
        +

        Gruppe löschen

        + +
        +
        +
        +
        + + +
        +
        +

        Gruppe verlassen

        +

        Möchtest du diese Gruppe wirklich verlassen?

        +
        + + +
        +
        +
        + + +
        +
        +

        Gruppe löschen

        +

        Diese Aktion kann nicht rückgängig gemacht werden. Alle Beiträge und Mitgliedschaften werden gelöscht.

        +
        + + +
        +
        +
        + + +
        +
        +

        +

        +
        +
        +
        + + + + + + + + + + + diff --git a/bin/main/static/community/gruppen.html b/bin/main/static/community/gruppen.html new file mode 100644 index 0000000..cf30f28 --- /dev/null +++ b/bin/main/static/community/gruppen.html @@ -0,0 +1,411 @@ + + + + + + + Gruppen – xXx Sphere + + + + + +
        +
        +
        +

        Gruppen

        + +
        + +
        + + + +
        + + +
        +
        + +
        + + +
        +
        + + +
        +
        +

        Gib einen Suchbegriff ein.

        +
        + + +
        +
          + +
          +
          +
          + + +
          +
          +

          Gruppe erstellen

          + + + + + + + + +
          + + +
          + +
          + + +
          +
          +
          + + +
          +
          +

          Beitrittsanfrage senden

          +

          + + + +
          + + +
          +
          +
          + + + + + + + diff --git a/bin/main/static/community/nachrichten.html b/bin/main/static/community/nachrichten.html new file mode 100644 index 0000000..4f5f054 --- /dev/null +++ b/bin/main/static/community/nachrichten.html @@ -0,0 +1,671 @@ + + + + + + + Nachrichten – xXx Sphere + + + + + + +
          +
          + +
          +
          Nachrichten
          +
            +
          • Wird geladen…
          • +
          +
          + + +
          +
          + + + Konversation auswählen +
          +
          +
          Wähle eine Konversation aus oder schreibe jemanden direkt an.
          +
          + +
          +
          +
          + + + + + + + + + diff --git a/bin/main/static/community/personen-suchen.html b/bin/main/static/community/personen-suchen.html new file mode 100644 index 0000000..e18b89e --- /dev/null +++ b/bin/main/static/community/personen-suchen.html @@ -0,0 +1,206 @@ + + + + + + + Personen suchen – xXx Sphere + + + + + + +
          +
          +

          Personen suchen

          + + + +
            +

            Gib mindestens 2 Zeichen ein, um zu suchen.

            +
            +
            + + + + + + + diff --git a/bin/main/static/css/style.css b/bin/main/static/css/style.css new file mode 100644 index 0000000..d968589 --- /dev/null +++ b/bin/main/static/css/style.css @@ -0,0 +1,967 @@ +/* ── Reset ── */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Base ── */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg); + font-family: 'Segoe UI', sans-serif; + color: var(--color-text); + gap: 2rem; +} + +h1 { + color: var(--color-primary); +} + +p { + color: var(--color-muted); + font-size: 1rem; +} + +/* ── Card ── */ +.card { + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 380px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + gap: 0; +} + +.card h1 { + text-align: center; + font-size: 1.6rem; + margin-bottom: 0.25rem; +} + +.subtitle { + text-align: center; + font-size: 0.85rem; + color: var(--color-muted); + margin-bottom: 2rem; +} + +/* ── Form elements ── */ +label { + display: block; + font-size: 0.8rem; + color: #aaa; + margin-bottom: 0.3rem; + margin-top: 1rem; +} + +input { + width: 100%; + padding: 0.65rem 0.9rem; + border: 1px solid var(--color-secondary); + border-radius: 6px; + background: var(--color-secondary); + color: var(--color-text); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +input:focus { + border-color: var(--color-primary); +} + +/* ── Buttons ── */ +button, .btn { + display: inline-block; + padding: 0.75rem 2.5rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.2s; +} + +button:hover:not(:disabled), .btn:hover { + background: #c73652; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button.full-width { + width: 100%; + margin-top: 1.75rem; + padding: 0.75rem; +} + +button.secondary { + background: var(--color-secondary); + font-weight: normal; + padding: 0.3rem 0.7rem; + font-size: 0.75rem; + width: auto; + margin-top: 0.5rem; +} + +button.secondary:hover { + background: #1a4a8a; +} + +/* ── Messages ── */ +.message { + margin-top: 1.25rem; + padding: 0.65rem 0.9rem; + border-radius: 6px; + font-size: 0.85rem; + display: none; + word-break: break-all; +} + +.message.error { + background: #3d0f1a; + border: 2px solid var(--color-primary); + color: var(--color-primary); +} + +.message.warning { + background: #3a2c0a; + border: 2px solid #f5c518; + color: #f5c518; +} + +.message.success { + background: #0f3d1a; + border: 1px solid var(--color-success); + color: var(--color-success); +} + +/* ── App layout ── */ +body.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--color-bg); + padding: 1.5rem 1.5rem 0; + gap: 0; +} + +.app-wrapper { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + gap: 1.5rem; + align-items: stretch; + padding-bottom: 1.5rem; + width: 100%; + max-width: calc(240px + 1.5rem + 93.75rem); + margin-left: auto; + margin-right: auto; +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; +} + +.content { padding: 2rem 1.5rem; flex: 1; } + +/* ── Sidebar ── */ +.sidebar-wrapper { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 0.75rem; + z-index: 10; + transition: transform 0.25s ease; +} + +.sidebar { + flex: 1; + min-height: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-scroll-area { + flex: 1; + overflow-y: auto; +} + +.sidebar-logo-area { + padding: 1rem 1rem 0.5rem; + flex-shrink: 0; +} + +.sidebar-logo-area a { display: block; line-height: 0; } + +.sidebar-logo-area img { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.sidebar-desktop-profile { flex-shrink: 0; padding: 0.25rem 0; } + +.sidebar-desktop-profile a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.9rem; + font-weight: 600; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.sidebar-desktop-profile a:hover { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.sidebar-desktop-profile .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.social-sidebar-logo-area { + padding: 1rem 1rem 0.5rem; + flex-shrink: 0; +} + +.social-sidebar-logo-area img { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.sidebar-mobile-only { display: none; } + +.sidebar ul { list-style: none; padding: 0.5rem 0; } + +.sidebar ul li a, +.sidebar-footer ul li a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.95rem; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.sidebar ul li a:hover, +.sidebar ul li a.active, +.sidebar-footer ul li a:hover, +.sidebar-footer ul li a.active { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.sidebar ul li a .icon, +.sidebar-footer ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.sidebar-profile-img { + width: 1.4rem; + height: 1.4rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--color-secondary); +} + +/* ── Burger (mobile only) ── */ +.burger { + display: none; + position: fixed; + top: 0.75rem; right: 0.75rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 6px; + cursor: pointer; + color: var(--color-text); + padding: 0.35rem 0.5rem; + z-index: 110; + transition: background 0.15s; +} + +.burger:hover { background: var(--color-secondary); } + +.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; } + +.burger-icon span { + display: block; + height: 2px; + background: var(--color-text); + border-radius: 2px; + transition: transform 0.25s, opacity 0.25s; +} + +.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); } +.burger.open .burger-icon span:nth-child(2) { opacity: 0; } +.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } + +/* ── Sidebar overlay ── */ +.sidebar-overlay { + display: none; + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 90; +} + +.sidebar-overlay.visible { display: block; } + +/* ── Mobile ── */ +@media (max-width: 768px) { + body.app { + height: auto; + min-height: 100vh; + overflow: visible; + padding: 0; + } + + .app-wrapper { + flex-direction: column; + gap: 0; + padding-bottom: 0; + overflow: visible; + } + + .sidebar-wrapper { + position: fixed; + top: 0; right: 0; + width: 240px; + height: 100vh; + gap: 0; + background: var(--color-bg); + border-left: 1px solid var(--color-secondary); + transform: translateX(100%); + align-self: auto; + z-index: 100; + padding: 0; + overflow-y: auto; + } + + .sidebar-wrapper.open { transform: translateX(0); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); } + + .sidebar { + flex: none; + border-radius: 0; + border: none; + box-shadow: none; + border-bottom: 1px solid var(--color-secondary); + } + + .sidebar-footer { + border-radius: 0; + box-shadow: none; + border: none; + border-top: 1px solid var(--color-secondary); + } + + .sidebar-logo-area { display: none; } + .sidebar-desktop-profile { display: none; } + + .main { + border-radius: 0; + box-shadow: none; + border: none; + min-height: 100vh; + overflow-y: visible; + } + + .burger { display: flex; } + .sidebar-mobile-only { display: block; } +} + +/* ── Social Sidebar ── */ +.social-sidebar { + width: 260px; + flex-shrink: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-self: flex-start; + position: sticky; + top: 1.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; +} + +.social-sidebar ul { list-style: none; padding: 0.5rem 0; display: flex; flex-direction: column; flex: 1; } + +.social-sidebar ul li a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.95rem; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.social-sidebar ul li a:hover, +.social-sidebar ul li a.active { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.social-sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.social-sidebar-title { + padding: 1rem 1.25rem 0.25rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--color-muted); + text-transform: uppercase; + flex-shrink: 0; +} + +.social-badge { + margin-left: auto; + background: var(--color-primary); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.1rem 0.4rem; + line-height: 1.4; + flex-shrink: 0; +} + +@media (max-width: 768px) { + .social-sidebar { + position: static; + width: 100%; + max-height: none; + border-radius: 0; + border: none; + border-top: 1px solid var(--color-secondary); + box-shadow: none; + align-self: auto; + } +} + +/* ── Token box ── */ +.token-box { + margin-top: 1.25rem; + padding: 0.65rem 0.9rem; + background: #0f1e3d; + border: 1px solid var(--color-secondary); + border-radius: 6px; + font-size: 0.75rem; + color: #aaa; + word-break: break-all; + display: none; +} + +.token-box span { + display: block; + font-size: 0.7rem; + color: #666; + margin-bottom: 0.4rem; +} + +/* ── Sidebar groups ── */ +.sidebar-footer { + flex-shrink: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + padding: 0.5rem 0; +} +.sidebar-footer ul { + list-style: none; + padding: 0; +} + +.sidebar-group-toggle { + cursor: pointer; + justify-content: space-between; +} + +.sidebar-arrow { + margin-left: auto; + font-size: 0.7rem; + flex-shrink: 0; + transition: transform 0.2s; +} + +.sidebar-group.open > a .sidebar-arrow { + transform: rotate(90deg); +} + +.sidebar-sub { + list-style: none; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.sidebar-group.open > .sidebar-sub { + max-height: 500px; +} + +.sidebar .sidebar-sub li a { + padding: 0.55rem 1.25rem 0.55rem 2.5rem; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* ═══════════════════════════════════════════ + TOP BAR + ═══════════════════════════════════════════ */ +.topbar { + flex-shrink: 0; + width: 100%; + max-width: calc(240px + 1.5rem + 93.75rem); + margin: 0 auto 1rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1rem; +} + +/* Linker Bereich – Banner, gleiche Breite wie Sidebar */ +.topbar-left { + width: 240px; + flex-shrink: 0; + align-self: stretch; + margin: -0.55rem 0 -0.55rem -1rem; + overflow: hidden; + border-radius: 11px 0 0 11px; + display: flex; + align-items: center; + padding: 5px 0 0 5px; + justify-content: center; +} +.topbar-left a { + display: flex; + align-items: center; + height: 100%; +} +.topbar-banner { + height: 3.5rem; + width: auto; + display: block; +} + +/* ── Suche ── */ +.topbar-search-wrap { + flex: 1; + max-width: 520px; + margin: 0 auto; + position: relative; +} + +.topbar-search-wrap input { + background: var(--color-secondary); + border: 1px solid transparent; + border-radius: 8px; + padding: 0.46rem 0.9rem 0.46rem 2.2rem; + width: 100%; + font-size: 0.9rem; + transition: border-color 0.2s; +} + +.topbar-search-wrap input:focus { + border-color: var(--color-primary); +} + +.topbar-search-icon { + position: absolute; + left: 0.7rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-muted); + font-size: 0.85rem; + pointer-events: none; + line-height: 1; +} + +.topbar-search-overlay { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 600; + max-height: 360px; + overflow-y: auto; + display: none; +} + +.topbar-search-overlay.open { display: block; } + +.topbar-search-hint { + padding: 0.75rem 1rem; + color: var(--color-muted); + font-size: 0.88rem; +} + +.topbar-search-result { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + text-decoration: none; + color: var(--color-text); + transition: background 0.15s; + border-bottom: 1px solid var(--color-secondary); +} + +.topbar-search-result:last-child { border-bottom: none; } +.topbar-search-result:hover { background: var(--color-secondary); } + +.topbar-search-avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.topbar-search-avatar--placeholder { + background: var(--color-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +/* ── Rechter Bereich ── */ +.topbar-right { + display: flex; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; + margin-left: auto; +} + +.topbar-btn { + position: relative; + background: none; + border: none; + padding: 0.4rem 0.55rem; + border-radius: 8px; + cursor: pointer; + font-size: 1.25rem; + color: var(--color-text); + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s; + width: auto; + font-weight: normal; + line-height: 1; +} + +.topbar-btn:hover { background: var(--color-secondary); } + +.topbar-badge { + position: absolute; + top: 1px; + right: 1px; + background: var(--color-primary); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.05rem 0.3rem; + min-width: 1rem; + text-align: center; + line-height: 1.6; + display: none; + pointer-events: none; +} + +.topbar-avatar { + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--color-secondary); + display: block; +} + +.topbar-avatar-placeholder { + font-size: 1.2rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.topbar-username { + font-size: 0.88rem; + font-weight: 600; + max-width: 130px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topbar-profile-btn { gap: 0.5rem; padding: 0.3rem 0.55rem; } + +/* ═══════════════════════════════════════════ + TOPBAR PANELS (Overlays) + ═══════════════════════════════════════════ */ +.topbar-panel { + position: fixed; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65); + z-index: 550; + width: 360px; + max-height: 500px; + display: none; + flex-direction: column; + overflow: hidden; +} + +.topbar-panel.open { display: flex; } + +.topbar-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.7rem 1rem; + font-weight: 700; + font-size: 0.92rem; + border-bottom: 1px solid var(--color-secondary); + flex-shrink: 0; + background: var(--color-card); +} + +.topbar-panel-close { + background: none; + border: none; + color: var(--color-muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + width: auto; + line-height: 1; + transition: background 0.15s, color 0.15s; +} + +.topbar-panel-close:hover { background: var(--color-secondary); color: var(--color-text); } + +.topbar-panel-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.topbar-panel-footer { + border-top: 1px solid var(--color-secondary); + padding: 0.55rem 1rem; + flex-shrink: 0; + text-align: center; + background: var(--color-card); +} + +.topbar-panel-footer a { + color: var(--color-primary); + font-size: 0.85rem; + text-decoration: none; +} + +.topbar-panel-footer a:hover { text-decoration: underline; } + +.topbar-panel-hint { + padding: 0.9rem 1rem; + color: var(--color-muted); + font-size: 0.88rem; +} + +/* Einzel-Eintrag in Panel */ +.topbar-panel-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--color-secondary); + text-decoration: none; + color: var(--color-text); + transition: background 0.15s; + cursor: pointer; +} + +.topbar-panel-item:last-child { border-bottom: none; } +.topbar-panel-item:hover { background: var(--color-secondary); } + +.topbar-item-avatar { + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.topbar-item-avatar--placeholder { + background: var(--color-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.topbar-panel-item-body { + flex: 1; + min-width: 0; +} + +.topbar-panel-item-sub { + font-size: 0.75rem; + color: var(--color-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.1rem; +} + +.topbar-item-badge { + background: var(--color-primary); + color: #fff; + font-size: 0.65rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.1rem 0.4rem; + flex-shrink: 0; +} + +/* Benachrichtigungen */ +.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.07); border-left: 3px solid var(--color-primary); } +.topbar-notif-item--unread:hover { background: rgba(var(--color-primary-rgb, 231,57,84), 0.12); } +.topbar-notif-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-primary); + flex-shrink: 0; + margin-top: 0.25rem; + align-self: flex-start; +} + +.topbar-mark-all-btn { + background: none; + border: none; + color: var(--color-primary); + font-size: 0.8rem; + cursor: pointer; + width: auto; + padding: 0.2rem 0.4rem; + border-radius: 4px; + transition: background 0.15s; +} + +.topbar-mark-all-btn:hover { background: var(--color-secondary); } + +/* Einladungen */ +.topbar-inv-card { align-items: center; } + +.topbar-inv-btn { + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + border-radius: 6px; + border: none; + cursor: pointer; + width: auto; + margin: 0; + text-decoration: none; + display: inline-block; + font-weight: 600; + line-height: 1.5; + transition: opacity 0.15s; +} + +.topbar-inv-btn:hover { opacity: 0.85; } +.topbar-inv-btn--decline { background: #c0392b; color: #fff; } +.topbar-inv-btn--accept { background: var(--color-success, #27ae60); color: #fff; } + +/* Profil-Panel */ +.topbar-profile-body { display: flex; flex-direction: column; } + +.topbar-profile-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 1rem 1rem 0.75rem; +} + +.topbar-profile-nav { + display: flex; + flex-direction: column; + padding: 0.4rem 0; +} + +.topbar-profile-link { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 1rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s; +} + +.topbar-profile-link:hover { background: var(--color-secondary); } +.topbar-profile-link--danger { color: var(--color-primary); } + +/* ── Mobile: Topbar ausblenden ── */ +@media (max-width: 768px) { + .topbar { display: none; } +} diff --git a/bin/main/static/forgot-password.html b/bin/main/static/forgot-password.html new file mode 100644 index 0000000..d833d9f --- /dev/null +++ b/bin/main/static/forgot-password.html @@ -0,0 +1,113 @@ + + + + + + + Passwort vergessen – xXx Sphere + + + + + +
            + Logo +

            Passwort vergessen

            +

            Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, erhältst du einen Link zum Zurücksetzen.

            + + + + + + +
            + +

            + Zurück zum Login +

            +
            + +
            + +
            + + + + diff --git a/bin/main/static/games/bdsm/bdsm-einladung.html b/bin/main/static/games/bdsm/bdsm-einladung.html new file mode 100644 index 0000000..f181f06 --- /dev/null +++ b/bin/main/static/games/bdsm/bdsm-einladung.html @@ -0,0 +1,128 @@ + + + + + + + BDSM Game – Einladung – xXx Sphere + + + + + +
            +
            +
            Einladung wird geladen…
            + +
            +
            + + + + + diff --git a/bin/main/static/games/bdsm/bdsmingame.html b/bin/main/static/games/bdsm/bdsmingame.html new file mode 100644 index 0000000..be6866e --- /dev/null +++ b/bin/main/static/games/bdsm/bdsmingame.html @@ -0,0 +1,1295 @@ + + + + + + + BDSM Game – Im Spiel – xXx Sphere + + + + + + + + + +
            +
            + + + + + +
            +
            Aufgabe wird geladen…
            + +
            + + +
            + + +
            +
            Wird geladen…
            +
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/games/bdsm/bdsmplayers.html b/bin/main/static/games/bdsm/bdsmplayers.html new file mode 100644 index 0000000..72717e6 --- /dev/null +++ b/bin/main/static/games/bdsm/bdsmplayers.html @@ -0,0 +1,11 @@ + + + + + + BDSM Game – xXx Sphere + + + + + diff --git a/bin/main/static/games/bdsm/infobdsm.html b/bin/main/static/games/bdsm/infobdsm.html new file mode 100644 index 0000000..3b0bcf4 --- /dev/null +++ b/bin/main/static/games/bdsm/infobdsm.html @@ -0,0 +1,21 @@ + + + + + + + BDSM Game – Info – xXx Sphere + + + + +
            +
            +

            BDSM Game

            +

            Informationen zum BDSM Game folgen hier.

            +
            +
            + + + + diff --git a/bin/main/static/games/bdsm/neubdsm.html b/bin/main/static/games/bdsm/neubdsm.html new file mode 100644 index 0000000..c30fed5 --- /dev/null +++ b/bin/main/static/games/bdsm/neubdsm.html @@ -0,0 +1,1485 @@ + + + + + + + BDSM Game – Session einrichten – xXx Sphere + + + + + + + + + + + + + + + + + + + diff --git a/bin/main/static/games/chastity/activelock.html b/bin/main/static/games/chastity/activelock.html new file mode 100644 index 0000000..9f15bc1 --- /dev/null +++ b/bin/main/static/games/chastity/activelock.html @@ -0,0 +1,1749 @@ + + + + + + + Chastity Game – xXx Sphere + + + + + +
            +
            + +

            🔒 Chastity Session

            + + + + + + + + + + + + + + + + + + + + +
            + Wird geladen… +
            + +
            + +
            +
            + + + + + +
            +
            +
            🚿
            +

            Hygiene-Öffnung

            + + +
            +

            Dein aktueller Entsperrcode:

            +
            +
            +
            Verbleibende Zeit
            +
            +
            + +
            + + + +
            +
            + + +
            +
            +
            🔓
            +

            Lock geöffnet

            + + +
            +

            Dein aktueller Entsperrcode:

            +
            + +
            + + + +
            +
            + + +
            +
            +
            +
            +
            + Karte +
            +
            + +
            +
            +
            + +
            +

            +

            +
            + + + + +
            +

            + Du hast die grüne Karte gezogen!
            Möchtest du den Entsperrcode erhalten und die Session beenden,
            oder die Karte zurücklegen? +

            +
            +
            + + +
            +
            + + +
            +
            +
            +
            TTLock-Kommunikation läuft…
            +
            Bitte warten, der TTLock-Server wird kontaktiert.
            +
            +
            + + +
            +
            +

            Lock beenden?

            +

            Dein Entsperrcode:

            +
            +
            + + +
            +
            +
            + + +
            +
            +

            🆘 Notfall-Entsperrung

            +
            +
            + + +
            +
            +
            + + + + + + + + + + + + + diff --git a/bin/main/static/games/chastity/activetimelock.html b/bin/main/static/games/chastity/activetimelock.html new file mode 100644 index 0000000..2fd94af --- /dev/null +++ b/bin/main/static/games/chastity/activetimelock.html @@ -0,0 +1,1382 @@ + + + + + + + TimeLock – xXx Sphere + + + + + +
            +
            + +

            ⏱ TimeLock Session

            + + + + + + + + +
            +
            Verbleibende Zeit
            +
            + + +
            + + + + + + + + + + + + + +
            Wird geladen…
            + +
            + +
            +
            + + +
            +
            +
            + + +
            +
            + + +
            +
            +
            🚿
            +

            Hygiene-Öffnung

            + +
            +

            Dein aktueller Entsperrcode:

            +
            +
            +
            Verbleibende Zeit
            +
            +
            + +
            + + + + +
            +
            + + + + + +
            +
            +
            🔓
            +

            Lock entsperrt

            +
            +
            + + +
            +
            +
            + + +
            +
            +

            🔓 Lock beenden?

            +

            Dein Entsperrcode:

            +
            +
            + + +
            +
            +
            + + +
            +
            +

            🆘 Notfall-Entsperrung

            +
            +
            + + +
            +
            +
            + + + + + + + + diff --git a/bin/main/static/games/chastity/communityvotes.html b/bin/main/static/games/chastity/communityvotes.html new file mode 100644 index 0000000..8662519 --- /dev/null +++ b/bin/main/static/games/chastity/communityvotes.html @@ -0,0 +1,413 @@ + + + + + + + Community Votes – xXx Sphere + + + + + +
            +
            + +
            Community Votes
            +
            Verifikationen, Aufgaben-Abstimmungen & Pranger
            + +
            + + +
            + +
            +
            + + + + + + + diff --git a/bin/main/static/games/chastity/entdecken-vorlagen.html b/bin/main/static/games/chastity/entdecken-vorlagen.html new file mode 100644 index 0000000..854f3f1 --- /dev/null +++ b/bin/main/static/games/chastity/entdecken-vorlagen.html @@ -0,0 +1,528 @@ + + + + + + + Vorlagen entdecken – xXx Sphere + + + + + +
            +
            + +

            🔍 Vorlagen entdecken

            + + + + + +
            +
            + + + +
            +
            + + +
            +
            +
            +
            + +
            +

            +
            +
            +
            + +
            + +
            + + +
            +
            + + + + + + + diff --git a/bin/main/static/games/chastity/entdecken.html b/bin/main/static/games/chastity/entdecken.html new file mode 100644 index 0000000..e119188 --- /dev/null +++ b/bin/main/static/games/chastity/entdecken.html @@ -0,0 +1,485 @@ + + + + + + + Entdecken – xXx Sphere + + + + + + +
            +
            + +
            Wird geladen…
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/games/chastity/infochastity.html b/bin/main/static/games/chastity/infochastity.html new file mode 100644 index 0000000..a08b00d --- /dev/null +++ b/bin/main/static/games/chastity/infochastity.html @@ -0,0 +1,21 @@ + + + + + + + Chastity Game – Info – xXx Sphere + + + + +
            +
            +

            Chastity Game

            +

            Informationen zum Chastity Game folgen hier.

            +
            +
            + + + + diff --git a/bin/main/static/games/chastity/joinlock.html b/bin/main/static/games/chastity/joinlock.html new file mode 100644 index 0000000..85a600b --- /dev/null +++ b/bin/main/static/games/chastity/joinlock.html @@ -0,0 +1,307 @@ + + + + + + + Lock-Einladung – xXx Sphere + + + + + +
            +
            + +
            Lade Einladung…
            + +
            +
            ⚠️
            +

            Einladung nicht gefunden

            +

            Diese Einladung existiert nicht oder wurde bereits bearbeitet.

            + Zu meinen Einladungen +
            + +
            +
            🔒
            +

            Lock bereits aktiv

            +

            Diese Einladung wurde bereits angenommen.

            +
            + +
            +
            +

            Einladung abgelehnt

            +

            Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.

            + Zu meinen Einladungen +
            + + + +
            +
            + + +
            +
            +
            +
            🔒
            +

            Dein Entsperrcode

            +

            + Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem. +

            +
            + + +
            +
            + + + + + + + diff --git a/bin/main/static/games/chastity/keyholder-finden.html b/bin/main/static/games/chastity/keyholder-finden.html new file mode 100644 index 0000000..fe4d44b --- /dev/null +++ b/bin/main/static/games/chastity/keyholder-finden.html @@ -0,0 +1,530 @@ + + + + + + + Keyholder finden – xXx Sphere + + + + + +
            +
            +

            🔍 Keyholder finden

            +

            + Hier findest du Nutzer*innen, die sich als Keyholder für ein bestimmtes Lock-Template anbieten. + Die beliebtesten Angebote erscheinen ganz oben. +

            + +
            + +

            Wird geladen…

            +
            +
            + + +
            +
            + +
            + +
            +

            +
            +
            +
            +
            + +
            +
            + + +
            +
            + +

            🔒 Angebot annehmen

            +

            + +
            +
            Schloss-Steuerung
            + +
            + +
            +
            Code-Länge
            + +
            + + + +
            + + +
            +
            +
            + + +
            +
            +
            🔒
            +

            +

            + +
            + + +
            +
            +
            + + + + + + + diff --git a/bin/main/static/games/chastity/keyholder-invitation-confirmed.html b/bin/main/static/games/chastity/keyholder-invitation-confirmed.html new file mode 100644 index 0000000..76e7b95 --- /dev/null +++ b/bin/main/static/games/chastity/keyholder-invitation-confirmed.html @@ -0,0 +1,45 @@ + + + + + + + Keyholder*In bestätigt – xXx Sphere + + + + +
            +
            + + + + + +
            +
            + + + diff --git a/bin/main/static/games/chastity/keyholder.html b/bin/main/static/games/chastity/keyholder.html new file mode 100644 index 0000000..b96da76 --- /dev/null +++ b/bin/main/static/games/chastity/keyholder.html @@ -0,0 +1,1923 @@ + + + + + + + Keyholder – xXx Sphere + + + + + +
            +
            +

            Keyholder

            + + +
            + + +
            + + +
            +
            + +
            + + + +
            +
            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bin/main/static/games/chastity/meine-locks.html b/bin/main/static/games/chastity/meine-locks.html new file mode 100644 index 0000000..137d536 --- /dev/null +++ b/bin/main/static/games/chastity/meine-locks.html @@ -0,0 +1,1308 @@ + + + + + + + Meine Vorlagen – xXx Sphere + + + + + +
            +
            +
            +

            Meine Vorlagen

            + +
            + +
            + +
            +

            Abonnierte Vorlagen

            +
            + +
            +
            +
            + + + + + + + + +
            +
            +
            + +

            +

            + +
            +
            + + + + + + + + + diff --git a/bin/main/static/games/chastity/neulock.html b/bin/main/static/games/chastity/neulock.html new file mode 100644 index 0000000..2ff849c --- /dev/null +++ b/bin/main/static/games/chastity/neulock.html @@ -0,0 +1,935 @@ + + + + + + + Neues Lock – xXx Sphere + + + + + +
            +
            +

            🔒 Neues Lock

            + + +
            +
            Vorlage*
            +
            +
            + +
            + +
            +
            +
            + + +
            +
            Personen
            + +
            + +
            + +
            + +
            +
            Wähle dich selbst oder einen Freund als Lockee.
            +
            + + + +
            + +
            + +
            + +
            +
            Ohne Keyholder läuft das Lock als Self-Lock.
            +
            +
            + + +
            +
            Optionen
            + + +
            + +
            +
            +
            + + + +
            + Tage +
            +
            :
            +
            +
            + + + +
            + Std +
            +
            +
            Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.
            +
            + + + + + +
            + +
            + + + +
            +
            + +
            + +
            + + Ziffern +
            +
            + +
            + + +
            +
            + +
            + +
            + + +
            +
            +
            + + + + + + + + + + + + + + + diff --git a/bin/main/static/games/chastity/sessionchastity.html b/bin/main/static/games/chastity/sessionchastity.html new file mode 100644 index 0000000..d726745 --- /dev/null +++ b/bin/main/static/games/chastity/sessionchastity.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/bin/main/static/games/chastity/unlock-history.html b/bin/main/static/games/chastity/unlock-history.html new file mode 100644 index 0000000..e4e9ca3 --- /dev/null +++ b/bin/main/static/games/chastity/unlock-history.html @@ -0,0 +1,90 @@ + + + + + + + Code-Historie – xXx Sphere + + + + + +
            +
            +

            🔙 Entsperrcode-Historie

            +

            Die letzten 10 Entsperrcodes, die dir angezeigt wurden.

            +
            + Wird geladen… +
            +
            +
            + + + + + + diff --git a/bin/main/static/games/common/aufgaben.html b/bin/main/static/games/common/aufgaben.html new file mode 100644 index 0000000..08cc6b2 --- /dev/null +++ b/bin/main/static/games/common/aufgaben.html @@ -0,0 +1,1786 @@ + + + + + + + Aufgaben – xXx Sphere + + + + + + + + + + + + + + + + + + +
            +
            + + +
            +
            +

            Meine Aufgabengruppen

            +
            + + + + +
            +
            +
            +
            Wird geladen…
            +
            + +
            + + +
            +
            +

            Abonnierte Aufgabengruppen

            +
            + + +
            +
            +
            +
            Wird geladen…
            +
            + +
            + + +
            +
            +

            System-Aufgabengruppen

            +
            + +
            +
            +
            +
            Wird geladen…
            +
            + +
            + +
            +
            + + + + + + diff --git a/bin/main/static/games/common/einladungen.html b/bin/main/static/games/common/einladungen.html new file mode 100644 index 0000000..52b7707 --- /dev/null +++ b/bin/main/static/games/common/einladungen.html @@ -0,0 +1,982 @@ + + + + + + + Einladungen – xXx Sphere + + + + + +
            +
            +

            Einladungen

            + +
            + + +
            + + +
            +
            + + +
            + + +
            +
            + + +
            +
            +
            + + +
            +
            +
            + +
            +
            +
            + + +
            +
            +
            + + +
            +
            +
            + +
            +
            🎲
            +
            +
            +
            Vanilla Game – Einladung
            +
            +
            +

            + Du wurdest zu einem Vanilla Game eingeladen. Wie möchtest du mitspielen? +

            +
            +
            + + + +
            +
            +
            + + +
            +
            +
            + +
            +
            ⛓️
            +
            +
            +
            BDSM Game – Einladung
            +
            +
            +

            + Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen? +

            +
            +
            + + + +
            +
            +
            + + +
            +
            +
            + +
            +
            🔒
            +
            +
            +
            +
            +
            +
            +
            +
            +
            + + + Ziffern +
            +
            +
            +
            + + +
            +
            +
            + + +
            +
            +
            +
            🔒
            +

            Dein Entsperrcode

            +

            + Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem. +

            +
            + + +
            +
            + + + + + + + + diff --git a/bin/main/static/games/common/toys.html b/bin/main/static/games/common/toys.html new file mode 100644 index 0000000..334c6ea --- /dev/null +++ b/bin/main/static/games/common/toys.html @@ -0,0 +1,642 @@ + + + + + + + Toys – xXx Sphere + + + + + + + + + +
            +
            + + +
            +
            +

            Meine Toys

            +
            + + + +
            +
            +
            +
            + +
            +
            + + +
            +
            +

            System-Toys

            +
            + +
            +
            +
            +
            + +
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/games/vanilla/infovanilla.html b/bin/main/static/games/vanilla/infovanilla.html new file mode 100644 index 0000000..6976334 --- /dev/null +++ b/bin/main/static/games/vanilla/infovanilla.html @@ -0,0 +1,21 @@ + + + + + + + Vanilla Game – Info – xXx Sphere + + + + +
            +
            +

            Vanilla Game

            +

            Informationen zum Vanilla Game folgen hier.

            +
            +
            + + + + diff --git a/bin/main/static/games/vanilla/neuvanilla.html b/bin/main/static/games/vanilla/neuvanilla.html new file mode 100644 index 0000000..d37a768 --- /dev/null +++ b/bin/main/static/games/vanilla/neuvanilla.html @@ -0,0 +1,1340 @@ + + + + + + + Vanilla Game – Session einrichten – xXx Sphere + + + + + + + + + + + + + + + + + + + diff --git a/bin/main/static/games/vanilla/sessionvanilla.html b/bin/main/static/games/vanilla/sessionvanilla.html new file mode 100644 index 0000000..06b3b36 --- /dev/null +++ b/bin/main/static/games/vanilla/sessionvanilla.html @@ -0,0 +1,21 @@ + + + + + + + Vanilla Game – Neue Session – xXx Sphere + + + + +
            +
            +

            Vanilla Game – Neue Session

            +

            Session-Setup für das Vanilla Game folgt hier.

            +
            +
            + + + + diff --git a/bin/main/static/games/vanilla/vanillaingame.html b/bin/main/static/games/vanilla/vanillaingame.html new file mode 100644 index 0000000..77aa53a --- /dev/null +++ b/bin/main/static/games/vanilla/vanillaingame.html @@ -0,0 +1,1019 @@ + + + + + + + Vanilla Game – Im Spiel – xXx Sphere + + + + + + + + + +
            +
            + + + + + +
            +
            Aufgabe wird geladen…
            + +
            + + +
            + + +
            +
            Wird geladen…
            +
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/games/vanilla/vanillawarten.html b/bin/main/static/games/vanilla/vanillawarten.html new file mode 100644 index 0000000..afd7d44 --- /dev/null +++ b/bin/main/static/games/vanilla/vanillawarten.html @@ -0,0 +1,11 @@ + + + + + + Vanilla Game – xXx Sphere + + + + + diff --git a/bin/main/static/help/impressum.html b/bin/main/static/help/impressum.html new file mode 100644 index 0000000..546c4c2 --- /dev/null +++ b/bin/main/static/help/impressum.html @@ -0,0 +1,108 @@ + + + + + + + Impressum – xXx Sphere + + + + + +
            +
            + +
            +

            📄 Impressum

            +

            Angaben gemäß § 5 TMG

            +
            + +
            +

            Verantwortlich

            +
            + Vorname Nachname
            + Musterstraße 1
            + 12345 Musterstadt
            + Deutschland +
            +
            + +
            +

            Kontakt

            +

            + E-Mail: kontakt@xxx-sphere.de +

            +
            + +
            +

            Hinweis

            +

            + xXx Sphere ist ein privat betriebenes Projekt ohne kommerzielle Absicht. + Die Plattform richtet sich ausschließlich an volljährige Personen. +

            +
            + +
            +

            Haftungsausschluss

            +

            + Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links. + Für den Inhalt verlinkter Seiten sind ausschließlich deren Betreiber verantwortlich. +

            +
            + +
            +
            + + + + diff --git a/bin/main/static/help/kontakt.html b/bin/main/static/help/kontakt.html new file mode 100644 index 0000000..30a48b6 --- /dev/null +++ b/bin/main/static/help/kontakt.html @@ -0,0 +1,265 @@ + + + + + + + Kontakt & Feedback – xXx Sphere + + + + + +
            +
            + +
            +

            ✉️ Kontakt & Feedback

            +

            Hast du Fragen, Ideen oder einen Fehler gefunden? Schreib uns!

            +
            + +
            + Alternativ per E-Mail: Du kannst uns auch direkt schreiben – + kontakt@xxx-sphere.de +
            + + + +
            +
            + + + + + diff --git a/bin/main/static/help/overview.html b/bin/main/static/help/overview.html new file mode 100644 index 0000000..247dda7 --- /dev/null +++ b/bin/main/static/help/overview.html @@ -0,0 +1,221 @@ + + + + + + + Hilfe-Übersicht – xXx Sphere + + + + + +
            +
            + +
            +

            ❓ Hilfe-Übersicht

            +

            Hier findest du Anleitungen und Erklärungen zu allen Bereichen von xXx Sphere.

            +
            + + + +
            +
            +
            ⚙️ Allgemeine Einstellungen
            +
            Profil, Benachrichtigungen, Datenschutz und weitere Kontoeinstellungen.
            + +
            +
            +
            🔒 TTLock-Integration
            +
            Verbinde deine physische Schlüsselbox mit xXx Sphere für automatische Code-Verwaltung.
            + +
            +
            +
            💳 Abonnements
            +
            Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.
            + +
            +
            + + + +
            +
            +
            🔒 Chastity Game
            +
            Alles rund um Schlösser, Keyholder, Karten und Aufgaben im Chastity Game.
            + +
            +
            +
            ⛓️ BDSM Game
            +
            Sessions erstellen, Spieler einladen und Aufgaben verwalten.
            + +
            +
            +
            ⚪ Vanilla Game
            +
            Leichtere Spiele ohne strenge Regeln – für den entspannten Einstieg.
            + +
            +
            + + + +
            +
            +
            👥 Gruppen
            +
            Gruppen erstellen, beitreten und verwalten.
            + +
            +
            +
            📰 Feed & Profil
            +
            Beiträge teilen, Profile entdecken und die Community kennenlernen.
            + +
            +
            +
            🏆 Community Votes
            +
            Verifikationen bewerten und an Community-Abstimmungen teilnehmen.
            + +
            +
            + + + +
            +
            +
            🔐 Sicherheit & Datenschutz
            +
            Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.
            + +
            +
            +
            🐛 Fehler melden
            +
            Hast du einen Fehler gefunden oder einen Verbesserungsvorschlag?
            + +
            +
            + +
            +
            + + + + diff --git a/bin/main/static/help/template.html b/bin/main/static/help/template.html new file mode 100644 index 0000000..fa9f0b8 --- /dev/null +++ b/bin/main/static/help/template.html @@ -0,0 +1,345 @@ + + + + + + + SEITENTITEL – xXx Sphere + + + + + +
            +
            + + +
            +

            🔒 SEITENTITEL

            +

            Kurze Beschreibung, worum es auf dieser Hilfeseite geht.

            +
            + + +
            +
            + 📖 Was ist das? + +
            +
            +

            + Hier steht ein einleitender Text. Du kannst mehrere Absätze verwenden, + um das Thema zu erklären. +

            +

            + Zweiter Absatz mit weiteren Informationen. Links gehen so: + neues Lock starten. +

            + + +
            + Hinweis: Hier steht ein wichtiger, aber freundlicher Hinweis. +
            +
            +
            + + +
            +
            + 🚀 So funktioniert es + +
            +
            +

            Führe diese Schritte der Reihe nach aus:

            +
              +
            1. + 1 + Erster Schritt – was der Nutzer hier tun muss. +
            2. +
            3. + 2 + Zweiter Schritt – weitere Aktion mit Erklärung. +
            4. +
            5. + 3 + Dritter Schritt – Abschluss oder Ergebnis. +
            6. +
            + + +
            + Achtung: Hier steht eine Warnung, z. B. dass eine Aktion nicht rückgängig gemacht werden kann. +
            +
            +
            + + +
            +
            + 📋 Übersicht + +
            +
            + + + + + + + + + + + + + + + + + + + + + +
            FunktionBeschreibung
            Beispiel AErklärung zu Funktion A.
            Beispiel BErklärung zu Funktion B.
            Beispiel CErklärung zu Funktion C.
            +
            +
            + + +
            +
            + ❓ Häufige Frage 1? + +
            +
            +

            + Antwort auf die erste häufige Frage. Kann auch mehrere Absätze haben. +

            +
            + Neutrale Info-Box für ergänzende Details ohne Wertung. +
            +
            +
            + +
            +
            + ❓ Häufige Frage 2? + +
            +
            +

            + Antwort auf die zweite häufige Frage. +

            +
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/help/ttlock.html b/bin/main/static/help/ttlock.html new file mode 100644 index 0000000..8a6956f --- /dev/null +++ b/bin/main/static/help/ttlock.html @@ -0,0 +1,360 @@ + + + + + + + Hilfe TTLock – xXx Sphere + + + + + +
            +
            + + +
            +

            🔒 TTLock

            +

            Hilfe zur Einrichtung der Kommunikation mit einer TTLock-Schlüsselbox

            +
            + + +
            +
            + 📖 Was ist das? + +
            +
            +

            + TTLock ist ein weit verbreitetes System für die Verwaltung von smarten Schlössern und Schlüsselboxen. Die Hardware kommuniziert in der Regel via Bluetooth, lässt sich aber über ein G2-Gateway auch aus der Ferne steuern. +

            +

            + Für Entwickler und Unternehmen bietet die TTLock Open Platform eine leistungsstarke REST-API. Damit lässt sich die Schlossverwaltung in eigene Anwendungen integrieren. + Diese API verwenden wir, um die Codes deiner Schlüsselbox zu steuern - kein nerviges manuelles Eintragen des generierten Schlüssels mehr. +

            +

            + TTLock steht allen Premium-Abonenten zur Verfügung. +

            +
            +
            + + +
            +
            + 📋 Voraussetzungen + +
            +
            +
            + Achtung: Für die Verwendung ist zwingend ein G2-Gateway für die Kommunikation von TTLock-Server zu Deiner Schlüsselbox notwendig. +
            +
            + Hinweis: Für die Verwendung einer TTLock-Schlüsselbox in Spielen ist zwingend ein Premium-Abonement notwendig. +
            +
            +
            + + +
            +
            + 🚀 So funktioniert es + +
            +
            +

            Führe diese Schritte der Reihe nach aus:

            +
              +
            1. + 1 + App-Setup: Verbinde deine Schlüsselbox in der TTLock-App. Wichtig: Für die Fernsteuerung muss ein Gateway (G2) eingerichtet und aktiv sein. +
            2. +
            3. + 2 + Fernzugriff aktivieren: Aktiviere die Funktion in den App-Einstellungen. Tipp: Schalte das WLAN an deinem Handy aus und versuche, die Box über mobile Daten zu öffnen. Funktioniert das? Dann ist alles bereit. +
            4. +
            5. + 3 + Accounts verknüpfen: Trage deine TTLock-Zugangsdaten unter Einstellungen > TTLock ein. +
            6. +
            7. + 4 + Lock-ID hinterlegen: Gib die ID deiner Box an (zu finden unter MAC/ID). Wichtig: Nur den Teil hinter dem Schrägstrich nutzen (z.B. bei 00:11.../123456 nur 123456). +
            8. +
            9. + 5 + Verbindung testen: Klicke auf „Verbindung testen“. Erst nach einem grünen Licht ist das System aktiv. +
            10. +
            + + +
            + Hinweis: Wir speichern dein Passwort nicht im Klartext in der Datenbank sondern nur als MD5-Hash. +
            +
            +
            + + +
            +
            + ❓ Warum steht dieser Dienst nur für Abonennten zur Verfügung? + +
            +
            +

            + Die Verwendung der API von TTLock ist nur begrenzt kostenlos verwendbar. Ab bestimmten Kontingenten wird die Verwendung für uns kostenpflichtig. +

            +
            + Es gilt weiter der Grundsatz, XXX-Sphere soll niemanden reich machen - Die Abonemments dienen dazu die laufenden Kosten (Server, API-Schnittstellen etc.) zu decken. +
            +
            +
            + +
            +
            + ❓ Hilfe - ich komme nicht mehr raus...Was machen Sachen? + +
            +
            +

            + Sollte sich der Schlüssel noch in der Box befinden und ihr nicht in einem aktiven Lock sein, besteht die Möglichkeit einen neuen Code für eine Notfallöffnung zu generieren: +

            + +
              +
            1. + 1 + Öffne die Einstellungen +
            2. +
            3. + 2 + Navigiere zum Bereich '🔒 TTLock' +
            4. +
            5. + 3 + Drücke '🔒 Öffnen' +
            6. +
            +

            + Der temporäre Code zum Öffnen wird euch angezeigt - damit lässt sich die Box dann öffnen. +

            +
            +
            + +
            +
            + + + + + + diff --git a/bin/main/static/img/banner.png b/bin/main/static/img/banner.png new file mode 100644 index 0000000..9f3671b Binary files /dev/null and b/bin/main/static/img/banner.png differ diff --git a/bin/main/static/img/card.png b/bin/main/static/img/card.png new file mode 100644 index 0000000..8bf5d1d Binary files /dev/null and b/bin/main/static/img/card.png differ diff --git a/bin/main/static/img/card_cum.png b/bin/main/static/img/card_cum.png new file mode 100644 index 0000000..25828fa Binary files /dev/null and b/bin/main/static/img/card_cum.png differ diff --git a/bin/main/static/img/card_cum_caged.png b/bin/main/static/img/card_cum_caged.png new file mode 100644 index 0000000..5da6f37 Binary files /dev/null and b/bin/main/static/img/card_cum_caged.png differ diff --git a/bin/main/static/img/card_doubleup.png b/bin/main/static/img/card_doubleup.png new file mode 100644 index 0000000..7772187 Binary files /dev/null and b/bin/main/static/img/card_doubleup.png differ diff --git a/bin/main/static/img/card_freeze.png b/bin/main/static/img/card_freeze.png new file mode 100644 index 0000000..a046939 Binary files /dev/null and b/bin/main/static/img/card_freeze.png differ diff --git a/bin/main/static/img/card_green.png b/bin/main/static/img/card_green.png new file mode 100644 index 0000000..d4f980a Binary files /dev/null and b/bin/main/static/img/card_green.png differ diff --git a/bin/main/static/img/card_old.png b/bin/main/static/img/card_old.png new file mode 100644 index 0000000..b3e1068 Binary files /dev/null and b/bin/main/static/img/card_old.png differ diff --git a/bin/main/static/img/card_red.png b/bin/main/static/img/card_red.png new file mode 100644 index 0000000..5e09a93 Binary files /dev/null and b/bin/main/static/img/card_red.png differ diff --git a/bin/main/static/img/card_reset.png b/bin/main/static/img/card_reset.png new file mode 100644 index 0000000..6cd9642 Binary files /dev/null and b/bin/main/static/img/card_reset.png differ diff --git a/bin/main/static/img/card_task.png b/bin/main/static/img/card_task.png new file mode 100644 index 0000000..022fc94 Binary files /dev/null and b/bin/main/static/img/card_task.png differ diff --git a/bin/main/static/img/card_yellow.png b/bin/main/static/img/card_yellow.png new file mode 100644 index 0000000..ca6e293 Binary files /dev/null and b/bin/main/static/img/card_yellow.png differ diff --git a/bin/main/static/img/icon.png b/bin/main/static/img/icon.png new file mode 100644 index 0000000..da7b7fd Binary files /dev/null and b/bin/main/static/img/icon.png differ diff --git a/bin/main/static/img/logo.png b/bin/main/static/img/logo.png new file mode 100644 index 0000000..ff1aafe Binary files /dev/null and b/bin/main/static/img/logo.png differ diff --git a/bin/main/static/img/logo_community.png b/bin/main/static/img/logo_community.png new file mode 100644 index 0000000..7d0d122 Binary files /dev/null and b/bin/main/static/img/logo_community.png differ diff --git a/bin/main/static/img/lvl1.png b/bin/main/static/img/lvl1.png new file mode 100644 index 0000000..3bde9b7 Binary files /dev/null and b/bin/main/static/img/lvl1.png differ diff --git a/bin/main/static/img/lvl2.png b/bin/main/static/img/lvl2.png new file mode 100644 index 0000000..bcf3a9d Binary files /dev/null and b/bin/main/static/img/lvl2.png differ diff --git a/bin/main/static/img/lvl3.png b/bin/main/static/img/lvl3.png new file mode 100644 index 0000000..c9a3e29 Binary files /dev/null and b/bin/main/static/img/lvl3.png differ diff --git a/bin/main/static/img/lvl4.png b/bin/main/static/img/lvl4.png new file mode 100644 index 0000000..88ba411 Binary files /dev/null and b/bin/main/static/img/lvl4.png differ diff --git a/bin/main/static/img/lvl5.png b/bin/main/static/img/lvl5.png new file mode 100644 index 0000000..72e16e4 Binary files /dev/null and b/bin/main/static/img/lvl5.png differ diff --git a/bin/main/static/img/vorlieben/dunno.png b/bin/main/static/img/vorlieben/dunno.png new file mode 100644 index 0000000..8cdf8fb Binary files /dev/null and b/bin/main/static/img/vorlieben/dunno.png differ diff --git a/bin/main/static/img/vorlieben/negative.png b/bin/main/static/img/vorlieben/negative.png new file mode 100644 index 0000000..7478998 Binary files /dev/null and b/bin/main/static/img/vorlieben/negative.png differ diff --git a/bin/main/static/img/vorlieben/neutral.png b/bin/main/static/img/vorlieben/neutral.png new file mode 100644 index 0000000..64381f2 Binary files /dev/null and b/bin/main/static/img/vorlieben/neutral.png differ diff --git a/bin/main/static/img/vorlieben/positiv.png b/bin/main/static/img/vorlieben/positiv.png new file mode 100644 index 0000000..172c0d8 Binary files /dev/null and b/bin/main/static/img/vorlieben/positiv.png differ diff --git a/bin/main/static/img/vorlieben/verynegative.png b/bin/main/static/img/vorlieben/verynegative.png new file mode 100644 index 0000000..7cfd217 Binary files /dev/null and b/bin/main/static/img/vorlieben/verynegative.png differ diff --git a/bin/main/static/img/vorlieben/verypositiv.png b/bin/main/static/img/vorlieben/verypositiv.png new file mode 100644 index 0000000..8fda4f4 Binary files /dev/null and b/bin/main/static/img/vorlieben/verypositiv.png differ diff --git a/bin/main/static/index.html b/bin/main/static/index.html new file mode 100644 index 0000000..60bd6de --- /dev/null +++ b/bin/main/static/index.html @@ -0,0 +1,35 @@ + + + + + + xXx Sphere + + + + + + Icon +

            Kinky Games und Communities

            +
            + Anmelden + Registrieren +
            + +
            + + + + diff --git a/bin/main/static/js/card-defs.js b/bin/main/static/js/card-defs.js new file mode 100644 index 0000000..765fc0f --- /dev/null +++ b/bin/main/static/js/card-defs.js @@ -0,0 +1,86 @@ +/** + * Zentrale Kartendefinitionen für das Chastity Game. + * + * Exportiert (global): + * CARD_DEFS – Array mit { id, img, name, desc, defMin, defMax } + * CARD_LABELS – Object { ID: { name, img, desc } } (Lookup für card-display.js u.a.) + */ +const CARD_DEFS = [ + { + id: 'RED', + img: '/img/card_red.png', + name: 'Rote Karte', + desc: 'Niete - Viel Erfolg beim nächsten Zug', + defMin: 5, + defMax: 10, + }, + { + id: 'GREEN', + img: '/img/card_green.png', + name: 'Grüne Karte', + desc: 'Öffnet das Lock. Kann wieder ins Deck zurück gelegt werden', + defMin: 1, + defMax: 2, + }, + { + id: 'YELLOW', + img: '/img/card_yellow.png', + name: 'Gelbe Karte', + desc: 'Per Zufall werden rote Karten entfernt oder hinzugefügt', + defMin: 1, + defMax: 2, + }, + { + id: 'TASK', + img: '/img/card_task.png', + name: 'Aufgabe', + desc: 'Keyholder*In, Community oder der Zufall teilt eine Aufgabe zu.', + defMin: 0, + defMax: 0, + }, + { + id: 'FREEZE', + img: '/img/card_freeze.png', + name: 'Freeze', + desc: 'Friert das Lock für eine festgelegte Zeit ein – in diesem Zeitraum können keine Karten gezogen werden.', + defMin: 0, + defMax: 0, + }, + { + id: 'RESET', + img: '/img/card_reset.png', + name: 'Reset', + desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.', + defMin: 0, + defMax: 0, + }, + { + id: 'DOUBLE_UP', + img: '/img/card_doubleup.png', + name: 'Double Up', + desc: 'Verdoppelt alle noch im Deck vorhandenen Karten.', + defMin: 0, + defMax: 0, + }, + { + id: 'CUM', + img: '/img/card_cum.png', + name: 'Cum', + desc: 'Du wirst entsperrt, nutze diese Entsperrung um zu kommen. Je länger du brauchst, desto schlimmer.', + defMin: 0, + defMax: 0, + }, + { + id: 'CUM_IN_CAGE', + img: '/img/card_cum_caged.png', + name: 'Cum in Cage', + desc: 'Komme in deinem Keuschheitsgürtel, wie du es anstellst ist deine Sache.', + defMin: 0, + defMax: 0, + }, +]; + +/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */ +const CARD_LABELS = Object.fromEntries( + CARD_DEFS.map(c => [c.id, { name: c.name, img: c.img, desc: c.desc }]) +); diff --git a/bin/main/static/js/card-display.js b/bin/main/static/js/card-display.js new file mode 100644 index 0000000..e55ba9e --- /dev/null +++ b/bin/main/static/js/card-display.js @@ -0,0 +1,60 @@ +/** + * Gemeinsame Kartenanzeige für Chastity Game. + * Benötigt: /js/card-defs.js (CARD_LABELS muss bereits global verfügbar sein) + * Exportiert: cardTypeGridHtml(cardCounts) + */ +(function () { + const style = document.createElement('style'); + style.textContent = ` + .card-type-grid { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.4rem; + } + .card-type-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + width: calc((100% - 6 * 0.6rem) / 14); + min-width: 28px; + } + .card-type-item img { + width: 100%; + height: auto; + border-radius: 4px; + display: block; + } + .card-type-badge { + font-size: 1rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; + } + `; + document.head.appendChild(style); +})(); + +/** + * Gibt HTML für ein Karten-Typ-Raster zurück (ein Bild pro Typ, Anzahl-Badge). + * @param {Object} cardCounts – { RED: 3, GREEN: 1, … } + * @returns {string} HTML-String + */ +function cardTypeGridHtml(cardCounts) { + if (!cardCounts || Object.keys(cardCounts).length === 0) { + return 'Keine Karten mehr im Stapel.'; + } + const items = Object.entries(cardCounts) + .filter(([, n]) => n > 0) + .map(([type, n]) => { + const info = CARD_LABELS[type] || { img: '/img/card.png', name: type }; + return `
            + ${info.name} + ${n} +
            `; + }).join(''); + return items + ? `
            ${items}
            ` + : 'Keine Karten mehr im Stapel.'; +} diff --git a/bin/main/static/js/icons.js b/bin/main/static/js/icons.js new file mode 100644 index 0000000..b36b876 --- /dev/null +++ b/bin/main/static/js/icons.js @@ -0,0 +1,190 @@ +/** + * Zentrale Icon-Verwaltung – XXX The Game + * + * Typen: + * emoji – Standard-Emoji oder Unicode-Zeichen (value: string) + * symbol – Unicode-Symbol (value: string) + * image – Pfad zu einer Bilddatei (value: string) + * compound – Doppel-Icon: base-Icon + kleines Overlay-Icon (bottom-right) + * Felder: base { type, value }, overlay { type, value } + * base kann emoji, symbol oder image sein. + */ +window.ICONS = { + // ── Navigation / Sidebar ────────────────────────────────────────────── + HOME: { type: 'emoji', value: '🏠' }, + VANILLA: { type: 'emoji', value: '⚪' }, + BDSM: { type: 'emoji', value: '⛓️' }, + CHASTITY: { type: 'emoji', value: '🔒' }, + + // ── Aktionen ────────────────────────────────────────────────────────── + PLAY_NEW: { type: 'emoji', value: '🆕' }, + PLAY_ACTIVE: { type: 'emoji', value: '▶️' }, + ACTIVE_LOCK: { type: 'emoji', value: '▶️' }, + WAITING: { type: 'emoji', value: '⏳' }, + CHECK: { type: 'emoji', value: '✅' }, + DISCOVER: { type: 'emoji', value: '🗺️' }, + ARROW: { type: 'emoji', value: '▶️' }, + REFRESH: { type: 'emoji', value: '🔄' }, // Erneuern / Neu laden + START: { type: 'emoji', value: '🚀' }, // Starten / Los + CELEBRATE: { type: 'emoji', value: '🎉' }, // Erfolg / Abschluss + + // ── UI-Symbole ──────────────────────────────────────────────────────── + CLOSE: { type: 'symbol', value: '✕' }, // Schließen / Ablehnen / Löschen + CONFIRM: { type: 'symbol', value: '✓' }, // Bestätigen / Abschließen / Annehmen + LIKE: { type: 'symbol', value: '♥' }, // Like-Button + AVATAR: { type: 'symbol', value: '◉' }, // Avatar-Platzhalter (kein Bild) + ERROR: { type: 'emoji', value: '❌' }, // Fehlerzustand + TIMER: { type: 'emoji', value: '⏱️' }, // Zeitanzeige / Stoppuhr + LIGHTNING: { type: 'emoji', value: '⚡' }, // Aktion (z. B. Zeit entfernen) + EMOJI_PICKER: { type: 'emoji', value: '😊' }, // Emoji-Picker öffnen + REMOVE: { type: 'symbol', value: '⊗' }, // Eintrag/Spiel entfernen + EDIT: { type: 'symbol', value: '✎' }, // Bearbeiten-Button + TRASH: { type: 'emoji', value: '🗑' }, // Löschen-Button + WARNING: { type: 'emoji', value: '⚠️' }, // Warnung / Hinweis + REPORT: { type: 'symbol', value: '⚑' }, // Melden-Button (Flag) + VISIBILITY: { type: 'emoji', value: '👁' }, // Sichtbar / Details sichtbar + THUMBS_UP: { type: 'emoji', value: '👍' }, // Upvote / Zustimmung + THUMBS_DOWN: { type: 'emoji', value: '👎' }, // Downvote / Ablehnung + ARROW_UP: { type: 'symbol', value: '⬆' }, // Sortierung aufsteigend + ARROW_DOWN: { type: 'symbol', value: '⬇' }, // Sortierung absteigend + NAV_PREV: { type: 'symbol', value: '←' }, // Zurück / Vorheriges Bild + NAV_NEXT: { type: 'symbol', value: '→' }, // Weiter / Nächstes Bild + CAROUSEL_PREV: { type: 'symbol', value: '‹' }, // Karussell zurück + CAROUSEL_NEXT: { type: 'symbol', value: '›' }, // Karussell weiter + TIP: { type: 'emoji', value: '💡' }, // Hinweis / Tipp + DOT_RED: { type: 'emoji', value: '🔴' }, // Status-Indikator rot + COMING_SOON: { type: 'emoji', value: '🚧' }, // In Entwicklung / Demnächst + + // ── Chastity Game ───────────────────────────────────────────────────── + NEW_LOCK: { type: 'emoji', value: '🆕' }, + LOCK: { type: 'emoji', value: '🔒' }, + UNLOCK: { type: 'emoji', value: '🔓' }, // Entsperren + LOCKED_SECURE: { type: 'emoji', value: '🔐' }, // Sicher gesperrt (mit Schlüssel) + KEY: { type: 'emoji', value: '🔑' }, + HISTORY: { type: 'emoji', value: '🔙' }, + VOTES: { type: 'emoji', value: '🗳️' }, + TRUST: { type: 'emoji', value: '🤝' }, // Trust-Lock + EMERGENCY: { type: 'emoji', value: '🆘' }, // Notfall-Entsperrung + HYGIENE: { type: 'emoji', value: '🚿' }, // Hygiene-Öffnung + FROZEN: { type: 'emoji', value: '❄️' }, // Eingefroren (zeitlich) + FROZEN_HARD: { type: 'emoji', value: '🧊' }, // Eingefroren (unlimitiert) + UNFREEZE: { type: 'emoji', value: '🌊' }, // Aufgetaut / Unfreeze + CODE_DIGITS: { type: 'emoji', value: '🔢' }, // Zahlenkombination / PIN-Länge + + // ── CardLock ────────────────────────────────────────────────────────── + CARD: { type: 'emoji', value: '🃏' }, // Karte (standalone) + DICE: { type: 'emoji', value: '🎲' }, // Zufällig / Würfeln + + // ── TimeLock / Spinning Wheel ────────────────────────────────────────── + SPINNING_WHEEL: { type: 'emoji', value: '🎡' }, // Glücksrad drehen + TASK_ACTIVE: { type: 'emoji', value: '🎯' }, // Aktuelle Aufgabe + CLOCK: { type: 'emoji', value: '🕐' }, // Uhr / Zeitpunkt + + // ── Social ──────────────────────────────────────────────────────────── + FEED: { type: 'emoji', value: '📰' }, + SEARCH: { type: 'emoji', value: '🔍' }, + FRIENDS: { type: 'emoji', value: '❤️' }, + MESSAGES: { type: 'emoji', value: '💬' }, + NOTIFICATIONS: { type: 'emoji', value: '🔔' }, + GROUPS: { type: 'emoji', value: '👥' }, + INVITATIONS: { type: 'emoji', value: '✨' }, + SETTINGS: { type: 'emoji', value: '⚙️' }, + LOGOUT: { type: 'emoji', value: '⏏️' }, + PROFILE: { type: 'emoji', value: '👤' }, + HELP: { type: 'emoji', value: '❓' }, + CONTACT: { type: 'emoji', value: '✉️' }, // Kontakt / E-Mail + + // ── Medien / Dateien ────────────────────────────────────────────────── + PHOTO: { type: 'emoji', value: '📷' }, // Foto / Kamera + FILE_UPLOAD: { type: 'emoji', value: '📁' }, // Datei auswählen / Upload + TEMPLATE: { type: 'emoji', value: '📋' }, // Vorlage / Template + DOCUMENT: { type: 'emoji', value: '📄' }, // Dokument / Impressum + GUIDE: { type: 'emoji', value: '📖' }, // Anleitung / Hilfeseite + STATS: { type: 'emoji', value: '📊' }, // Statistik / Umfrage-Ergebnis + PACKAGE: { type: 'emoji', value: '📦' }, // Paket / Einladung + MAILBOX: { type: 'emoji', value: '📬' }, // Posteingang (Admin) + + // ── Abo / Premium ───────────────────────────────────────────────────── + PREMIUM: { type: 'emoji', value: '⭐' }, // Abonnement / Premium + TROPHY: { type: 'emoji', value: '🏆' }, // Auszeichnung / Erfolg + PAYMENT: { type: 'emoji', value: '💳' }, // Zahlung / Abonnement + + // ── TTLock / Technik ────────────────────────────────────────────────── + MOBILE: { type: 'emoji', value: '📱' }, // TTLock-App / Mobilgerät + CONNECTION: { type: 'emoji', value: '🔌' }, // Verbindung / Integration + GAMEPAD: { type: 'emoji', value: '🕹️' }, // Spielsteuerung + SHIELD: { type: 'emoji', value: '🛡️' }, // Sicherheit / Datenschutz + ADMIN_TOOLS: { type: 'emoji', value: '🔧' }, // Admin / Werkzeuge + + // ── Aufgaben / Items ────────────────────────────────────────────────── + TOYS: { type: 'emoji', value: '➰' }, + + // ── Spielhistorie – Spieltypen ──────────────────────────────────────── + GAME_BDSM: { type: 'emoji', value: '⛓️' }, + GAME_VANILLA: { type: 'emoji', value: '❤️' }, + + // Doppel-Icons: großes Basis-Icon + kleines 🔒-Overlay + GAME_CARDLOCK: { + type: 'compound', + base: { type: 'image', value: '/img/card.png' }, + overlay: { type: 'emoji', value: '🔒' } + }, + GAME_TIMELOCK: { + type: 'compound', + base: { type: 'emoji', value: '⏰' }, + overlay: { type: 'emoji', value: '🔒' } + }, + + // ── Spielhistorie – Rollen-Badges ───────────────────────────────────── + ROLE_KEYHOLDER: { type: 'emoji', value: '🔑' }, + ROLE_LOCKEE: { type: 'emoji', value: '🔒' }, +}; + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + +/** Gibt den rohen Wert-String zurück (nur für einfache Icons; '' für compound). */ +window.IC = function(key) { + const icon = window.ICONS[key]; + return (icon && icon.type !== 'compound') ? (icon.value || '') : ''; +}; + +/** + * Gibt ein fertiges HTML-Fragment zurück, das das Icon darstellt. + * + * @param {string} key – Schlüssel aus window.ICONS + * @param {number} [size] – Basisgröße in rem (Standard: 2.7) + * @returns {string} HTML-String + */ +window.IChtml = function(key, size) { + const icon = window.ICONS[key]; + if (!icon) return ''; + return _iconToHtml(icon, size != null ? size : 2.7); +}; + +function _iconToHtml(icon, size) { + switch (icon.type) { + case 'emoji': + case 'symbol': + return `${icon.value}`; + case 'image': + return ``; + case 'compound': { + const baseHtml = _compoundBase(icon.base, size); + const overlayHtml = _compoundOverlay(icon.overlay, size * 0.48); + return `${baseHtml}${overlayHtml}`; + } + default: + return ''; + } +} + +function _compoundBase(base, size) { + if (base.type === 'image') { + return ``; + } + return `${base.value}`; +} + +function _compoundOverlay(overlay, size) { + return `${overlay.value}`; +} diff --git a/bin/main/static/js/image-viewer.js b/bin/main/static/js/image-viewer.js new file mode 100644 index 0000000..148a4d0 --- /dev/null +++ b/bin/main/static/js/image-viewer.js @@ -0,0 +1,237 @@ +// ───────────────────────────────────────────────────────────────────────────── +// image-viewer.js – Universelle Bild-Lightbox +// +// Einbinden: (vorher) +// +// +// Zwei Modi: +// Modus A – Nur Bild (kein Like, keine Kommentare): +// imageViewer.open({ images: [{ src }] }) +// +// Modus B – Galerie mit Like + Kommentare: +// imageViewer.open({ +// images: [{ src, id, likedByMe, likeCount }], +// index: 0, +// showLike: true, +// showComments: true, +// myUserId: '...', +// onLike: async (img) => {} // optional; sonst POST /social/profile-images/{id}/like +// }) +// +// Globale Instanz: window.imageViewer +// ───────────────────────────────────────────────────────────────────────────── + +class ImageViewer { + constructor() { + this._cfg = null; + this._idx = 0; + this.isOpen = false; + this._injectStyles(); + this._injectHTML(); + this._bindEvents(); + } + + // ── Öffentliche API ─────────────────────────────────────────────────────── + + open(cfg) { + this._cfg = cfg; + this._idx = cfg.index || 0; + this.isOpen = true; + + const multi = cfg.images.length > 1; + const showLike = !!cfg.showLike; + const showCom = !!cfg.showComments; + + this._q('ivPrev').style.display = multi ? '' : 'none'; + this._q('ivNext').style.display = multi ? '' : 'none'; + this._q('ivCounter').style.display = multi ? '' : 'none'; + this._q('ivLikeBtn').style.display = showLike ? '' : 'none'; + this._q('ivComments').style.display = showCom ? '' : 'none'; + + this._render(); + this._q('imageViewer').classList.add('open'); + this._updateLayout(); + } + + close() { + this._q('imageViewer').classList.remove('open'); + this.isOpen = false; + this._cfg = null; + } + + /** Kommentare im offenen Viewer neu laden (z.B. nach externem Löschen) */ + reloadComments() { + if (this.isOpen && this._cfg?.showComments) this._loadComments(); + } + + // ── Internes Rendering ──────────────────────────────────────────────────── + + _q(id) { return document.getElementById(id); } + + _render() { + const img = this._cfg.images[this._idx]; + this._q('ivImg').src = img.src; + + const total = this._cfg.images.length; + this._q('ivCounter').textContent = `${this._idx + 1} / ${total}`; + this._q('ivPrev').disabled = this._idx === 0; + this._q('ivNext').disabled = this._idx === total - 1; + + if (this._cfg.showLike) this._syncLike(); + if (this._cfg.showComments) this._loadComments(); + } + + _syncLike() { + const img = this._cfg.images[this._idx]; + const btn = this._q('ivLikeBtn'); + btn.className = 'btn-like' + (img.likedByMe ? ' liked' : ''); + this._q('ivLikeCount').textContent = img.likeCount; + } + + async _loadComments() { + const img = this._cfg.images[this._idx]; + const res = await fetch(`/social/kommentare?targetType=IMAGE&targetId=${img.id}`); + const comments = await res.json(); + const myUserId = this._cfg.myUserId || null; + this._q('ivCommentsList').innerHTML = comments.length === 0 + ? '

            Noch keine Kommentare.

            ' + : comments.map(k => renderKommentarHtml(k, 'IMAGE', img.id, { myUserId, showReplies: true })).join(''); + } + + async _postComment() { + const input = this._q('ivCommentInput'); + const text = input.value.trim(); + if (!text) return; + const img = this._cfg.images[this._idx]; + await fetch('/social/kommentare', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'IMAGE', targetId: img.id, text }) + }); + input.value = ''; + await this._loadComments(); + } + + async _toggleLike() { + const img = this._cfg.images[this._idx]; + const onLike = this._cfg.onLike; + img.likedByMe = !img.likedByMe; + img.likeCount += img.likedByMe ? 1 : -1; + this._syncLike(); + try { + if (onLike) await onLike(img); + else await fetch('/social/profile-images/' + img.id + '/like', { method: 'POST' }); + } catch { + img.likedByMe = !img.likedByMe; + img.likeCount += img.likedByMe ? 1 : -1; + this._syncLike(); + } + } + + _prev() { if (this._idx > 0) { this._idx--; this._render(); } } + _next() { if (this._idx < this._cfg.images.length - 1) { this._idx++; this._render(); } } + + _updateLayout() { + const el = this._q('ivLayout'); + if (!el) return; + const bp = parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--breakpoint-mobile').trim()) || 768; + el.classList.toggle('iv-narrow', window.innerWidth <= bp); + } + + // ── CSS + HTML Injection ────────────────────────────────────────────────── + + _injectStyles() { + if (document.getElementById('iv-styles')) return; + const s = document.createElement('style'); + s.id = 'iv-styles'; + s.textContent = ` +#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:500;align-items:center;justify-content:center;padding:2rem} +#imageViewer.open{display:flex} +#ivLayout{display:flex;flex-direction:row;gap:1rem;height:min(78vh,660px);max-width:calc(100vw - 4rem);align-items:stretch} +#ivImageSide{width:660px;flex-shrink:1;min-width:0;display:flex;flex-direction:column} +.iv-image-box{flex:1;position:relative;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;overflow:hidden;display:flex;align-items:center;justify-content:center} +#ivImg{width:100%;height:100%;object-fit:contain;display:block} +.iv-overlay{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.6));border-radius:0 0 12px 12px;padding:2rem 0.75rem 0.6rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem} +.iv-nav-btn{background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;padding:0.3rem 0.75rem;cursor:pointer;margin:0;width:auto;font-size:1rem;flex-shrink:0;transition:background 0.15s} +.iv-nav-btn:hover{background:rgba(0,0,0,0.65)} +.iv-nav-btn:disabled{opacity:.25;cursor:default} +.iv-overlay-center{display:flex;align-items:center;gap:0.6rem;flex:1;justify-content:center} +#ivCounter{font-size:0.8rem;color:rgba(255,255,255,0.75)} +.iv-close{position:fixed;top:1rem;right:1rem;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:1.1rem;width:2.2rem;height:2.2rem;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;z-index:502;transition:background 0.15s} +.iv-close:hover{background:rgba(180,30,30,0.8)} +#ivComments{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden} +.iv-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0} +#ivCommentsList{flex:1;overflow-y:auto;padding:0.65rem 0.75rem;scrollbar-width:thin;scrollbar-color:var(--color-secondary) transparent} +.iv-comment-compose{display:flex;gap:0.4rem;padding:0.65rem 0.75rem;border-top:1px solid var(--color-secondary);flex-shrink:0;align-items:center} +.iv-comment-compose input{flex:1;padding:0.4rem 0.65rem;font-size:0.85rem} +.iv-comment-compose button{width:auto;padding:0.4rem 0.7rem;font-size:0.82rem;white-space:nowrap} +#ivLayout.iv-narrow{flex-direction:column;height:auto;max-height:90vh;overflow-y:auto;width:calc(100vw - 1rem);max-width:calc(100vw - 1rem)} +#ivLayout.iv-narrow #ivImageSide{width:100%;flex-shrink:0} +#ivLayout.iv-narrow .iv-image-box{height:min(45vh,360px);flex:none} +#ivLayout.iv-narrow #ivComments{width:100%;max-height:40vh;flex-shrink:0} +`; + document.head.appendChild(s); + } + + _injectHTML() { + if (document.getElementById('imageViewer')) return; + const div = document.createElement('div'); + div.id = 'imageViewer'; + div.innerHTML = ` + +
            +
            +
            + +
            + +
            + + +
            + +
            +
            +
            +
            +
            Kommentare
            +
            +
            + + + +
            +
            +
            `; + document.body.appendChild(div); + } + + _bindEvents() { + const init = () => { + this._q('ivClose').addEventListener('click', () => this.close()); + this._q('imageViewer').addEventListener('click', e => { + if (e.target === this._q('imageViewer')) this.close(); + }); + this._q('ivPrev').addEventListener('click', () => this._prev()); + this._q('ivNext').addEventListener('click', () => this._next()); + this._q('ivLikeBtn').addEventListener('click', () => this._toggleLike()); + this._q('ivCommentSend').addEventListener('click', () => this._postComment()); + this._q('ivCommentInput').addEventListener('keydown', e => { + if (e.key === 'Enter') this._postComment(); + }); + window.addEventListener('resize', () => this._updateLayout()); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); + + document.addEventListener('keydown', e => { + if (!this.isOpen) return; + if (e.key === 'Escape') this.close(); + if (e.key === 'ArrowLeft') this._prev(); + if (e.key === 'ArrowRight') this._next(); + }); + } +} + +window.imageViewer = new ImageViewer(); diff --git a/bin/main/static/js/meldung.js b/bin/main/static/js/meldung.js new file mode 100644 index 0000000..c5044b5 --- /dev/null +++ b/bin/main/static/js/meldung.js @@ -0,0 +1,87 @@ +/** + * Wiederverwendbares Meldungs-Modul. + * Bietet openMeldungDialog(zielTyp, zielId) und renderMeldenBtn(zielTyp, zielId). + */ +(function () { + // Dialog einmalig in den DOM einfügen + if (!document.getElementById('meldungDialog')) { + document.body.insertAdjacentHTML('beforeend', ` +
            +
            +

            Inhalt melden

            +

            + +
            + + +
            + +
            +
            + `); + + document.getElementById('meldungAbbrechen').addEventListener('click', () => closeMeldungDialog()); + document.getElementById('meldungDialog').addEventListener('click', function (e) { + if (e.target === this) closeMeldungDialog(); + }); + } + + let _zielTyp = null, _zielId = null; + + window.openMeldungDialog = function (zielTyp, zielId) { + _zielTyp = zielTyp; + _zielId = zielId; + document.getElementById('meldungGrund').value = ''; + document.getElementById('meldungMsg').style.display = 'none'; + document.getElementById('meldungDialogLabel').textContent = + zielTyp === 'PROFIL' ? 'Profil melden' : 'Post melden'; + document.getElementById('meldungDialog').style.display = 'flex'; + + document.getElementById('meldungSenden').onclick = async function () { + const grund = document.getElementById('meldungGrund').value.trim(); + const r = await fetch('/meldung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zielTyp: _zielTyp, zielId: _zielId, grund: grund || null }) + }); + const msg = document.getElementById('meldungMsg'); + msg.style.display = 'block'; + if (r.status === 201) { + msg.style.color = 'var(--color-success, #2ecc71)'; + msg.textContent = 'Meldung wurde übermittelt.'; + setTimeout(closeMeldungDialog, 1500); + } else if (r.status === 409) { + msg.style.color = 'var(--color-primary)'; + msg.textContent = 'Du hast diesen Inhalt bereits gemeldet.'; + } else { + msg.style.color = 'var(--color-primary)'; + msg.textContent = 'Fehler beim Senden.'; + } + }; + }; + + window.closeMeldungDialog = function () { + document.getElementById('meldungDialog').style.display = 'none'; + }; + + /** + * Erzeugt einen kleinen "Melden"-Button-HTML-String. + * Verwendung: in innerHTML-Templates, wo onclick genutzt werden kann. + */ + window.renderMeldenBtn = function (zielTyp, zielId) { + return ``; + }; +})(); diff --git a/bin/main/static/js/shared.js b/bin/main/static/js/shared.js new file mode 100644 index 0000000..9d79b8e --- /dev/null +++ b/bin/main/static/js/shared.js @@ -0,0 +1,237 @@ +// ───────────────────────────────────────────────────────────────────────────── +// shared.js – Gemeinsame Helfer & Komponenten +// Einbinden: +// (vor allen Seiten-Skripten, nach CSS-Links) +// ───────────────────────────────────────────────────────────────────────────── + +// ── CSS-Injection (Comment + Carousel) ──────────────────────────────────────── +(function injectSharedStyles() { + if (document.getElementById('shared-styles')) return; + const s = document.createElement('style'); + s.id = 'shared-styles'; + s.textContent = ` +/* ── Karussell ── */ +.post-carousel{position:relative;margin-top:0.5rem} +.car-slide{display:none} +.car-slide.active{display:block} +.car-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:2.2rem;width:auto;min-width:2.4rem;height:3.2rem;border-radius:8px;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;padding:0 0.5rem;margin:0;line-height:1} +.car-prev{left:0.3rem} +.car-next{right:0.3rem} +.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem} + +/* ── Like / Löschen-Buttons ── */ +.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s} +.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)} +.btn-delete-small{background:none;border:none;color:rgba(200,50,50,0.6);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0} +.btn-delete-small:hover{color:var(--color-primary)} +.btn-text{background:none;border:none;color:var(--color-muted);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)} +.btn-text:hover{color:var(--color-text)} + +/* ── Kommentare ── */ +.comment-item{display:flex;gap:0.5rem;margin-bottom:0.5rem} +.comment-avatar{width:28px;height:28px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;flex-shrink:0;overflow:hidden} +.comment-avatar img{width:100%;height:100%;object-fit:cover} +.comment-body{flex:1;background:rgba(255,255,255,0.04);border-radius:6px;padding:0.5rem 0.65rem} +.comment-author{font-size:0.8rem;font-weight:600;color:var(--color-text)} +.comment-date{font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem} +.comment-text{font-size:0.85rem;color:rgba(255,255,255,0.75);margin-top:0.2rem;line-height:1.45;white-space:pre-wrap;word-break:break-word} +.comment-actions{display:flex;gap:0.4rem;margin-top:0.3rem;align-items:center} +.replies-section{margin-top:0.5rem;padding-left:0.5rem;border-left:2px solid rgba(255,255,255,0.06)} +.comment-write{display:flex;gap:0.4rem;margin-top:0.5rem} +.comment-write input{flex:1;padding:0.4rem 0.75rem;font-size:0.85rem} +.comment-write button{width:auto;padding:0.4rem 0.75rem;font-size:0.82rem;white-space:nowrap} +`; + document.head.appendChild(s); +})(); + +// ── HTML-Escape ──────────────────────────────────────────────────────────────── +function esc(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/\n/g, '
            '); +} + +// ── Datum-Format ────────────────────────────────────────────────────────────── +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +// ── Emoji-Picker ────────────────────────────────────────────────────────────── +const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩']; +let _emojiTarget = null; + +function toggleEmojiPicker(btn, targetId) { + _emojiTarget = document.getElementById(targetId); + let picker = document.getElementById('sharedEmojiPicker'); + if (!picker) { + picker = document.createElement('div'); + picker.id = 'sharedEmojiPicker'; + picker.style.cssText = 'position:fixed;z-index:9000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);'; + EMOJIS.forEach(em => { + const b = document.createElement('button'); + b.textContent = em; + b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;'; + b.onclick = e => { e.stopPropagation(); insertEmoji(em); }; + picker.appendChild(b); + }); + document.body.appendChild(picker); + } + if (picker.style.display === 'flex') { picker.style.display = 'none'; return; } + picker.style.display = 'flex'; + requestAnimationFrame(() => { + const rect = btn.getBoundingClientRect(); + const ph = picker.offsetHeight, pw = picker.offsetWidth; + let top = rect.top - ph - 8; + let left = rect.left; + if (top < 8) top = rect.bottom + 8; + if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; + picker.style.top = top + 'px'; + picker.style.left = left + 'px'; + }); +} + +function insertEmoji(emoji) { + if (!_emojiTarget) return; + const start = _emojiTarget.selectionStart ?? _emojiTarget.value.length; + const end = _emojiTarget.selectionEnd ?? start; + _emojiTarget.value = _emojiTarget.value.slice(0, start) + emoji + _emojiTarget.value.slice(end); + _emojiTarget.selectionStart = _emojiTarget.selectionEnd = start + emoji.length; + _emojiTarget.focus(); +} + +document.addEventListener('click', e => { + const picker = document.getElementById('sharedEmojiPicker'); + if (picker && picker.style.display === 'flex' + && !picker.contains(e.target) + && !e.target.closest('[onclick*="toggleEmojiPicker"]')) { + picker.style.display = 'none'; + } +}); + +// ── Bild-Karussell ──────────────────────────────────────────────────────────── +function bilderCarousel(bilder) { + if (!bilder || bilder.length === 0) return ''; + if (bilder.length === 1) { + return `
            `; + } + const slides = bilder.map((b, i) => + `
            ` + ).join(''); + return `
            + ${slides} + + +
            1/${bilder.length}
            +
            `; +} + +function carNav(btn, dir) { + const car = btn.closest('.post-carousel'); + const slides = Array.from(car.querySelectorAll('.car-slide')); + const cur = slides.findIndex(s => s.classList.contains('active')); + slides[cur].classList.remove('active'); + const next = (cur + dir + slides.length) % slides.length; + slides[next].classList.add('active'); + const ind = car.querySelector('.car-cur'); + if (ind) ind.textContent = next + 1; +} + +// ── Kommentar-Rendering ─────────────────────────────────────────────────────── +// opts: { myUserId, showReplies } +// Seite muss definieren: deleteKommentar(kommentarId, targetType, targetId) +function renderKommentarHtml(k, targetType, targetId, opts) { + const { myUserId = null, showReplies = false } = opts || {}; + const avatarHtml = k.authorPicture + ? `` + : '◉'; + const canDelete = k.authorId === myUserId; + const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten'; + return `
            +
            ${avatarHtml}
            +
            + ${esc(k.authorName)} + ${fmtDate(k.createdAt)} +
            ${esc(k.text)}
            +
            + + ${showReplies ? `` : ''} + ${canDelete ? `` : ''} +
            + ${showReplies ? `` : ''} +
            +
            `; +} + +function renderReplyHtml(r, parentId) { + const avatarHtml = r.authorPicture + ? `` + : '◉'; + const canDelete = typeof window.myUserId !== 'undefined' && r.authorId === window.myUserId; + return `
            +
            ${avatarHtml}
            +
            + ${esc(r.authorName)} + ${fmtDate(r.createdAt)} +
            ${esc(r.text)}
            +
            + + ${canDelete ? `` : ''} +
            +
            +
            `; +} + +async function toggleKommentarLike(kommentarId) { + await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' }); + const btn = document.getElementById('lk-kom-' + kommentarId); + const lc = document.getElementById('lkc-kom-' + kommentarId); + if (!btn || !lc) return; + const was = btn.classList.contains('liked'); + btn.classList.toggle('liked', !was); + lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1); +} + +async function toggleReplies(kommentarId) { + const section = document.getElementById('replies-' + kommentarId); + if (section.style.display === 'none') { + section.style.display = ''; + await loadReplies(kommentarId); + } else { + section.style.display = 'none'; + } +} + +async function loadReplies(kommentarId) { + const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`); + const replies = await res.json(); + const section = document.getElementById('replies-' + kommentarId); + section.innerHTML = (replies.length === 0 + ? '

            Noch keine Antworten.

            ' + : replies.map(r => renderReplyHtml(r, kommentarId)).join('')) + + `
            + + +
            `; +} + +async function postReply(kommentarId) { + const input = document.getElementById('ri-' + kommentarId); + const text = input.value.trim(); + if (!text) return; + await fetch('/social/kommentare', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text }) + }); + input.value = ''; + await loadReplies(kommentarId); +} + +async function deleteReply(replyId, parentId) { + await fetch('/social/kommentare/' + replyId, { method: 'DELETE' }); + await loadReplies(parentId); +} diff --git a/bin/main/static/js/sidebar.js b/bin/main/static/js/sidebar.js new file mode 100644 index 0000000..668d233 --- /dev/null +++ b/bin/main/static/js/sidebar.js @@ -0,0 +1,254 @@ +(function () { + const path = window.location.pathname; + const I = window.IC || function() { return ''; }; + + const groups = [ + { + label: 'Vanilla Game', + icon: I('VANILLA'), + items: [ + { href: '/games/vanilla/neuvanilla.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navVanillaNeu' }, + { href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navVanillaAktiv' }, + { href: '/games/vanilla/vanillaingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navVanillaImSpiel' }, + { href: '/games/common/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' }, + { href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' }, + { href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' }, + ] + }, + { + label: 'BDSM Game', + icon: I('BDSM'), + items: [ + { href: '/games/bdsm/neubdsm.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navBdsmNeu' }, + { href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navBdsmAktiv' }, + { href: '/games/bdsm/bdsmingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navBdsmImSpiel' }, + { href: '/games/common/aufgaben.html?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' }, + { href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' }, + { href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' }, + ] + }, + { + label: 'Chastity Game', + icon: I('CHASTITY'), + items: [ + { href: '/games/chastity/neulock.html', icon: I('NEW_LOCK'), label: 'Neues Lock', id: 'navChastityNeu' }, + { href: '#', icon: I('ACTIVE_LOCK'), label: 'Aktives Lock', id: 'navChastityAktiv' }, + { href: '/games/chastity/communityvotes.html', icon: I('VOTES'), label: 'Community Votes' }, + { href: '/games/chastity/meine-locks.html', icon: I('LOCK'), label: 'Meine Vorlagen' }, + { href: '/games/chastity/entdecken-vorlagen.html', icon: I('DISCOVER'), label: 'Entdecken' }, + { href: '/games/chastity/keyholder-finden.html', icon: I('FRIENDS'), label: 'Keyholder finden' }, + { href: '/games/chastity/keyholder.html', icon: I('KEY'), label: 'Keyholder' }, + { href: '/games/chastity/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' }, + ] + }, + ]; + + const homeCls = path === '/userhome.html' ? ' class="active"' : ''; + const homeItem = ` + `; + + // ── Community-Links (immer sichtbar, oberhalb der Spiele) ── + const socialLinks = [ + { href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null }, + { href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'}, + { href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null }, + { href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null }, + { href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'}, + { href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null }, + ]; + const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => { + const cls = path === href ? ' class="active"' : ''; + const badge = badgeId ? `` : ''; + return `
          • ${icon} ${label}${badge}
          • `; + }).join(''); + + const fullHref = path + window.location.search; + const nav = groups.map(({ label, icon, items }) => { + const isOpen = items.some(item => item.href === path || item.href === fullHref); + const openCls = isOpen ? ' open' : ''; + const subItems = items.map(({ href, icon: iIcon, label: iLabel, id: iId }) => { + const cls = (href === path || href === fullHref) ? ' class="active"' : ''; + const idAt = iId ? ` id="${iId}"` : ''; + return `${iIcon} ${iLabel}`; + }).join(''); + return ` + `; + }).join(''); + + const adminCls = path === '/admin/admin.html' ? ' class="active"' : ''; + const adminItem = ``; + + const footerLinks = [ + { href: '/help/kontakt.html', icon: '✉️', label: 'Kontakt & Feedback' }, + { href: '/help/impressum.html', icon: '📄', label: 'Impressum' }, + ]; + const footerNav = footerLinks.map(({ href, icon, label }) => { + const cls = path === href ? ' class="active"' : ''; + return `
          • ${icon} ${label}
          • `; + }).join(''); + + document.body.insertAdjacentHTML('afterbegin', ` + + + + `); + + // Sidebar und .main in einen zentrierten App-Wrapper verschieben + const appWrapper = document.createElement('div'); + appWrapper.className = 'app-wrapper'; + const sidebarEl = document.getElementById('sidebar'); + const mainEl = document.querySelector('.main'); + document.body.insertBefore(appWrapper, sidebarEl); + appWrapper.appendChild(sidebarEl); + if (mainEl) appWrapper.appendChild(mainEl); + + // Group toggle + document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => { + toggle.addEventListener('click', e => { + e.preventDefault(); + toggle.closest('.sidebar-group').classList.toggle('open'); + }); + }); + + // "Im Spiel" und "Aktive Session" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet + const navNeu = document.getElementById('navBdsmNeu'); + const navAktiv = document.getElementById('navBdsmAktiv'); + const navImSpiel = document.getElementById('navBdsmImSpiel'); + const navCAktiv = document.getElementById('navChastityAktiv'); + const navVNeu = document.getElementById('navVanillaNeu'); + const navVAktiv = document.getElementById('navVanillaAktiv'); + const navVImSpiel = document.getElementById('navVanillaImSpiel'); + if (navAktiv) navAktiv.style.display = 'none'; + if (navImSpiel) navImSpiel.style.display = 'none'; + if (navCAktiv) navCAktiv.style.display = 'none'; + if (navVAktiv) navVAktiv.style.display = 'none'; + if (navVImSpiel) navVImSpiel.style.display = 'none'; + + // Session-Status prüfen + fetch('/login/me') + .then(r => r.ok ? r.json() : null) + .then(async user => { + if (!user) return; + + // BDSM Session-Status + try { + const aktivRes = await fetch('/bdsm/einladung/meine-aktive'); + if (aktivRes.ok) { + const aktiv = await aktivRes.json(); + if (navNeu) navNeu.style.display = 'none'; + if (navImSpiel) navImSpiel.style.display = 'none'; + if (navAktiv) { + navAktiv.style.display = ''; + navAktiv.querySelector('a').href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html'; + } + } else { + const sessionRes = await fetch(`/bdsm?userId=${user.userId}`); + const hasSession = sessionRes.status === 200; + if (navNeu) navNeu.style.display = hasSession ? 'none' : ''; + if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none'; + } + } catch (_) {} + + // Vanilla Session-Status + try { + const vAktivRes = await fetch('/vanilla/einladung/meine-aktive'); + if (vAktivRes.ok) { + const vAktiv = await vAktivRes.json(); + if (navVNeu) navVNeu.style.display = 'none'; + if (navVImSpiel) navVImSpiel.style.display = 'none'; + if (navVAktiv) { + navVAktiv.style.display = ''; + navVAktiv.querySelector('a').href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html'; + } + } else { + const vSessionRes = await fetch(`/vanilla?userId=${user.userId}`); + const vHasSession = vSessionRes.status === 200; + if (navVNeu) navVNeu.style.display = vHasSession ? 'none' : ''; + if (navVImSpiel) navVImSpiel.style.display = vHasSession ? '' : 'none'; + } + } catch (_) {} + + // Chastity Lock-Status + try { + const lockRes = await fetch('/keyholder/mylock'); + if (lockRes.ok) { + const lockData = await lockRes.json(); + if (navCAktiv) { + navCAktiv.style.display = ''; + navCAktiv.querySelector('a').href = '/games/chastity/activelock.html?lockId=' + lockData.lockId; + } + } + } catch (_) {} + + // Admin-Link + if (user.admin) { + const navAdminLink = document.getElementById('navAdminLink'); + const navAdminDivider = document.getElementById('navAdminDivider'); + if (navAdminLink) navAdminLink.style.display = ''; + if (navAdminDivider) navAdminDivider.style.display = ''; + } + }) + .catch(() => {}); + + const sidebar = document.getElementById('sidebar'); + const burgerBtn = document.getElementById('burgerBtn'); + const overlay = document.getElementById('sidebarOverlay'); + + function openMenu() { + sidebar.classList.add('open'); + overlay.classList.add('visible'); + burgerBtn.classList.add('open'); + burgerBtn.setAttribute('aria-label', 'Menü schließen'); + } + + function closeMenu() { + sidebar.classList.remove('open'); + overlay.classList.remove('visible'); + burgerBtn.classList.remove('open'); + burgerBtn.setAttribute('aria-label', 'Menü öffnen'); + } + + burgerBtn.addEventListener('click', () => + sidebar.classList.contains('open') ? closeMenu() : openMenu() + ); + overlay.addEventListener('click', closeMenu); + sidebar.querySelectorAll('a:not(.sidebar-group-toggle)').forEach(l => + l.addEventListener('click', () => { + if (window.innerWidth <= (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768)) + closeMenu(); + }) + ); + + // Topbar und Social-Sidebar nachladen + function loadScript(src) { + const s = document.createElement('script'); + s.src = src; + document.head.appendChild(s); + } + loadScript('/js/topbar.js'); + loadScript('/js/social-sidebar.js'); +})(); diff --git a/bin/main/static/js/social-sidebar.js b/bin/main/static/js/social-sidebar.js new file mode 100644 index 0000000..12c6246 --- /dev/null +++ b/bin/main/static/js/social-sidebar.js @@ -0,0 +1,109 @@ +(function () { + // Badge + SSE service (kein Sidebar-Rendering mehr) + + // ── Badge-Zähler ── + function setBadge(ids, count, topbarType) { + ids.forEach(id => { + if (!id) return; + const el = document.getElementById(id); + if (!el) return; + el.textContent = count; + el.style.display = count > 0 ? '' : 'none'; + }); + if (topbarType && window.__topbarSetBadge) window.__topbarSetBadge(topbarType, count); + } + + // ── Ton abspielen ── + let userHasInteracted = false; + document.addEventListener('click', () => { userHasInteracted = true; }, { passive: true }); + document.addEventListener('keydown', () => { userHasInteracted = true; }, { passive: true }); + document.addEventListener('touchstart', () => { userHasInteracted = true; }, { passive: true }); + + function playSound(src) { + if (!userHasInteracted) return; + try { + const audio = new Audio(src); + audio.volume = 0.6; + audio.play().catch(() => {}); + } catch(e) {} + } + + // ── Initiale Badge-Counts laden ── + fetch('/social/friends/pending/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialFriendsBadge'], n, null)) + .catch(() => {}); + + fetch('/social/messages/unread/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialMsgBadge'], n, 'msg')) + .catch(() => {}); + + fetch('/notifications/unread/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialNotifBadge'], n, 'notif')) + .catch(() => {}); + + Promise.all([ + fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([joins, reports]) => setBadge(['socialGruppenBadge'], joins + reports, null)) + .catch(() => {}); + + Promise.all([ + fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([kh, lockee, bdsm, vanilla]) => + setBadge(['socialInvBadge'], kh + lockee + bdsm + vanilla, 'inv') + ).catch(() => {}); + + // ── SSE: Echtzeit-Push vom Server ── + function connectSse() { + const es = new EventSource('/events/stream'); + + es.addEventListener('DM', e => { + try { + const data = JSON.parse(e.data); + setBadge(['socialMsgBadge'], data.unreadCount || 0, 'msg'); + if (window.location.pathname !== '/community/nachrichten.html') { + playSound('/audio/message.mp3'); + } + if (typeof window.__sseOnDm === 'function') window.__sseOnDm(data); + } catch(ex) {} + }); + + es.addEventListener('NOTIFICATION', e => { + try { + const data = JSON.parse(e.data); + setBadge(['socialNotifBadge'], data.unreadCount || 0, 'notif'); + if (window.location.pathname !== '/community/benachrichtigungen.html') { + playSound('/audio/notification.mp3'); + } + if (typeof window.__sseOnNotification === 'function') window.__sseOnNotification(data); + } catch(ex) {} + }); + + es.addEventListener('INVITATION', () => { + try { + if (typeof window.__topbarReloadInvBadge === 'function') window.__topbarReloadInvBadge(); + } catch(ex) {} + }); + + es.onerror = () => { + es.close(); + // Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session) + setTimeout(() => { + fetch('/login/me', { method: 'GET' }) + .then(r => { if (r.ok) connectSse(); }) + .catch(() => {}); + }, 5000); + }; + } + + // SSE nur starten wenn authentifiziert – verhindert Fehler-Spam bei nicht eingeloggten Seiten + fetch('/login/me', { method: 'GET' }) + .then(r => { if (r.ok) connectSse(); }) + .catch(() => {}); +})(); diff --git a/bin/main/static/js/topbar.js b/bin/main/static/js/topbar.js new file mode 100644 index 0000000..411428e --- /dev/null +++ b/bin/main/static/js/topbar.js @@ -0,0 +1,405 @@ +(function () { + if (document.querySelector('.topbar')) return; + + function esc(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ── Warten bis app-wrapper existiert (sidebar.js läuft synchron davor) ── + function init() { + const appWrapper = document.querySelector('.app-wrapper'); + if (!appWrapper) { setTimeout(init, 30); return; } + injectHTML(appWrapper); + loadProfile(); + setupSearch(); + setupOverlayButtons(); + loadInitialBadges(); + } + setTimeout(init, 0); + + // ── HTML Struktur ── + function injectHTML(appWrapper) { + const topbar = document.createElement('div'); + topbar.className = 'topbar'; + topbar.id = 'topbar'; + topbar.innerHTML = ` +
            + xXx Sphere +
            +
            + ${IC('SEARCH')} + +
            +
            +
            + + + + +
            `; + appWrapper.insertAdjacentElement('beforebegin', topbar); + + // Panel-Overlays am Ende von body einfügen + document.body.insertAdjacentHTML('beforeend', ` +
            +
            + ${IC('MESSAGES')} Nachrichten + +
            +
            + +
            + +
            +
            + ${IC('NOTIFICATIONS')} Benachrichtigungen + +
            +
            + +
            + +
            +
            + ${IC('INVITATIONS')} Einladungen + +
            +
            + +
            + + + `); + } + + function IC(key) { return window.IC ? window.IC(key) : (window.ICONS?.[key]?.value || ''); } + + // ── Profil laden ── + function loadProfile() { + fetch('/login/me') + .then(r => r.ok ? r.json() : null) + .then(user => { + if (!user) return; + const nameEl = document.getElementById('topbarUsername'); + if (nameEl) nameEl.textContent = user.name; + + const avatarWrap = document.getElementById('topbarAvatarWrap'); + if (avatarWrap && user.profilePicture) { + avatarWrap.innerHTML = ``; + } + const panelName = document.getElementById('topbarPanelName'); + if (panelName) panelName.textContent = user.name; + + const panelAvatar = document.getElementById('topbarPanelAvatarWrap'); + if (panelAvatar && user.profilePicture) { + panelAvatar.innerHTML = ``; + } + const profileLink = document.getElementById('topbarProfileLink'); + if (profileLink && user.userId) profileLink.href = '/community/benutzer.html?userId=' + user.userId; + }) + .catch(() => {}); + } + + // ── Suche ── + function setupSearch() { + const input = document.getElementById('topbarSearchInput'); + const overlay = document.getElementById('topbarSearchOverlay'); + if (!input || !overlay) return; + + let timer; + input.addEventListener('input', () => { + clearTimeout(timer); + const q = input.value.trim(); + if (q.length < 2) { overlay.innerHTML = ''; overlay.classList.remove('open'); return; } + overlay.innerHTML = '
            Suche…
            '; + overlay.classList.add('open'); + timer = setTimeout(() => doSearch(q, overlay), 300); + }); + + document.addEventListener('click', e => { + if (!e.target.closest('.topbar-search-wrap')) { + overlay.classList.remove('open'); + } + }); + } + + async function doSearch(q, overlay) { + try { + const res = await fetch('/social/users/search?q=' + encodeURIComponent(q)); + if (!res.ok) { overlay.innerHTML = '
            Fehler bei der Suche.
            '; return; } + const users = await res.json(); + if (!users || users.length === 0) { + overlay.innerHTML = '
            Keine Ergebnisse.
            '; + return; + } + overlay.innerHTML = users.map(u => { + const av = u.profilePicture + ? `` + : `${IC('PROFILE')}`; + return ` + ${av} + ${esc(u.name)} + `; + }).join(''); + } catch (e) { + overlay.innerHTML = '
            Fehler bei der Suche.
            '; + } + } + + // ── Panel-Overlays ── + let _activePanel = null; + + function positionPanel(panel, btn) { + const topbar = document.getElementById('topbar'); + const tRect = topbar ? topbar.getBoundingClientRect() : btn.getBoundingClientRect(); + panel.style.top = tRect.bottom + 'px'; + panel.style.right = Math.max(4, window.innerWidth - tRect.right) + 'px'; + panel.style.left = 'auto'; + } + + function openPanel(panelId, btnId, loadFn) { + const panel = document.getElementById(panelId); + const btn = document.getElementById(btnId); + if (!panel || !btn) return; + if (_activePanel === panel && panel.classList.contains('open')) { + closeAllPanels(); return; + } + closeAllPanels(); + positionPanel(panel, btn); + panel.classList.add('open'); + _activePanel = panel; + if (loadFn) loadFn(); + } + + function closeAllPanels() { + document.querySelectorAll('.topbar-panel.open').forEach(p => p.classList.remove('open')); + _activePanel = null; + } + + window.__topbarCloseAll = closeAllPanels; + + document.addEventListener('click', e => { + if (!e.target.closest('.topbar-panel') && !e.target.closest('.topbar-btn')) + closeAllPanels(); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllPanels(); }); + + function setupOverlayButtons() { + const msgBtn = document.getElementById('topbarMsgBtn'); + const notifBtn = document.getElementById('topbarNotifBtn'); + const invBtn = document.getElementById('topbarInvBtn'); + const profileBtn = document.getElementById('topbarProfileBtn'); + if (msgBtn) msgBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarMsgPanel', 'topbarMsgBtn', loadMessages); }); + if (notifBtn) notifBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarNotifPanel', 'topbarNotifBtn', loadNotifications); }); + if (invBtn) invBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarInvPanel', 'topbarInvBtn', loadInvitations); }); + if (profileBtn) profileBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarProfilePanel', 'topbarProfileBtn', null); }); + } + + // ── Nachrichten ── + async function loadMessages() { + const body = document.getElementById('topbarMsgBody'); + if (!body) return; + body.innerHTML = '
            Wird geladen…
            '; + try { + const res = await fetch('/social/messages'); + if (!res.ok) { body.innerHTML = '
            Keine Nachrichten.
            '; return; } + const convos = await res.json(); + if (!convos.length) { body.innerHTML = '
            Noch keine Nachrichten.
            '; return; } + body.innerHTML = convos.slice(0, 7).map(c => { + const av = c.partner?.profilePicture + ? `` + : `${IC('PROFILE')}`; + const bold = c.unreadCount > 0 ? 'font-weight:700;' : ''; + const badge = c.unreadCount > 0 + ? `${c.unreadCount > 99 ? '99+' : c.unreadCount}` : ''; + return ` + ${av} +
            +
            ${esc(c.partner?.name || '')}
            +
            ${esc(c.lastMessage?.text || '')}
            +
            + ${badge} +
            `; + }).join(''); + } catch (e) { body.innerHTML = '
            Fehler beim Laden.
            '; } + } + + // ── Benachrichtigungen ── + async function loadNotifications() { + const body = document.getElementById('topbarNotifBody'); + if (!body) return; + body.innerHTML = '
            Wird geladen…
            '; + try { + const res = await fetch('/notifications'); + if (!res.ok) { body.innerHTML = '
            Keine Benachrichtigungen.
            '; return; } + const unread = (await res.json()).filter(n => !n.read); + if (!unread.length) { body.innerHTML = '
            Keine neuen Benachrichtigungen.
            '; return; } + body.innerHTML = ''; + unread.forEach(n => { + const el = document.createElement('div'); + const tag = n.targetUrl ? 'a' : 'div'; + const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : ''; + const av = n.senderAvatar + ? `` + : `${IC('PROFILE')}`; + el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${n.read ? '' : ' topbar-notif-item--unread'}"> + ${av} +
            +
            ${esc(n.text)}
            +
            ${n.sentAt ? new Date(n.sentAt).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'}) : ''}
            +
            + `; + body.appendChild(el.firstElementChild); + }); + // Alle als gelesen markieren + fetch('/notifications/read-all', { method: 'POST' }).then(() => setTopbarBadge('notif', 0)).catch(() => {}); + } catch (e) { body.innerHTML = '
            Fehler beim Laden.
            '; } + } + + window.__topbarMarkNotifRead = async function (id) { + try { + await fetch('/notifications/' + id + '/read', { method: 'POST' }); + const el = document.querySelector(`.topbar-notif-item--unread[onclick*="${id}"]`); + if (el) el.classList.remove('topbar-notif-item--unread'); + const r = await fetch('/notifications/unread/count'); + if (r.ok) setTopbarBadge('notif', await r.json()); + } catch (e) {} + }; + + window.__topbarMarkAllRead = async function () { + try { + await fetch('/notifications/read-all', { method: 'POST' }); + setTopbarBadge('notif', 0); + loadNotifications(); + } catch (e) {} + }; + + // ── Einladungen ── + async function loadInvitations() { + const body = document.getElementById('topbarInvBody'); + if (!body) return; + body.innerHTML = '
            Wird geladen…
            '; + try { + const [lr, kr, br, vr] = await Promise.all([ + fetch('/lockee/invitations/mine'), + fetch('/keyholder/invitations/mine'), + fetch('/bdsm/einladung/pending'), + fetch('/vanilla/einladung/pending') + ]); + const lockee = lr.ok ? await lr.json() : []; + const kh = kr.ok ? await kr.json() : []; + const bdsm = br.ok ? await br.json() : []; + const vanilla = vr.ok ? await vr.json() : []; + const all = [ + ...lockee.map(i => ({ ...i, _type: 'lockee' })), + ...kh.map(i => ({ ...i, _type: 'keyholder' })), + ...bdsm.map(i => ({ ...i, _type: 'bdsm' })), + ...vanilla.map(i => ({ ...i, _type: 'vanilla' })) + ]; + if (!all.length) { body.innerHTML = '
            Keine offenen Einladungen.
            '; return; } + body.innerHTML = ''; + all.forEach(inv => body.appendChild(buildInvCard(inv))); + } catch (e) { body.innerHTML = '
            Fehler beim Laden.
            '; } + } + + function buildInvCard(inv) { + let typeIcon, typeName, line; + + if (inv._type === 'lockee') { + typeIcon = IC('LOCK'); typeName = 'Lockee-Einladung'; line = inv.lockName || 'Lock'; + } else if (inv._type === 'keyholder') { + typeIcon = IC('KEY'); typeName = 'Keyholder-Einladung'; line = inv.lockName || 'Lock'; + } else if (inv._type === 'vanilla') { + typeIcon = IC('INVITATIONS'); typeName = 'Vanilla Game'; line = inv.inviterName || 'Einladung'; + } else { + typeIcon = IC('BDSM'); typeName = 'BDSM Game'; line = inv.senderName || 'Einladung'; + } + + const senderPic = inv.senderAvatar || inv.lockOwnerAvatar || inv.inviterAvatar; + const av = senderPic + ? `` + : `${IC('PROFILE')}`; + + const div = document.createElement('div'); + div.className = 'topbar-panel-item topbar-inv-card'; + div.style.cursor = 'pointer'; + div.innerHTML = `${av} +
            +
            ${typeIcon} ${typeName}
            +
            ${esc(line)}
            +
            `; + div.addEventListener('click', () => { window.location.href = '/games/common/einladungen.html'; }); + return div; + } + + // ── Badge-Verwaltung ── + function setTopbarBadge(type, count) { + const map = { msg: 'topbarMsgBadge', notif: 'topbarNotifBadge', inv: 'topbarInvBadge' }; + const el = document.getElementById(map[type]); + if (!el) return; + el.textContent = count > 99 ? '99+' : count; + el.style.display = count > 0 ? 'inline-block' : 'none'; + } + + // Für social-sidebar.js zugänglich + window.__topbarSetBadge = setTopbarBadge; + + function reloadInvBadge() { + Promise.all([ + fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([l, k, b, v]) => setTopbarBadge('inv', l + k + b + v)).catch(() => {}); + } + window.__topbarReloadInvBadge = reloadInvBadge; + + function loadInitialBadges() { + fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {}); + fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {}); + reloadInvBadge(); + } +})(); diff --git a/bin/main/static/konto/einstellungen.html b/bin/main/static/konto/einstellungen.html new file mode 100644 index 0000000..d76ce54 --- /dev/null +++ b/bin/main/static/konto/einstellungen.html @@ -0,0 +1,1323 @@ + + + + + + + Einstellungen – xXx Sphere + + + + + +
            +
            +

            ⚙️ Einstellungen

            + + +
            +
            + 👤 Grunddaten + +
            +
            + +
            +
            +
            Nickname
            +
            +
            + +
            + +
            +
            +
            E-Mail
            +
            +
            + +
            + +
            +
            +
            Geburtsdatum
            +
            +
            + +
            + +
            +
            +
            Konto löschen
            +
            Alle Daten werden unwiderruflich gelöscht
            +
            + +
            + +
            +
            + + +
            +
            + 🕹️ Spiel Einstellungen + +
            +
            + + +
            +
            BDSM Game
            + +
            +
            Spiele mit Geschlecht
            +
            + + + +
            +
            + +
            +
            Meine Rollen
            +
            + + + + +
            +
            + +
            +
            Was ich einsetze
            +
            + + + + + +
            +
            +
            + +
            +
            + + +
            +
            + 🔔 Benachrichtigungen + +
            +
            +
            + + +
            +
            +
            In-App
            +
            E-Mail
            +
            + + +
            +
            +
            Einladungen
            +
            Einladungen zu Locks und Spielen, Annahmen und Ablehnungen
            +
            +
            + +
            +
            + +
            +
            + + +
            +
            +
            Spielstatus
            +
            Karten, Aufgaben, Verifikationen, Einfrierungen und andere Spielereignisse
            +
            +
            + +
            +
            + +
            +
            + + +
            +
            +
            Notfall
            +
            Notfall-Entsperrungen und dringende Meldungen
            +
            +
            + +
            +
            + +
            +
            + + +
            +
            +
            Freundschaftsanfragen
            +
            Neue Freundschaftsanfragen von anderen Nutzern
            +
            +
            + +
            +
            + +
            +
            + +
            +
            +
            + + +
            +
            + ⭐ Abonnements + +
            +
            +
            +
            +
            Aktuelles Abo
            +
            Wird geladen…
            +
            + +
            + + +
            +
            + + +
            +
            + 🛡️ Datenschutz + +
            +
            + + +
            +
            +
            Grunddaten
            +
            Alter, Größe, Gewicht, Geschlecht, Neigung, Beziehungsstatus, Beschreibung
            +
            + +
            + + +
            +
            +
            Galerie
            +
            Fotos auf dem Profil
            +
            + +
            + + +
            +
            +
            Freundesliste
            +
            Wer kann sehen, wer deine Freunde sind
            +
            + +
            + + +
            +
            +
            Feed / Posts
            +
            Posts auf dem Profil-Tab
            +
            + +
            + + +
            +
            +
            Pinnwand
            +
            Einträge auf der Pinnwand
            +
            + +
            + + +
            +
            +
            XP-Punkte
            +
            Lockee-XP und Keyholder-XP
            +
            + +
            + + +
            +
            +
            Lock-Historie
            +
            Abgeschlossene Locks und Keyholder-Aktivitäten
            +
            + +
            + + +
            +
            +
            Vorlieben
            +
            Deine Vorlieben und Bewertungen im Profil
            +
            + +
            + + +
            +
            +
            Profil bei Veröffentlichungen sichtbar
            +
            Dein Name wird bei veröffentlichten Lock-Vorlagen angezeigt
            +
            + +
            + +
            + + +
            + Profil-Vorschau – wie sieht mein Profil für andere aus? + + +
            + +
            +
            + + +
            +
            + 🔒 TTLock + +
            +
            +

            + Verknüpfe deinen TTLock-Account, um deine physische Schlüsselbox direkt über das Spiel zu steuern. +

            + + + +
            +
            Benutzername (E-Mail)
            + +
            + +
            +
            Passwort
            + +
            + +
            +
            Lock-ID
            + +
            + +
            + +
            + Verbindungsstatus: + +
            + +
            + + + +
            + + + + + +
            + + +
            +
            + +
            +
            + + + + + + + + + + + + + + + + + + + +
            ✓ Gespeichert
            + + + + + + + diff --git a/bin/main/static/konto/profile.html b/bin/main/static/konto/profile.html new file mode 100644 index 0000000..92369fb --- /dev/null +++ b/bin/main/static/konto/profile.html @@ -0,0 +1,766 @@ + + + + + + + Profil – xXx Sphere + + + + + + +
            +
            + +
            +
            + +
            + + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            0 / 600
            +
            +
            + +
            + + + + +

            Wähle für jede Vorliebe aus, wie du dazu stehst. Nicht ausgefüllte Einträge werden nicht angezeigt.

            +

            Wird geladen…

            + + + + + +
            +
            +
            + + + + + + + + diff --git a/bin/main/static/login.html b/bin/main/static/login.html new file mode 100644 index 0000000..7d01738 --- /dev/null +++ b/bin/main/static/login.html @@ -0,0 +1,107 @@ + + + + + + + Login – xXx Sphere + + + + +
            + Logo +

            Bitte melde dich an

            + + + + + + + + + +
            + +

            + Passwort vergessen? +

            +

            + Noch kein Konto? Registrieren +

            +
            + + + + diff --git a/bin/main/static/registration.html b/bin/main/static/registration.html new file mode 100644 index 0000000..3e9293e --- /dev/null +++ b/bin/main/static/registration.html @@ -0,0 +1,130 @@ + + + + + + + Neues Konto erstellen – xXx Sphere + + + + +
            + Logo +

            Neues Konto erstellen

            + + + + + + + + + + + + + + + + + + +
            + +

            + Bereits registriert? Anmelden +

            +
            + + + + diff --git a/bin/main/static/reset-password.html b/bin/main/static/reset-password.html new file mode 100644 index 0000000..01f5b49 --- /dev/null +++ b/bin/main/static/reset-password.html @@ -0,0 +1,142 @@ + + + + + + + Neues Passwort – xXx Sphere + + + + + +
            + Logo +

            Neues Passwort

            +

            Gib dein neues Passwort ein.

            + + + + + + + + + +
            +
            + +
            + +
            + + + + diff --git a/bin/main/static/userhome.html b/bin/main/static/userhome.html new file mode 100644 index 0000000..e3f067b --- /dev/null +++ b/bin/main/static/userhome.html @@ -0,0 +1,87 @@ + + + + + + + Home – xXx Sphere + + + + + +
            +
            +

            Home

            +

            + +
            +
            +
            +

            Vanilla Game

            +

            + Entdecke spielerische Rollenspiele und Aufgaben in einem entspannten Rahmen. + Ideal für den Einstieg – ohne Regeln, nur Spaß zu zweit oder in der Gruppe. +

            + +
            + +
            +
            +

            BDSM Game

            +

            + Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen. + Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinem Partner. +

            + +
            + +
            +
            +

            Chastity Game

            +

            + Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System, + Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig. +

            + +
            +
            +
            +
            + + + + + + diff --git a/bin/main/xxx.jks b/bin/main/xxx.jks new file mode 100644 index 0000000..2d75a54 Binary files /dev/null and b/bin/main/xxx.jks differ diff --git a/bin/test/application-test.properties b/bin/test/application-test.properties new file mode 100644 index 0000000..ebacde7 --- /dev/null +++ b/bin/test/application-test.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR + +jwt.keystore.path=classpath:xxx.jks +jwt.keystore.password=XUR!Rv&f$j3UsqD& +jwt.keystore.alias=xxx + +spring.mail.host=localhost +spring.mail.port=25 diff --git a/bin/test/de/oaa/xxx/XxxThegameApplicationTests.class b/bin/test/de/oaa/xxx/XxxThegameApplicationTests.class new file mode 100644 index 0000000..5ed7699 Binary files /dev/null and b/bin/test/de/oaa/xxx/XxxThegameApplicationTests.class differ diff --git a/bin/test/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.class b/bin/test/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.class new file mode 100644 index 0000000..fd6adb5 Binary files /dev/null and b/bin/test/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.class differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c40335c --- /dev/null +++ b/build.gradle.kts @@ -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 { + options.compilerArgs.add("-parameters") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..e7c2406 --- /dev/null +++ b/deploy.sh @@ -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 ---" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e4c89a7 --- /dev/null +++ b/docker-compose.yml @@ -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-web: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: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..cc61ce4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..4844ffe Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8357d84 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..17a9170 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..27926c9 --- /dev/null +++ b/settings.gradle.kts @@ -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 = "xxx-sphere-web" diff --git a/src/main/java/Ideen.txt b/src/main/java/Ideen.txt new file mode 100644 index 0000000..851ad46 --- /dev/null +++ b/src/main/java/Ideen.txt @@ -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. + + \ No newline at end of file diff --git a/src/main/java/de/oaa/xxx/XxxThegameApplication.java b/src/main/java/de/oaa/xxx/XxxThegameApplication.java new file mode 100644 index 0000000..a2ddc34 --- /dev/null +++ b/src/main/java/de/oaa/xxx/XxxThegameApplication.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/xxx/admin/AdminController.java b/src/main/java/de/oaa/xxx/admin/AdminController.java new file mode 100644 index 0000000..4ab4401 --- /dev/null +++ b/src/main/java/de/oaa/xxx/admin/AdminController.java @@ -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 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> getMeldungen( + @RequestParam(name = "status", required = false) MeldungStatus status, + Principal principal) { + requireAdmin(principal); + List list = status != null + ? meldungRepository.findByStatusOrderByGemeldetAtDesc(status) + : meldungRepository.findAllByOrderByGemeldetAtDesc(); + return ResponseEntity.ok(list.stream().map(this::toMeldungDto).toList()); + } + + @PutMapping("/meldungen/{id}") + public ResponseEntity 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> getAufgabengruppen(Principal principal) { + requireAdmin(principal); + List list = aufgabenGruppeRepository + .findByUserIdIsNull(PageRequest.of(0, 1000)).getContent(); + return ResponseEntity.ok(list.stream().map(AufgabenGruppeEntity::toAufgabenGruppe).toList()); + } + + @PostMapping("/aufgabengruppen") + public ResponseEntity 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 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 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 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> getToys(Principal principal) { + requireAdmin(principal); + List 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 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 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 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> searchUsers( + @RequestParam String q, Principal principal) { + requireSuperAdmin(principal); + if (q == null || q.isBlank()) return ResponseEntity.ok(List.of()); + List 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> searchAllUsers( + @RequestParam String q, Principal principal) { + requireSuperAdmin(principal); + if (q == null || q.isBlank()) return ResponseEntity.ok(List.of()); + List 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> getAdmins(Principal principal) { + requireSuperAdmin(principal); + return ResponseEntity.ok(adminRepository.findAll().stream().map(this::toDto).toList()); + } + + @PostMapping("/admins") + public ResponseEntity 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 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> 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 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 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>> getFeedback(Principal principal) { + requireAdmin(principal); + List ungelesen = feedbackRepository + .findByStatusOrderByEingegangenDesc(FeedbackStatus.UNGELESEN) + .stream().map(this::toFeedbackDto).toList(); + List inArbeit = feedbackRepository + .findByStatusOrderByEingegangenDesc(FeedbackStatus.IN_ARBEIT) + .stream().map(this::toFeedbackDto).toList(); + List 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 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 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 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 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(); + } +} diff --git a/src/main/java/de/oaa/xxx/admin/AdminEntity.java b/src/main/java/de/oaa/xxx/admin/AdminEntity.java new file mode 100644 index 0000000..9088698 --- /dev/null +++ b/src/main/java/de/oaa/xxx/admin/AdminEntity.java @@ -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; + } +} diff --git a/src/main/java/de/oaa/xxx/admin/AdminRepository.java b/src/main/java/de/oaa/xxx/admin/AdminRepository.java new file mode 100644 index 0000000..1a3f831 --- /dev/null +++ b/src/main/java/de/oaa/xxx/admin/AdminRepository.java @@ -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 { + + Optional findByUserId(UUID userId); + + boolean existsByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/admin/AdminRolle.java b/src/main/java/de/oaa/xxx/admin/AdminRolle.java new file mode 100644 index 0000000..18d2f22 --- /dev/null +++ b/src/main/java/de/oaa/xxx/admin/AdminRolle.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.admin; + +public enum AdminRolle { + ADMIN, SUPERADMIN +} diff --git a/src/main/java/de/oaa/xxx/config/JwtFilter.java b/src/main/java/de/oaa/xxx/config/JwtFilter.java new file mode 100644 index 0000000..ab27a0c --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/JwtFilter.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/xxx/config/JwtService.java b/src/main/java/de/oaa/xxx/config/JwtService.java new file mode 100644 index 0000000..9ea02e9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/JwtService.java @@ -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(); + } +} diff --git a/src/main/java/de/oaa/xxx/config/SchemaMigration.java b/src/main/java/de/oaa/xxx/config/SchemaMigration.java new file mode 100644 index 0000000..f3f2087 --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/SchemaMigration.java @@ -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()); + } + } +} diff --git a/src/main/java/de/oaa/xxx/config/SecurityConfig.java b/src/main/java/de/oaa/xxx/config/SecurityConfig.java new file mode 100644 index 0000000..e73b6cf --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/de/oaa/xxx/config/StringListConverter.java b/src/main/java/de/oaa/xxx/config/StringListConverter.java new file mode 100644 index 0000000..dfb2566 --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/StringListConverter.java @@ -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, String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List list) { + if (list == null || list.isEmpty()) return null; + try { + return mapper.writeValueAsString(list); + } catch (Exception e) { + return null; + } + } + + @Override + public List 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(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/config/ThemeController.java b/src/main/java/de/oaa/xxx/config/ThemeController.java new file mode 100644 index 0000000..134f359 --- /dev/null +++ b/src/main/java/de/oaa/xxx/config/ThemeController.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/xxx/emailchange/EmailChangeController.java b/src/main/java/de/oaa/xxx/emailchange/EmailChangeController.java new file mode 100644 index 0000000..6627313 --- /dev/null +++ b/src/main/java/de/oaa/xxx/emailchange/EmailChangeController.java @@ -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 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"); + } +} diff --git a/src/main/java/de/oaa/xxx/emailchange/EmailChangeEntity.java b/src/main/java/de/oaa/xxx/emailchange/EmailChangeEntity.java new file mode 100644 index 0000000..0c29b73 --- /dev/null +++ b/src/main/java/de/oaa/xxx/emailchange/EmailChangeEntity.java @@ -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; + } +} diff --git a/src/main/java/de/oaa/xxx/emailchange/EmailChangeRepository.java b/src/main/java/de/oaa/xxx/emailchange/EmailChangeRepository.java new file mode 100644 index 0000000..c941e81 --- /dev/null +++ b/src/main/java/de/oaa/xxx/emailchange/EmailChangeRepository.java @@ -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 { + + Optional findByUserEmail(String userEmail); +} diff --git a/src/main/java/de/oaa/xxx/feed/FeedController.java b/src/main/java/de/oaa/xxx/feed/FeedController.java new file mode 100644 index 0000000..6b9b339 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/FeedController.java @@ -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 posts, boolean hasMore) {} + record VoteRequest(UUID optionId) {} + + // ── POST /feed/posts ── + + @PostMapping("/posts") + public ResponseEntity 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 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 friendIds = friendshipRepository + .findFriends(myId, FriendshipEntity.Status.ACCEPTED) + .stream() + .map(f -> f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId()) + .toList(); + + List authorIds = new ArrayList<>(friendIds); + authorIds.add(myId); + + // Collect group IDs + List gruppeIds = mitgliedRepository.findByUserId(myId) + .stream() + .map(m -> m.getGruppeId()) + .toList(); + + LocalDateTime since = LocalDateTime.now().minusDays(90); + + // Fetch feed posts from friends + self + List feedPosts = feedPostRepository + .findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(authorIds, since); + + // Fetch gruppe posts + List gruppePosts = gruppeIds.isEmpty() ? List.of() : + gruppenbeitragRepository.findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(gruppeIds, since); + + // Merge, convert, sort + List 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 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 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 slice = feedPostRepository + .findByIsPublicTrueOrderByCreatedAtDesc(PageRequest.of(page, size)); + + List 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 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 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 nextPage; + if (myId.equals(userId)) { + nextPage = feedPostRepository.findByAuthorIdOrderByCreatedAtDesc(userId, nextPageable); + } else { + nextPage = feedPostRepository.findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(userId, nextPageable); + } + + List 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 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 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 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 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 optionen = List.of(); + List 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 optionen = List.of(); + List 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 + ); + } +} diff --git a/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java b/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java new file mode 100644 index 0000000..67e1122 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/dto/FeedItemDto.java @@ -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 bilder, + LocalDateTime createdAt, + long likeCount, + boolean likedByMe, + long kommentarCount, + List optionen, + List myVoteOptionIds, + boolean isPublic +) {} diff --git a/src/main/java/de/oaa/xxx/feed/dto/FeedPostRequest.java b/src/main/java/de/oaa/xxx/feed/dto/FeedPostRequest.java new file mode 100644 index 0000000..1964aae --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/dto/FeedPostRequest.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.feed.dto; + +import java.util.List; + +public record FeedPostRequest( + String beitragTyp, + String text, + Boolean multiChoice, + List optionen, + List bilder, + boolean isPublic +) {} diff --git a/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java b/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java new file mode 100644 index 0000000..cb682f8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/entity/FeedPostEntity.java @@ -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 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; +} diff --git a/src/main/java/de/oaa/xxx/feed/entity/FeedPostLikeEntity.java b/src/main/java/de/oaa/xxx/feed/entity/FeedPostLikeEntity.java new file mode 100644 index 0000000..85af4f2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/entity/FeedPostLikeEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/feed/entity/FeedPostOptionEntity.java b/src/main/java/de/oaa/xxx/feed/entity/FeedPostOptionEntity.java new file mode 100644 index 0000000..87e9639 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/entity/FeedPostOptionEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/feed/entity/FeedPostVoteEntity.java b/src/main/java/de/oaa/xxx/feed/entity/FeedPostVoteEntity.java new file mode 100644 index 0000000..107ae80 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/entity/FeedPostVoteEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/feed/repository/FeedPostLikeRepository.java b/src/main/java/de/oaa/xxx/feed/repository/FeedPostLikeRepository.java new file mode 100644 index 0000000..038ad15 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/repository/FeedPostLikeRepository.java @@ -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 { + + Optional findByPostIdAndUserId(UUID postId, UUID userId); + + long countByPostId(UUID postId); + + @Transactional + void deleteByPostId(UUID postId); +} diff --git a/src/main/java/de/oaa/xxx/feed/repository/FeedPostOptionRepository.java b/src/main/java/de/oaa/xxx/feed/repository/FeedPostOptionRepository.java new file mode 100644 index 0000000..52282df --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/repository/FeedPostOptionRepository.java @@ -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 { + + List findByPostIdOrderByReihenfolge(UUID postId); + + @Transactional + void deleteByPostId(UUID postId); +} diff --git a/src/main/java/de/oaa/xxx/feed/repository/FeedPostRepository.java b/src/main/java/de/oaa/xxx/feed/repository/FeedPostRepository.java new file mode 100644 index 0000000..d29618c --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/repository/FeedPostRepository.java @@ -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 { + + Slice findByIsPublicTrueOrderByCreatedAtDesc(Pageable pageable); + + List findByAuthorIdInAndCreatedAtAfterOrderByCreatedAtDesc(List authorIds, LocalDateTime since); + + List findByAuthorIdAndIsPublicTrueOrderByCreatedAtDesc(UUID authorId, Pageable pageable); + + List findByAuthorIdOrderByCreatedAtDesc(UUID authorId, Pageable pageable); + + @Transactional + void deleteByAuthorId(UUID authorId); +} diff --git a/src/main/java/de/oaa/xxx/feed/repository/FeedPostVoteRepository.java b/src/main/java/de/oaa/xxx/feed/repository/FeedPostVoteRepository.java new file mode 100644 index 0000000..34d31f2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feed/repository/FeedPostVoteRepository.java @@ -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 { + + List findByPostIdAndUserId(UUID postId, UUID userId); + + Optional findByOptionIdAndUserId(UUID optionId, UUID userId); + + long countByOptionId(UUID optionId); + + @Transactional + void deleteByPostId(UUID postId); +} diff --git a/src/main/java/de/oaa/xxx/feedback/FeedbackController.java b/src/main/java/de/oaa/xxx/feedback/FeedbackController.java new file mode 100644 index 0000000..59c7887 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feedback/FeedbackController.java @@ -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 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( + "Von: " + esc(req.name()) + "
            " + + "Seite: " + esc(req.seite()) + "
            " + + "Grund: " + esc(req.grund()) + "

            " + + "Nachricht:
            " + esc(req.text()).replace("\n", "
            ") + ); + 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("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/de/oaa/xxx/feedback/FeedbackEntity.java b/src/main/java/de/oaa/xxx/feedback/FeedbackEntity.java new file mode 100644 index 0000000..af061e4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feedback/FeedbackEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/feedback/FeedbackRepository.java b/src/main/java/de/oaa/xxx/feedback/FeedbackRepository.java new file mode 100644 index 0000000..b2f465a --- /dev/null +++ b/src/main/java/de/oaa/xxx/feedback/FeedbackRepository.java @@ -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 { + + List findByStatusOrderByEingegangenDesc(FeedbackStatus status); +} diff --git a/src/main/java/de/oaa/xxx/feedback/FeedbackStatus.java b/src/main/java/de/oaa/xxx/feedback/FeedbackStatus.java new file mode 100644 index 0000000..b6e0197 --- /dev/null +++ b/src/main/java/de/oaa/xxx/feedback/FeedbackStatus.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.feedback; + +public enum FeedbackStatus { + UNGELESEN, + IN_ARBEIT, + BEANTWORTET +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/AktiveSperre.java b/src/main/java/de/oaa/xxx/games/bdsm/AktiveSperre.java new file mode 100644 index 0000000..f935298 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/AktiveSperre.java @@ -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 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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/AufgabeAnzeige.java b/src/main/java/de/oaa/xxx/games/bdsm/AufgabeAnzeige.java new file mode 100644 index 0000000..5972b6d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/AufgabeAnzeige.java @@ -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) + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/AufgabeArt.java b/src/main/java/de/oaa/xxx/games/bdsm/AufgabeArt.java new file mode 100644 index 0000000..2444f20 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/AufgabeArt.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.games.bdsm; + +public enum AufgabeArt { + AUFGABE, + STRAFE, + SPERRE; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/BdsmGame.java b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGame.java new file mode 100644 index 0000000..7ac64a4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGame.java @@ -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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.java b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.java new file mode 100644 index 0000000..abf614c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameDurchfuehren.java @@ -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 mitspieler = new ArrayList<>(); + private final List 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> 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 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 getFinisher() { + var list = new ArrayList(); + 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 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 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 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 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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameService.java b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameService.java new file mode 100644 index 0000000..21fd5cc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/BdsmGameService.java @@ -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); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/BdsmMitspieler.java b/src/main/java/de/oaa/xxx/games/bdsm/BdsmMitspieler.java new file mode 100644 index 0000000..c2d5166 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/BdsmMitspieler.java @@ -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 spieltMit; + private List rollen; + private List 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/Callback.java b/src/main/java/de/oaa/xxx/games/bdsm/Callback.java new file mode 100644 index 0000000..0f83c59 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/Callback.java @@ -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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/GeschlechtEnum.java b/src/main/java/de/oaa/xxx/games/bdsm/GeschlechtEnum.java new file mode 100644 index 0000000..7851012 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/GeschlechtEnum.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.games.bdsm; + +public enum GeschlechtEnum { + WEIBLICH, + DIVERS, + MAENNLICH; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/RolleEnum.java b/src/main/java/de/oaa/xxx/games/bdsm/RolleEnum.java new file mode 100644 index 0000000..cb19db6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/RolleEnum.java @@ -0,0 +1,8 @@ +package de.oaa.xxx.games.bdsm; + +public enum RolleEnum { + BESTRAFUNG_AKTIV, + BESTRAFUNG_PASSIV, + AUFGABE_AKTIV, + AUFGABE_PASSIV; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/AboController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/AboController.java new file mode 100644 index 0000000..285152e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/AboController.java @@ -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 listSubscribed( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size, + Principal principal) { + UserEntity user = userService.requireUser(principal); + + List 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 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 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 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 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 all, int page, int size) { + int total = all.size(); + int start = page * size; + List 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; + } + +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabeController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabeController.java new file mode 100644 index 0000000..9a32775 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabeController.java @@ -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 get(@PathVariable UUID aufgabeId) { + return aufgabeRepository.findById(aufgabeId) + .map(entity -> ResponseEntity.ok(entity.toAufgabe())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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 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 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.java new file mode 100644 index 0000000..6155252 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/AufgabenGruppeController.java @@ -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 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 result = gruppeRepository.findByUserId( + user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); + return ResponseEntity.ok(toGruppePage(result, true)); + } + + @GetMapping("/list/system") + public ResponseEntity listSystem( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { + Page result = gruppeRepository.findByUserIdIsNull( + PageRequest.of(page, size, Sort.by("name"))); + return ResponseEntity.ok(toGruppePage(result)); + } + + // ── Bestehende Endpunkte ── + + @GetMapping("/all") + public ResponseEntity 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 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 get(@PathVariable UUID gruppeId) { + return gruppeRepository.findById(gruppeId) + .map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe())) + .orElse(ResponseEntity.noContent().build()); + } + + // ── Anlegen ── + + @PostMapping + public ResponseEntity 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 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 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 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 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 page) { + return toGruppePage(page, false); + } + + private AufgabenGruppePage toGruppePage(Page 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.java new file mode 100644 index 0000000..2ec9d67 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmEinladungController.java @@ -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> 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 result = new LinkedHashMap<>(); + result.put("einladungId", entity.getEinladungId()); + return ResponseEntity.ok(result); + } + + @DeleteMapping("/{id}") + public ResponseEntity 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>> getBySetupId(@RequestParam UUID setupId, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findBySetupId(setupId).stream() + .map(this::toMap).toList(); + return ResponseEntity.ok(list); + } + + @GetMapping("/meine-aktive") + public ResponseEntity> 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 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>> getPending(Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findByInviteeIdAndStatus(userId, Status.PENDING) + .stream().map(e -> { + Map 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>> getSent(Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findByInviterIdAndStatus(userId, Status.PENDING) + .stream().map(e -> { + Map 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> 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 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 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 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 toMap(BdsmEinladungEntity e) { + Map 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmGameController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmGameController.java new file mode 100644 index 0000000..e32e6bc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmGameController.java @@ -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 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 getBySessionId(@PathVariable UUID sessionId) { + return sessionRepository.findById(sessionId) + .map(entity -> ResponseEntity.ok(toSession(entity))) + .orElse(ResponseEntity.noContent().build()); + } + + @GetMapping + public ResponseEntity getByUserId(@RequestParam UUID userId) { + return sessionRepository.findByUserId(userId) + .map(entity -> ResponseEntity.ok(toSession(entity))) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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().build(); + }) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping("/{sessionId}/abgeschlossen") + public ResponseEntity 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 istBeendet(@PathVariable UUID sessionId) { + if (ORDENTLICH_BEENDET.remove(sessionId)) return ResponseEntity.ok().build(); + return ResponseEntity.notFound().build(); + } + + @DeleteMapping("/{sessionId}/verlassen") + public ResponseEntity 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 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 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 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 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> 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 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> 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 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 abgelaufeneSperren) {} + + @PostMapping("/{sessionId}/active-task/abschliessen") + public ResponseEntity 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 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 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 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 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 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> 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 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 locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(kandidat.getUserId()); + if (locks.isEmpty()) continue; + + Map 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>> keyholderLocks( + @PathVariable UUID sessionId, @RequestParam UUID keyholderUserId) { + BdsmGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) return ResponseEntity.notFound().build(); + + List> result = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(keyholderUserId).stream() + .map(l -> { + Map 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> zuChastity( + @PathVariable UUID sessionId, @RequestBody ZuChastityRequest req) { + try { + CardLockEntity newLock = bdsmGameService.zuChastity( + sessionId, req.lockId(), req.lockeeUserId(), req.keyholderUserId()); + Map 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> chastityConstraint(@RequestParam UUID userId) { + Map 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> debug(@PathVariable UUID sessionId) { + BdsmGameEntity entity = sessionRepository.findById(sessionId).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + + Map 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> mitspielerList = entity.getMitspieler().stream().map(m -> { + Map 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> sperrenList = entity.getAktiveSperren().stream().map(s -> { + Map 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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.java new file mode 100644 index 0000000..1e8d6e8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/BdsmSetupDraftController.java @@ -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> 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 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 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 deleteDraft(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + draftRepository.findByUserId(userId).ifPresent(draftRepository::delete); + return ResponseEntity.accepted().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/FavoritController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/FavoritController.java new file mode 100644 index 0000000..e8ae9e4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/FavoritController.java @@ -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 get(@PathVariable UUID favoritId) { + return favoritRepository.findById(favoritId) + .map(entity -> ResponseEntity.ok(entity.toFavorit())) + .orElse(ResponseEntity.noContent().build()); + } + + @GetMapping + public ResponseEntity all(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + List entities = favoritRepository.findByUserId(userId); + FavoritList result = new FavoritList(); + result.setFavoriten(entities.stream().map(FavoritEntity::toFavorit).toList()); + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity create(@RequestBody Favorit favorit, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + if (favorit.getAufgabenGruppeId() == null) { + return ResponseEntity.badRequest().build(); + } + List 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 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(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/FillerController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/FillerController.java new file mode 100644 index 0000000..5e8f46b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/FillerController.java @@ -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 fill() { + try { + defaultFiller.fill(); + return ResponseEntity.ok().build(); + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/FinisherController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/FinisherController.java new file mode 100644 index 0000000..1e60fd5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/FinisherController.java @@ -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 get(@PathVariable UUID finisherId) { + return finisherRepository.findById(finisherId) + .map(entity -> ResponseEntity.ok(entity.toFinisher())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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 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 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/SperreController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/SperreController.java new file mode 100644 index 0000000..f7ce1b2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/SperreController.java @@ -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 get(@PathVariable UUID sperreId) { + return sperreRepository.findById(sperreId) + .map(entity -> ResponseEntity.ok(entity.toSperre())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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 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 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/StrafeController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/StrafeController.java new file mode 100644 index 0000000..dd78aed --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/StrafeController.java @@ -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 get(@PathVariable UUID strafeId) { + return strafeRepository.findById(strafeId) + .map(entity -> ResponseEntity.ok(entity.toStrafe())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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 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 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/controller/ToyController.java b/src/main/java/de/oaa/xxx/games/bdsm/controller/ToyController.java new file mode 100644 index 0000000..b7978be --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/controller/ToyController.java @@ -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 listUser( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size, + Principal principal) { + UserEntity user = userService.requireUser(principal); + Page result = toyRepository.findByUserId( + user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); + return ResponseEntity.ok(toToyPage(result)); + } + + @GetMapping("/list/system") + public ResponseEntity listSystem( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { + Page 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> available(Principal principal) { + UserEntity user = userService.requireUser(principal); + + List own = toyRepository.findByUserId(user.getUserId(), PageRequest.of(0, 500, Sort.by("name"))).getContent(); + List system = toyRepository.findByUserIdIsNull(PageRequest.of(0, 500, Sort.by("name"))).getContent(); + + Set knownIds = new HashSet<>(); + own.forEach(t -> knownIds.add(t.getToyId())); + system.forEach(t -> knownIds.add(t.getToyId())); + + Set 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 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 get(@PathVariable UUID toyId) { + return toyRepository.findById(toyId) + .map(entity -> ResponseEntity.ok(entity.toToy())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 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 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 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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.java new file mode 100644 index 0000000..d5150c5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/AktiveSperreEntity.java @@ -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 fuer = new ArrayList<>(); + @Column + private String releaseText; + @ManyToOne + @JoinColumn(name = "sessionId", nullable = false) + private BdsmGameEntity session; + + public AktiveSperre toSperre(List 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 mitspielerList, UUID id) { + Optional first = mitspielerList.stream().filter(m -> m.getId().equals(id)).findFirst(); + return first.orElse(null); + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.java new file mode 100644 index 0000000..45444c0 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmDefaultsEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.java new file mode 100644 index 0000000..7abfea4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmEinladungEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.java new file mode 100644 index 0000000..cb6756b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmGameEntity.java @@ -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 mitspieler = new ArrayList<>(); + @OneToMany(mappedBy = "session", fetch = FetchType.EAGER) + private List 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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.java new file mode 100644 index 0000000..d8ba754 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/BdsmSetupDraftEntity.java @@ -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; +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.java b/src/main/java/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.java new file mode 100644 index 0000000..bb0b2cc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/entity/MitspielerEntity.java @@ -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 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 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 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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.java new file mode 100644 index 0000000..acf8fa4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/AktiveSperreRepository.java @@ -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 { + + @Query("select a from AktiveSperreEntity a join a.session s where a.endzeit < :now and s.sessionId = :sessionId") + List findAbgelaufene(@Param("sessionId") UUID sessionId, @Param("now") LocalDateTime now); + + @Query("select a from AktiveSperreEntity a join a.mitspieler m where m.mitspielerId = :mitspielerId") + List findAktiveLocks(@Param("mitspielerId") UUID mitspielerId); +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.java new file mode 100644 index 0000000..c144a2d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmDefaultsRepository.java @@ -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 { + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.java new file mode 100644 index 0000000..4ac66bc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmEinladungRepository.java @@ -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 { + + List findBySetupId(UUID setupId); + + List findByInviteeIdAndStatus(UUID inviteeId, Status status); + + List findByInviterIdAndStatus(UUID inviterId, Status status); +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.java new file mode 100644 index 0000000..d3aedeb --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmGameRepository.java @@ -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 { + + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.java new file mode 100644 index 0000000..7b0ca40 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/BdsmSetupDraftRepository.java @@ -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 { + Optional findByUserId(UUID userId); + Optional findBySetupId(String setupId); +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.java b/src/main/java/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.java new file mode 100644 index 0000000..f920772 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/repository/MitspielerRepository.java @@ -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 { +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreCallback.java b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreCallback.java new file mode 100644 index 0000000..83f5dc8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreCallback.java @@ -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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.java b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.java new file mode 100644 index 0000000..e97e2e9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperreVerarbeiten.java @@ -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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.java b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.java new file mode 100644 index 0000000..3d26541 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/bdsm/sperre/SperrenVerlaengernCallback.java @@ -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 + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/Card.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/Card.java new file mode 100644 index 0000000..9f7aac5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/Card.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.games.chastity.cardlock; + +public interface Card { + + public CardDTO processCard(CardLockService lock); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.java new file mode 100644 index 0000000..d0314bc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardCountMapConverter.java @@ -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, String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map map) { + if (map == null || map.isEmpty()) return null; + try { + return mapper.writeValueAsString(map); + } catch (Exception e) { + return null; + } + } + + @Override + public Map convertToEntityAttribute(String json) { + if (json == null || json.isBlank()) return new LinkedHashMap<>(); + try { + return mapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + return new LinkedHashMap<>(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardDTO.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardDTO.java new file mode 100644 index 0000000..d155e76 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardDTO.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.cardlock; + +public record CardDTO (CardEnum card, String unlockCode){ + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnum.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnum.java new file mode 100644 index 0000000..7851cbe --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnum.java @@ -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(); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.java new file mode 100644 index 0000000..cb2c45b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardEnumListConverter.java @@ -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, String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List 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 convertToEntityAttribute(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + List names = mapper.readValue(json, new TypeReference<>() {}); + return new ArrayList<>(names.stream().map(CardEnum::valueOf).toList()); + } catch (Exception e) { + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java new file mode 100644 index 0000000..a172748 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockController.java @@ -0,0 +1,1504 @@ +package de.oaa.xxx.games.chastity.cardlock; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.Principal; +import java.security.SecureRandom; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import javax.imageio.ImageIO; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository; +import de.oaa.xxx.games.chastity.community.CommunityVerificationEntity; +import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository; +import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteEntity; +import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationEntity; +import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository; +import de.oaa.xxx.games.chastity.lockcontroll.LockControlFactory; +import de.oaa.xxx.games.chastity.lockcontroll.LockControllType; +import de.oaa.xxx.games.chastity.lockee.LockeeInvitationEntity; +import de.oaa.xxx.games.chastity.lockee.LockeeInvitationRepository; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; +import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryRepository; +import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.subscription.SubscriptionLimitService; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/keyholder") +public class CardLockController { + + private final CardlockRepository cardlockRepository; + private final UserRepository userRepository; + private final KeyholderInvitationRepository invitationRepository; + private final CommunityVerificationRepository verificationRepository; + private final CommunityVerificationVoteRepository verificationVoteRepository; + private final KeyholderNotificationRepository keyholderNotificationRepository; + private final LockeeInvitationRepository lockeeInvitationRepository; + private final AssignedTaskRepository assignedTaskRepository; + private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository; + private final CommunityTaskVoteRepository communityTaskVoteRepository; + private final UnlockCodeHistoryRepository unlockCodeHistoryRepository; + private final UnlockCodeHistoryService unlockCodeHistoryService; + private final SystemMessageService systemMessageService; + private final CardLockServiceFactory cardLockServiceFactory; + private final SubscriptionLimitService subscriptionLimitService; + private final UserService userService; + + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + + public CardLockController(CardlockRepository cardlockRepository, + UserRepository userRepository, + KeyholderInvitationRepository invitationRepository, + CommunityVerificationRepository verificationRepository, + CommunityVerificationVoteRepository verificationVoteRepository, + KeyholderNotificationRepository keyholderNotificationRepository, + LockeeInvitationRepository lockeeInvitationRepository, + AssignedTaskRepository assignedTaskRepository, + KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + CommunityTaskVoteRepository communityTaskVoteRepository, + UnlockCodeHistoryRepository unlockCodeHistoryRepository, + UnlockCodeHistoryService unlockCodeHistoryService, + SystemMessageService systemMessageService, + CardLockServiceFactory cardLockServiceFactory, + LockControlFactory lockControlFactory, + SubscriptionLimitService subscriptionLimitService, + UserService userService) { + this.cardlockRepository = cardlockRepository; + this.userRepository = userRepository; + this.invitationRepository = invitationRepository; + this.verificationRepository = verificationRepository; + this.verificationVoteRepository = verificationVoteRepository; + this.keyholderNotificationRepository = keyholderNotificationRepository; + this.lockeeInvitationRepository = lockeeInvitationRepository; + this.assignedTaskRepository = assignedTaskRepository; + this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository; + this.communityTaskVoteRepository = communityTaskVoteRepository; + this.unlockCodeHistoryRepository = unlockCodeHistoryRepository; + this.unlockCodeHistoryService = unlockCodeHistoryService; + this.systemMessageService = systemMessageService; + this.cardLockServiceFactory = cardLockServiceFactory; + this.subscriptionLimitService = subscriptionLimitService; + this.userService = userService; + } + + record CreateCardLockRequest(String name, UUID keyholder, UUID lockeeUserId, boolean lockeeDetailsVisible, + List initialCards, Integer pickEveryMinute, boolean accumulatePicks, boolean showRemainingCards, + LocalDateTime latestOpeningtime, Integer hygineOpeningDurationMinutes, Integer hygineOpeningEveryMinites, + List tasks, boolean requiresVerification, boolean testLock, Integer unlockCodeLines, + TaskMode taskMode, LockControllType controllType) { + } + + private static final SecureRandom RNG = new SecureRandom(); + + private String generateUnlockCode(int lines) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines; i++) { + sb.append(RNG.nextInt(10)); + } + return sb.toString(); + } + + @PostMapping("/cardlock") + public ResponseEntity> createCardLock(@RequestBody CreateCardLockRequest req, + Principal principal) { + + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + if (req.initialCards() == null || req.initialCards().isEmpty() || req.pickEveryMinute() == null + || req.pickEveryMinute() < 1) + return ResponseEntity.badRequest().build(); + + // Friend-lockee path: current user becomes keyholder, invite lockee + boolean friendLockee = req.lockeeUserId() != null && !req.lockeeUserId().equals(myId); + if (friendLockee) { + var lockeeOpt = userRepository.findById(req.lockeeUserId()); + if (lockeeOpt.isEmpty()) + return ResponseEntity.badRequest().build(); + var lockee = lockeeOpt.get(); + + if (cardLockServiceFactory.hasActiveLock(req.lockeeUserId())) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + LocalDateTime now = LocalDateTime.now(); + CardLockEntity lock = new CardLockEntity(); + lock.setName(req.name()); + lock.setLockee(lockee.getUserId()); + lock.setKeyholder(myId); + lock.setInitialCards(req.initialCards()); + lock.setPickEveryMinute(req.pickEveryMinute()); + lock.setAccumulatePicks(req.accumulatePicks()); + lock.setShowRemainingCards(req.showRemainingCards()); + lock.setLatestOpeningtime(req.latestOpeningtime()); + lock.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes()); + lock.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites()); + lock.setTasks(req.tasks() != null ? req.tasks() : List.of()); + lock.setRequiresVerification(req.requiresVerification()); + lock.setTestLock(false); + lock.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM); + // startTime, unlockCode, unlockCodeLines left null until lockee accepts + cardlockRepository.save(lock); + + String token = UUID.randomUUID().toString().replace("-", ""); + LockeeInvitationEntity inv = new LockeeInvitationEntity(); + inv.setLockId(lock.getLockId()); + inv.setLockeeUserId(lockee.getUserId()); + inv.setKeyholderUserId(myId); + inv.setToken(token); + inv.setCreatedAt(now); + inv.setDetailsVisible(req.lockeeDetailsVisible()); + lockeeInvitationRepository.save(inv); + + systemMessageService.pushInvitationUpdate(lockee.getUserId()); + + return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "lockeeInvitationSent", true)); + } + + // Self-lockee path (existing behavior) + if (cardLockServiceFactory.hasActiveLock(myId)) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE; + if (controllType == LockControllType.TTLOCK && !subscriptionLimitService.hasActivePaidSubscription(myId)) { + return ResponseEntity.status(403).body(Map.of("error", "subscription_required")); + } + + int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1) ? req.unlockCodeLines() : 5; + + CardLockEntity lock = new CardLockEntity(); + lock.setName(req.name()); + lock.setLockee(myId); + lock.setKeyholder(null); // set only after invitation is confirmed + lock.setInitialCards(req.initialCards()); + lock.setPickEveryMinute(req.pickEveryMinute()); + lock.setAccumulatePicks(req.accumulatePicks()); + lock.setShowRemainingCards(req.showRemainingCards()); + lock.setLatestOpeningtime(req.latestOpeningtime()); + lock.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes()); + lock.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites()); + lock.setTasks(req.tasks() != null ? req.tasks() : List.of()); + lock.setRequiresVerification(req.requiresVerification()); + lock.setTestLock(req.testLock()); + lock.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM); + lock.setUnlockCodeLength(codeLines); + lock.setControllType(controllType); + + LocalDateTime now = LocalDateTime.now(); + lock.setStartTime(now); + lock.setAvailableCards(new ArrayList<>(req.initialCards())); + lock.setOpenPicks(0); + lock.setNextCardIn(now.plusMinutes(req.pickEveryMinute())); + if (req.hygineOpeningEveryMinites() != null) { + lock.setLastHygineOpening(now); + } + cardlockRepository.save(lock); // erst speichern, damit Lock-ID vorhanden ist + + // Initialen Unlock-Code / TTLock-PIN via LockControl setzen + CardLockService initService = cardLockServiceFactory.create(lock); + if (initService.getLockControl() != null) { + initService.getLockControl().lock(); + } else { + // Fallback: direkte Code-Generierung (UNLOCK_CODE ohne Factory) + lock.setUnlockCode(generateUnlockCode(codeLines)); + cardlockRepository.save(lock); + } + + boolean keyholderPending = false; + if (req.keyholder() != null) { + var khOpt = userRepository.findById(req.keyholder()); + if (khOpt.isPresent()) { + var kh = khOpt.get(); + String token = UUID.randomUUID().toString().replace("-", ""); + + KeyholderInvitationEntity inv = new KeyholderInvitationEntity(); + inv.setLockId(lock.getLockId()); + inv.setKeyholderUserId(kh.getUserId()); + inv.setLockeeUserId(myId); + inv.setToken(token); + inv.setCreatedAt(now); + invitationRepository.save(inv); + + systemMessageService.pushInvitationUpdate(kh.getUserId()); + + keyholderPending = true; + } + } + + return ResponseEntity.ok(Map.of("lockId", lock.getLockId().toString(), "unlockCode", lock.getUnlockCode(), + "keyholderPending", keyholderPending)); + } + + @PostMapping("/cardlock/{lockId}/draw") + @Transactional + public ResponseEntity> drawCard(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + CardLockService service = cardLockServiceFactory.create(l); + CardDTO dto = service.getNextCard(); + if (dto == null) + return ResponseEntity.status(409).body(Map.of("error", "Keine Karte verfügbar")); + + String taskPending = (dto.card() == CardEnum.TASK) ? service.getPendingTaskMode() : null; + + Map result = new HashMap<>(); + result.put("card", dto.card().name()); + result.put("unlockCode", dto.unlockCode() != null ? dto.unlockCode() : ""); + if (taskPending != null) + result.put("taskPending", taskPending); + + // Grüne Karte → Entsperrcode-Historie speichern + Keyholder benachrichtigen + if (dto.unlockCode() != null && !dto.unlockCode().isBlank()) { + unlockCodeHistoryService.save(myId, l.getLockId(), l.getName(), dto.unlockCode(), "GREEN_CARD"); + if (l.getKeyholder() != null) { + String meName = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + sendMessage(myId, l.getKeyholder(), + meName + " hat die grüne Karte gezogen! Der Entsperrcode wurde angezeigt.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } + } + + return ResponseEntity.ok(result); + } + + @PostMapping("/cardlock/{lockId}/hygiene/start") + @Transactional + public ResponseEntity> startHygieneOpening(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + cardLockServiceFactory.create(l).startHygieneOpening(); + return ResponseEntity.ok(Map.of("unlockCode", l.getUnlockCode(), "durationMinutes", + l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 30)); + } + + @PostMapping("/cardlock/{lockId}/hygiene/end") + @Transactional + public ResponseEntity> endHygieneOpening(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + String code = cardLockServiceFactory.create(l).endTempOpening(); + return ResponseEntity.ok(Map.of("newUnlockCode", code)); + } + + @PostMapping("/cardlock/{lockId}/task/complete") + @Transactional + public ResponseEntity completeTask(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + CardLockService service = cardLockServiceFactory.create(l); + service.clearTask(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/cardlock/{lockId}/relock") + @Transactional + public ResponseEntity relock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getControllType() != LockControllType.TTLOCK) return ResponseEntity.status(409).build(); + + var lc = cardLockServiceFactory.create(l).getLockControl(); + if (lc != null) lc.lock(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/cardlock/{lockId}/green/keep") + @Transactional + public ResponseEntity greenKeep(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + CardLockService service = cardLockServiceFactory.create(l); + service.putBackGreen(); + + // Grüne Karte zurückgelegt → Keyholder benachrichtigen + if (l.getKeyholder() != null) { + String meName = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + sendMessage(myId, l.getKeyholder(), + meName + " hat die grüne Karte zurückgelegt und bleibt im Lock.", "/games/chastity/keyholder.html", + de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/mylock") + public ResponseEntity> getMyActiveLock(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + var activeLockId = cardLockServiceFactory.findActiveLockId(myId); + if (activeLockId.isEmpty()) + return ResponseEntity.noContent().build(); + return ResponseEntity.ok(Map.of("lockId", activeLockId.get().toString())); + } + + @GetMapping("/cardlock/{lockId}") + public ResponseEntity> getLock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + Map cardCounts = new LinkedHashMap<>(); + if (l.getAvailableCards() != null) { + l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum)); + } + long totalCards = l.getAvailableCards() != null ? l.getAvailableCards().size() : 0; + + // Hygiene-Berechnung + boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null; + boolean hygieneOpeningDue = false; + long hygieneSecondsRemaining = 0; + if (hygieneEnabled) { + LocalDateTime base = l.getLastHygineOpening() != null ? l.getLastHygineOpening() : l.getStartTime(); + if (base != null) { + LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites()); + hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene); + hygieneOpeningDue = hygieneSecondsRemaining <= 0; + } + } + + Map result = new HashMap<>(); + result.put("lockId", l.getLockId().toString()); + result.put("name", l.getName() != null ? l.getName() : ""); + result.put("showRemainingCards", l.isShowRemainingCards()); + result.put("availableCardCounts", cardCounts); + result.put("totalCards", totalCards); + result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0); + result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : ""); + result.put("frozenUntill", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null); + result.put("currentTask", l.getCurrentTask() != null ? l.getCurrentTask() : null); + result.put("currentTaskDescription", l.getCurrentTaskDescription()); + result.put("taskFrozenUntil", l.getTaskUntil() != null ? l.getTaskUntil().toString() : null); + result.put("hygieneEnabled", hygieneEnabled); + result.put("hygieneOpeningDue", hygieneOpeningDue); + result.put("hygieneSecondsRemaining", hygieneSecondsRemaining); + result.put("hygieneOpeningActive", l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE == l.getTempOpeningReason()); + boolean tempOpeningActive = l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE != l.getTempOpeningReason(); + result.put("tempOpeningActive", tempOpeningActive); + if (tempOpeningActive) { + result.put("tempOpeningUnlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : ""); + } + result.put("hygieneOpeningStarted", + l.getTempOpeningTime() != null ? l.getTempOpeningTime().toString() : null); + result.put("hygieneDurationMinutes", + l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 0); + result.put("hasKeyholder", l.getKeyholder() != null); + result.put("keyholderInvitationPending", + l.getKeyholder() == null && !invitationRepository.findByLockId(l.getLockId()).isEmpty()); + if (l.getKeyholder() != null) { + userRepository.findById(l.getKeyholder()).ifPresent(kh -> { + result.put("keyholderName", kh.getName()); + result.put("keyholderUserId", kh.getUserId().toString()); + result.put("keyholderProfilePic", kh.getProfilePicture()); + }); + } + + // Verifikation + boolean verificationDue = false; + String verificationTodayId = null; + String verificationPendingId = null; + String verificationPendingCode = null; + long verificationUpvotes = 0; + long verificationDownvotes = 0; + if (l.isRequiresVerification()) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + var completed = verificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNotNull(l.getLockId(), todayStart, todayEnd); + if (!completed.isEmpty()) { + var todayV = completed.get(0); + verificationTodayId = todayV.getDisplayId().toString(); + var votes = verificationVoteRepository.findAllByVerificationId(todayV.getDisplayId()); + verificationUpvotes = votes.stream().filter(CommunityVerificationVoteEntity::isUpvote).count(); + verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count(); + } else { + verificationDue = true; + var pending = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNull(l.getLockId(), + todayStart, todayEnd); + if (!pending.isEmpty()) { + verificationPendingId = pending.get(0).getDisplayId().toString(); + verificationPendingCode = pending.get(0).getCode(); + } + } + } + result.put("verificationRequired", l.isRequiresVerification()); + result.put("verificationDue", verificationDue); + result.put("verificationTodayId", verificationTodayId); + result.put("verificationUpvotes", verificationUpvotes); + result.put("verificationDownvotes", verificationDownvotes); + result.put("verificationPendingId", verificationPendingId); + result.put("verificationPendingCode", verificationPendingCode); + + // Abgelaufene Aufgaben prüfen und Strafe anwenden + var expiredTasks = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING").stream() + .filter(t -> t.getAcceptDeadline().isBefore(LocalDateTime.now())).toList(); + if (!expiredTasks.isEmpty()) { + CardLockService penaltyService = cardLockServiceFactory.create(l); + for (var t : expiredTasks) { + t.setStatus("EXPIRED"); + penaltyService.applyAssignedTaskPenalty(t); + assignedTaskRepository.save(t); + sendMessage(l.getKeyholder(), l.getLockee(), + "Die dir gestellte Aufgabe ist abgelaufen, ohne dass du reagiert hast. Die Strafe wurde automatisch angewendet.", + "/games/chastity/activelock.html?lockId=" + l.getLockId(), de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } + } + + // Ausstehende Keyholder-Aufgaben (ohne Aufgabentext) + var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(l.getLockId(), "PENDING").stream() + .filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).map(t -> { + Map m = new LinkedHashMap<>(); + m.put("taskId", t.getTaskId().toString()); + m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText()); + m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : ""); + m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0); + m.put("assignedAt", t.getAssignedAt().toString()); + m.put("acceptDeadline", t.getAcceptDeadline().toString()); + m.put("penaltyFreezeMinutes", + t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0); + m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0); + return m; + }).toList(); + result.put("assignedTasks", pendingAssigned); + result.put("taskMode", l.getTaskMode()); + + // Ausstehende Keyholder-Choices + boolean pendingKeyholderChoice = !keyholderTaskChoiceRepository.findByLockIdAndActiveTrue(l.getLockId()) + .isEmpty(); + result.put("pendingKeyholderChoice", pendingKeyholderChoice); + + // Aktive Community-Vote + var activeVotes = communityTaskVoteRepository.findByActiveTrue().stream() + .filter(v -> v.getLockId().equals(l.getLockId())).findFirst(); + if (activeVotes.isPresent()) { + var v = activeVotes.get(); + result.put("activeCommunityVote", + Map.of("voteSessionId", v.getDisplayId().toString(), "expiresAt", v.getExpiresAt().toString())); + } + + // Notfall-Entsperrung: nach 1 Stunde automatisch öffnen + if (l.getEmergencyUnlockRequestedAt() != null && !l.isKeyholderRequestedUnlock() + && l.getEmergencyUnlockRequestedAt().isBefore(LocalDateTime.now().minusHours(1))) { + l.setEmergencyAutoUnlocked(true); + l.setKeyholderRequestedUnlock(true); + cardlockRepository.save(l); + } + + // Keyholder hat Unlock angefordert → Unlock-Code mitliefern + result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock()); + if (l.isKeyholderRequestedUnlock()) { + result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : ""); + // Notfall-Freigaben werden nicht in der Historie gespeichert + if (l.getEmergencyUnlockRequestedAt() == null) { + unlockCodeHistoryService.save(myId, l.getLockId(), l.getName(), l.getUnlockCode(), "KEYHOLDER_UNLOCK"); + } + } + + result.put("testLock", l.isTestLock()); + result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null); + result.put("controllType", l.getControllType() != null ? l.getControllType().name() : "UNLOCK_CODE"); + if (l.isTestLock()) { + result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : ""); + } + + return ResponseEntity.ok(result); + } + + @PostMapping("/cardlock/{lockId}/verification/start") + @Transactional + public ResponseEntity> startVerification(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + + // Existierende Verifikation für heute zurückgeben statt neue anlegen + var existing = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNull(lockId, todayStart, + todayEnd); + if (!existing.isEmpty()) { + var ev = existing.get(0); + return ResponseEntity.ok(Map.of("verificationId", ev.getDisplayId().toString(), "code", ev.getCode())); + } + var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId, + todayStart, todayEnd); + if (!completed.isEmpty()) { + var cv = completed.get(0); + return ResponseEntity.ok(Map.of("verificationId", cv.getDisplayId().toString(), "code", cv.getCode())); + } + + CommunityVerificationEntity v = new CommunityVerificationEntity(); + v.setDisplayId(UUID.randomUUID()); + v.setLockId(lockId); + v.setLockeeId(myId); + v.setCode(CodeCreator.createAlphanumeric(6)); + v.setCreatedAt(LocalDateTime.now()); + if (l.getKeyholder() != null) + v.setKeyholderId(l.getKeyholder()); + verificationRepository.save(v); + + return ResponseEntity.ok(Map.of("verificationId", v.getDisplayId().toString(), "code", v.getCode())); + } + + @PostMapping(value = "/cardlock/{lockId}/verification/{verificationId}/complete", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity completeVerification(@PathVariable UUID lockId, @PathVariable UUID verificationId, + @RequestParam MultipartFile image, Principal principal) throws IOException { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + if (!lockOpt.get().getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + var vOpt = verificationRepository.findById(verificationId); + if (vOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var v = vOpt.get(); + if (!v.getLockId().equals(lockId)) + return ResponseEntity.status(403).build(); + + v.setImage(scaleImage(image.getBytes(), 1024)); + verificationRepository.save(v); + + var lock = lockOpt.get(); + if (lock.getKeyholder() != null) { + String meName = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + sendMessage(myId, lock.getKeyholder(), "📸 " + meName + " hat eine Verifikation eingereicht.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } + + return ResponseEntity.noContent().build(); + } + + private byte[] scaleImage(byte[] input, int maxSize) throws IOException { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(input)); + if (original == null) + return input; + int w = original.getWidth(); + int h = original.getHeight(); + if (w <= maxSize && h <= maxSize) + return input; + double scale = (double) maxSize / Math.max(w, h); + int newW = (int) (w * scale); + int newH = (int) (h * scale); + BufferedImage scaled = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(original, 0, 0, newW, newH, null); + g.dispose(); + String format = "jpeg"; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(scaled, format, out); + return out.toByteArray(); + } + + @DeleteMapping("/cardlock/{lockId}/verification/today") + @Transactional + public ResponseEntity renewVerification(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + if (!lockOpt.get().getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId, + todayStart, todayEnd); + for (var v : completed) { + verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId()); + verificationRepository.delete(v); + } + return ResponseEntity.noContent().build(); + } + + // ── Keyholder-Dashboard Endpunkte ── + + @GetMapping("/invitations/mine/count") + public ResponseEntity countMyInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + int count = 0; + for (var inv : invitationRepository.findByKeyholderUserId(myId)) { + var lockOpt = cardlockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) continue; + if (lockOpt.get().getKeyholder() != null) continue; + count++; + } + return ResponseEntity.ok(count); + } + + @GetMapping("/invitations/mine") + public ResponseEntity>> getMyInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var invitations = invitationRepository.findByKeyholderUserId(myId); + List> result = new ArrayList<>(); + for (var inv : invitations) { + var lockOpt = cardlockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) + continue; + var lock = lockOpt.get(); + if (lock.getKeyholder() != null) + continue; // bereits akzeptiert + var lockeeOpt = userRepository.findById(lock.getLockee()); + if (lockeeOpt.isEmpty()) + continue; + var lockee = lockeeOpt.get(); + Map item = new HashMap<>(); + item.put("lockId", inv.getLockId().toString()); + item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + item.put("lockeeName", lockee.getName()); + item.put("lockeeId", lockee.getUserId().toString()); + item.put("lockeeProfilePic", lockee.getProfilePicture()); + item.put("token", inv.getToken()); + item.put("createdAt", inv.getCreatedAt().toString()); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @Transactional + @DeleteMapping("/invitations/mine/{token}") + public ResponseEntity declineInvitation(@PathVariable String token, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var invOpt = invitationRepository.findByToken(token); + if (invOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + if (!inv.getKeyholderUserId().equals(myId)) + return ResponseEntity.status(403).build(); + + var lockOpt = cardlockRepository.findById(inv.getLockId()); + invitationRepository.delete(inv); + + if (lockOpt.isPresent()) { + var lock = lockOpt.get(); + systemMessageService.pushInvitationUpdate(lock.getLockee()); + } + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/invitations/sent") + public ResponseEntity>> getSentKeyholderInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var invitations = invitationRepository.findByLockeeUserId(myId); + List> result = new ArrayList<>(); + for (var inv : invitations) { + var lockOpt = cardlockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) + continue; + var lock = lockOpt.get(); + if (lock.getKeyholder() != null) + continue; // already accepted + var khOpt = userRepository.findById(inv.getKeyholderUserId()); + if (khOpt.isEmpty()) + continue; + var kh = khOpt.get(); + Map item = new HashMap<>(); + item.put("lockId", lock.getLockId().toString()); + item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + item.put("keyholderName", kh.getName()); + item.put("keyholderProfilePic", kh.getProfilePicture()); + item.put("token", inv.getToken()); + item.put("createdAt", inv.getCreatedAt().toString()); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @Transactional + @DeleteMapping("/invitations/sent/{token}") + public ResponseEntity cancelSentKeyholderInvitation(@PathVariable String token, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var invOpt = invitationRepository.findByToken(token); + if (invOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + + // Verify the lock belongs to the current user as lockee + var lockOpt = cardlockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty() || !lockOpt.get().getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + invitationRepository.delete(inv); + + systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId()); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/as-keyholder") + public ResponseEntity>> getLocksAsKeyholder(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(myId); + List> result = new ArrayList<>(); + for (var lock : locks) { + var lockeeOpt = userRepository.findById(lock.getLockee()); + if (lockeeOpt.isEmpty()) + continue; + var lockee = lockeeOpt.get(); + Map item = new HashMap<>(); + item.put("lockId", lock.getLockId().toString()); + item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + item.put("lockeeName", lockee.getName()); + item.put("lockeeId", lockee.getUserId().toString()); + item.put("lockeeProfilePic", lockee.getProfilePicture()); + item.put("totalCards", lock.getAvailableCards() != null ? lock.getAvailableCards().size() : 0); + item.put("startTime", lock.getStartTime() != null ? lock.getStartTime().toString() : null); + boolean frozenByKh = lock.getFrozenUntil() != null && lock.getFrozenUntil().isAfter(LocalDateTime.now()) + && (lock.getCurrentTask() == null || lock.getCurrentTask().isBlank()); + item.put("isFrozenByKeyholder", frozenByKh); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @GetMapping("/as-keyholder/{lockId}") + public ResponseEntity> getLockAsKeyholder(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + + var lockeeOpt = userRepository.findById(l.getLockee()); + if (lockeeOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var lockee = lockeeOpt.get(); + + Map cardCounts = new LinkedHashMap<>(); + if (l.getAvailableCards() != null) { + l.getAvailableCards().forEach(c -> cardCounts.merge(c.name(), 1L, Long::sum)); + } + + boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null; + boolean hygieneOpeningDue = false; + long hygieneSecondsRemaining = 0; + if (hygieneEnabled) { + LocalDateTime base = l.getLastHygineOpening() != null ? l.getLastHygineOpening() : l.getStartTime(); + if (base != null) { + LocalDateTime nextHygiene = base.plusMinutes(l.getHygineOpeningEveryMinites()); + hygieneSecondsRemaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), nextHygiene); + hygieneOpeningDue = hygieneSecondsRemaining <= 0; + } + } + + boolean verificationDue = false; + boolean verificationDoneToday = false; + String verificationMyVote = null; // null = not voted, "upvote", "downvote" + String verificationTodayId = null; + String verificationImage = null; + long verificationUpvotes = 0, verificationDownvotes = 0; + if (l.isRequiresVerification()) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + var completed = verificationRepository.findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId, + todayStart, todayEnd); + if (!completed.isEmpty()) { + verificationDoneToday = true; + var v = completed.get(0); + var votes = verificationVoteRepository.findAllByVerificationId(v.getDisplayId()); + verificationUpvotes = votes.stream().filter(CommunityVerificationVoteEntity::isUpvote).count(); + verificationDownvotes = votes.stream().filter(v2 -> !v2.isUpvote()).count(); + verificationTodayId = v.getDisplayId().toString(); + var myVoteOpt = verificationVoteRepository.findByVerificationIdAndUserId(v.getDisplayId(), myId); + if (myVoteOpt.isPresent()) { + verificationMyVote = myVoteOpt.get().isUpvote() ? "upvote" : "downvote"; + } else if (v.getImage() != null) { + verificationImage = java.util.Base64.getEncoder().encodeToString(v.getImage()); + } + } else { + verificationDue = true; + } + } + + var notification = keyholderNotificationRepository.findByLockId(lockId).stream() + .sorted((a, b) -> b.getViolationTime().compareTo(a.getViolationTime())).limit(5) + .map(v -> Map.of("time", v.getViolationTime().toString(), "overtimeMinutes", v.getOvertimeMinutes(), + "openingReason", v.getOpeningReason() != null ? v.getOpeningReason().name() : "HYGIENE")) + .toList(); + + Map result = new HashMap<>(); + result.put("lockId", l.getLockId().toString()); + result.put("lockName", l.getName() != null ? l.getName() : "Unbenanntes Lock"); + result.put("lockeeName", lockee.getName()); + result.put("lockeeId", lockee.getUserId().toString()); + result.put("lockeeProfilePic", lockee.getProfilePicture()); + result.put("totalCards", l.getAvailableCards() != null ? l.getAvailableCards().size() : 0); + result.put("cardCounts", cardCounts); + result.put("openPicks", l.getOpenPicks() != null ? l.getOpenPicks() : 0); + result.put("nextCardIn", l.getNextCardIn() != null ? l.getNextCardIn().toString() : null); + result.put("frozenUntill", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null); + result.put("taskFrozenUntil", l.getTaskUntil() != null ? l.getTaskUntil().toString() : null); + boolean isFrozenByKeyholder = l.getFrozenUntil() != null && l.getFrozenUntil().isAfter(LocalDateTime.now()); + result.put("isFrozenByKeyholder", isFrozenByKeyholder); + result.put("currentTask", l.getCurrentTask()); + result.put("currentTaskDescription", l.getCurrentTaskDescription()); + result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null); + result.put("hygieneEnabled", hygieneEnabled); + result.put("hygieneOpeningDue", hygieneOpeningDue); + result.put("hygieneSecondsRemaining", hygieneSecondsRemaining); + result.put("hygieneOpeningActive", l.getTempOpeningTime() != null && TempOpeningReason.HYGIENE == l.getTempOpeningReason()); + result.put("requiresVerification", l.isRequiresVerification()); + result.put("verificationDue", verificationDue); + result.put("verificationDoneToday", verificationDoneToday); + result.put("verificationTodayId", verificationTodayId); + result.put("verificationMyVote", verificationMyVote); + result.put("verificationImage", verificationImage); + result.put("verificationUpvotes", verificationUpvotes); + result.put("verificationDownvotes", verificationDownvotes); + result.put("hygieneViolations", notification); + result.put("hasTasks", l.getTasks() != null && !l.getTasks().isEmpty()); + if (l.getTasks() != null) { + var taskList = l.getTasks().stream().map(t -> { + Map m = new LinkedHashMap<>(); + m.put("title", t.getTitle()); + m.put("description", t.getDescription() != null ? t.getDescription() : ""); + m.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0); + return m; + }).toList(); + result.put("taskList", taskList); + } else { + result.put("taskList", List.of()); + } + var pendingAssigned = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream() + .filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).map(t -> { + Map m = new LinkedHashMap<>(); + m.put("taskId", t.getTaskId().toString()); + m.put("taskTitle", t.getTaskTitle() != null ? t.getTaskTitle() : t.getTaskText()); + m.put("taskDescription", t.getTaskDescription() != null ? t.getTaskDescription() : ""); + m.put("taskMinutes", t.getTaskMinutes() != null ? t.getTaskMinutes() : 0); + m.put("assignedAt", t.getAssignedAt().toString()); + m.put("acceptDeadline", t.getAcceptDeadline().toString()); + m.put("penaltyFreezeMinutes", + t.getPenaltyFreezeMinutes() != null ? t.getPenaltyFreezeMinutes() : 0); + m.put("penaltyRedCards", t.getPenaltyRedCards() != null ? t.getPenaltyRedCards() : 0); + return m; + }).toList(); + result.put("pendingAssignedTasks", pendingAssigned); + result.put("taskMode", l.getTaskMode()); + + // Ausstehende Task-Karten-Choices (KEYHOLDER-Modus) + List lockTasks = l.getTasks() != null ? l.getTasks() : List.of(); + List> taskListForChoice = new ArrayList<>(); + for (int i = 0; i < lockTasks.size(); i++) { + Task t = lockTasks.get(i); + Map tm = new LinkedHashMap<>(); + tm.put("index", i); + tm.put("title", t.getTitle()); + tm.put("description", t.getDescription() != null ? t.getDescription() : ""); + tm.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0); + taskListForChoice.add(tm); + } + var pendingChoices = keyholderTaskChoiceRepository.findByLockIdAndActiveTrue(lockId).stream().map(c -> { + Map cm = new LinkedHashMap<>(); + cm.put("choiceId", c.getChoiceId().toString()); + cm.put("createdAt", c.getCreatedAt().toString()); + cm.put("tasks", taskListForChoice); + return cm; + }).toList(); + result.put("pendingTaskChoices", pendingChoices); + result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock()); + result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null); + result.put("emergencyUnlockRequestedAt", + l.getEmergencyUnlockRequestedAt() != null ? l.getEmergencyUnlockRequestedAt().toString() : null); + + return ResponseEntity.ok(result); + } + + @Transactional + @DeleteMapping("/cardlock/{lockId}") + public ResponseEntity deleteLock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + // Entsperrung protokollieren (History + XP) – gültig nur wenn Keyholder + // vorhanden und kein Auto-Notfall + CardLockService service = cardLockServiceFactory.create(l); + service.unlock(l.getUnlockCode()); + + var verifications = verificationRepository.findByLockId(lockId); + verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId())); + verificationRepository.deleteAll(verifications); + invitationRepository.deleteByLockId(lockId); + cardlockRepository.deleteById(lockId); + return ResponseEntity.noContent().build(); + } + + record ModifyCardsRequest(Map cards, boolean notifyDetailed) { + } + + @PostMapping("/as-keyholder/{lockId}/cards/add") + @Transactional + public ResponseEntity addCards(@PathVariable UUID lockId, @RequestBody ModifyCardsRequest req, + Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + if (req.cards() == null || req.cards().isEmpty()) + return ResponseEntity.badRequest().build(); + + List toAdd = new ArrayList<>(); + for (var entry : req.cards().entrySet()) { + try { + CardEnum type = CardEnum.valueOf(entry.getKey()); + int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0; + for (int i = 0; i < count; i++) + toAdd.add(type); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + if (toAdd.isEmpty()) + return ResponseEntity.badRequest().build(); + + List cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of()); + cards.addAll(toAdd); + l.setAvailableCards(cards); + cardlockRepository.save(l); + + String detail = toAdd.stream().collect(Collectors.groupingBy(c -> c, Collectors.counting())).entrySet().stream() + .map(e -> e.getValue() + "x " + cardLabel(e.getKey())).collect(Collectors.joining(", ")); + String msgText = req.notifyDetailed() + ? me.getName() + " hat " + toAdd.size() + " Karte(n) zu deinem Lock hinzugefügt: " + detail + "." + : me.getName() + " hat Karten zu deinem Lock hinzugefügt."; + + sendMessage(myId, l.getLockee(), msgText, "/games/chastity/activelock.html?lockId=" + lockId, + de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + @PostMapping("/as-keyholder/{lockId}/cards/remove") + @Transactional + public ResponseEntity> removeCards(@PathVariable UUID lockId, + @RequestBody ModifyCardsRequest req, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + if (req.cards() == null || req.cards().isEmpty()) + return ResponseEntity.badRequest().build(); + + List cards = new ArrayList<>(l.getAvailableCards() != null ? l.getAvailableCards() : List.of()); + + // Plausi: letzte grüne Karte darf nicht entfernt werden + long greenInDeck = cards.stream().filter(c -> c == CardEnum.GREEN).count(); + int greenToRemove = req.cards().getOrDefault("GREEN", 0); + if (greenInDeck > 0 && greenToRemove >= greenInDeck) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Die letzte grüne Karte darf nicht entfernt werden.")); + } + + List removed = new ArrayList<>(); + for (var entry : req.cards().entrySet()) { + try { + CardEnum type = CardEnum.valueOf(entry.getKey()); + int count = entry.getValue() != null ? Math.max(0, entry.getValue()) : 0; + Iterator it = cards.iterator(); + int done = 0; + while (it.hasNext() && done < count) { + if (it.next() == type) { + it.remove(); + removed.add(type); + done++; + } + } + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + if (removed.isEmpty()) + return ResponseEntity.badRequest().build(); + + l.setAvailableCards(cards); + cardlockRepository.save(l); + + String detail = removed.stream().collect(Collectors.groupingBy(c -> c, Collectors.counting())).entrySet() + .stream().map(e -> e.getValue() + "x " + cardLabel(e.getKey())).collect(Collectors.joining(", ")); + String msgText = req.notifyDetailed() + ? me.getName() + " hat " + removed.size() + " Karte(n) aus deinem Lock entfernt: " + detail + "." + : me.getName() + " hat Karten aus deinem Lock entfernt."; + + sendMessage(myId, l.getLockee(), msgText, "/games/chastity/activelock.html?lockId=" + lockId, + de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + // ── Hilfsmethoden ────────────────────────────────────────────────────────── + + private void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl, + de.oaa.xxx.social.entity.MessageCause cause) { + systemMessageService.send(senderId, receiverId, text, targetUrl, cause); + } + + @GetMapping("/cardlock/unlock-history") + public ResponseEntity>> getUnlockHistory(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + var entries = unlockCodeHistoryRepository.findByUserIdOrderByReceivedAtDesc(myId, + org.springframework.data.domain.PageRequest.of(0, 10)); + List> result = new ArrayList<>(); + for (var e : entries) { + Map item = new HashMap<>(); + item.put("lockName", e.getLockName()); + item.put("unlockCode", e.getUnlockCode()); + item.put("source", e.getSource()); + item.put("receivedAt", e.getReceivedAt().toString()); + result.add(item); + } + return ResponseEntity.ok(result); + } + + // ── Keyholder: Aufgabe stellen ───────────────────────────────────────────── + + record AssignTaskRequest(int taskIndex, int acceptDeadlineMinutes, Integer penaltyFreezeMinutes, + Integer penaltyRedCards) { + } + + @Transactional + @PostMapping("/as-keyholder/{lockId}/assign-task") + public ResponseEntity assignTask(@PathVariable UUID lockId, @RequestBody AssignTaskRequest req, + Principal principal) { + var me = userService.requireUser(principal); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!me.getUserId().equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + + var tasks = l.getTasks(); + if (tasks == null || tasks.isEmpty()) + return ResponseEntity.badRequest().body(Map.of("error", "Dieses Lock hat keine Aufgaben.")); + if (req.taskIndex() < 0 || req.taskIndex() >= tasks.size()) + return ResponseEntity.badRequest().body(Map.of("error", "Ungültiger Aufgaben-Index.")); + if (req.acceptDeadlineMinutes() < 1) + return ResponseEntity.badRequest() + .body(Map.of("error", "Die Annahme-Frist muss mindestens 1 Minute betragen.")); + + long pendingCount = assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream() + .filter(t -> t.getAcceptDeadline().isAfter(LocalDateTime.now())).count(); + if (pendingCount >= 5) + return ResponseEntity.badRequest().body(Map.of("error", + "Es sind bereits 5 Aufgaben offen. Bitte warte, bis der Lockee eine davon annimmt oder ablehnt.")); + + Task task = tasks.get(req.taskIndex()); + AssignedTaskEntity assigned = new AssignedTaskEntity(); + assigned.setLockId(lockId); + assigned.setTaskTitle(task.getTitle()); + assigned.setTaskDescription(task.getDescription()); + assigned.setTaskText(task.getTitle()); // Compat + assigned.setTaskMinutes(task.getMinutes()); + assigned.setAssignedAt(LocalDateTime.now()); + assigned.setAcceptDeadline(LocalDateTime.now().plusMinutes(req.acceptDeadlineMinutes())); + assigned.setPenaltyFreezeMinutes(req.penaltyFreezeMinutes()); + assigned.setPenaltyRedCards(req.penaltyRedCards()); + assigned.setStatus("PENDING"); + assignedTaskRepository.save(assigned); + + sendMessage(me.getUserId(), l.getLockee(), + me.getName() + " hat dir eine Aufgabe gestellt. Du hast " + req.acceptDeadlineMinutes() + + " Minuten, um sie anzunehmen.", + "/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + // ── Lockee: Aufgabe annehmen ─────────────────────────────────────────────── + + @Transactional + @PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/accept") + public ResponseEntity acceptAssignedTask(@PathVariable UUID lockId, @PathVariable UUID taskId, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + var taskOpt = assignedTaskRepository.findById(taskId); + if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId)) + return ResponseEntity.notFound().build(); + var task = taskOpt.get(); + if (!"PENDING".equals(task.getStatus())) + return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend.")); + if (task.getAcceptDeadline().isBefore(LocalDateTime.now())) { + // Bereits abgelaufen – Strafe anwenden + task.setStatus("EXPIRED"); + cardLockServiceFactory.create(l).applyAssignedTaskPenalty(task); + assignedTaskRepository.save(task); + return ResponseEntity.status(409) + .body(Map.of("error", "Die Annahme-Frist ist abgelaufen. Die Strafe wurde angewendet.")); + } + boolean hasActiveTask = (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) + || (l.getTaskUntil() != null && l.getTaskUntil().isAfter(LocalDateTime.now())); + if (hasActiveTask) + return ResponseEntity.status(409).body(Map.of("error", "Du hast bereits eine laufende Aufgabe.")); + + // Aufgabe aktivieren – separater Task-Timer, kein Freeze + String title = task.getTaskTitle() != null ? task.getTaskTitle() : task.getTaskText(); + l.setCurrentTask(title); + l.setCurrentTaskDescription(task.getTaskDescription()); + if (task.getTaskMinutes() != null && task.getTaskMinutes() > 0) { + l.setTaskUntil(LocalDateTime.now().plusMinutes(task.getTaskMinutes())); + + // Fälligkeit aller anderen offenen Aufgaben um die Task-Dauer verschieben + final int extraMinutes = task.getTaskMinutes(); + assignedTaskRepository.findByLockIdAndStatus(lockId, "PENDING").stream() + .filter(t -> !t.getTaskId().equals(taskId)).forEach(t -> { + t.setAcceptDeadline(t.getAcceptDeadline().plusMinutes(extraMinutes)); + assignedTaskRepository.save(t); + }); + } + task.setStatus("ACCEPTED"); + assignedTaskRepository.save(task); + cardlockRepository.save(l); + + String meName = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + sendMessage(myId, l.getKeyholder(), meName + " hat die gestellte Aufgabe angenommen.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + return ResponseEntity.noContent().build(); + } + + // ── Lockee: Aufgabe ablehnen ─────────────────────────────────────────────── + + @Transactional + @PostMapping("/cardlock/{lockId}/assigned-tasks/{taskId}/decline") + public ResponseEntity declineAssignedTask(@PathVariable UUID lockId, @PathVariable UUID taskId, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + var taskOpt = assignedTaskRepository.findById(taskId); + if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId)) + return ResponseEntity.notFound().build(); + var task = taskOpt.get(); + if (!"PENDING".equals(task.getStatus())) + return ResponseEntity.status(409).body(Map.of("error", "Diese Aufgabe ist nicht mehr ausstehend.")); + + task.setStatus("DECLINED"); + cardLockServiceFactory.create(l).applyAssignedTaskPenalty(task); + assignedTaskRepository.save(task); + + String meNameDecl = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + sendMessage(myId, l.getKeyholder(), + meNameDecl + " hat die gestellte Aufgabe abgelehnt. Die Strafe wurde angewendet.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + return ResponseEntity.noContent().build(); + } + + // ── Keyholder: Aufgabe zurückziehen ─────────────────────────────────────── + + @Transactional + @DeleteMapping("/as-keyholder/{lockId}/assigned-tasks/{taskId}") + public ResponseEntity cancelAssignedTask(@PathVariable UUID lockId, @PathVariable UUID taskId, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + + var taskOpt = assignedTaskRepository.findById(taskId); + if (taskOpt.isEmpty() || !taskOpt.get().getLockId().equals(lockId)) + return ResponseEntity.notFound().build(); + var task = taskOpt.get(); + if (!"PENDING".equals(task.getStatus())) + return ResponseEntity.status(409).body(Map.of("error", "Aufgabe ist nicht mehr ausstehend.")); + + assignedTaskRepository.delete(task); + return ResponseEntity.noContent().build(); + } + + record FreezeRequest(String frozenUntil) { + } + + @Transactional + @PostMapping("/as-keyholder/{lockId}/freeze") + public ResponseEntity freezeLock(@PathVariable UUID lockId, @RequestBody FreezeRequest req, + Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Das Lock ist gerade durch eine Aufgabe eingefroren.")); + } + + LocalDateTime until; + try { + until = LocalDateTime.parse(req.frozenUntil()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat.")); + } + if (!until.isAfter(LocalDateTime.now())) { + return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen.")); + } + + l.setFrozenUntil(until); + cardlockRepository.save(l); + + sendMessage(myId, l.getLockee(), + me.getName() + " hat dein Lock bis " + + until.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")) + " " + + until.toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + + " Uhr eingefroren.", + "/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + @Transactional + @DeleteMapping("/as-keyholder/{lockId}/freeze") + public ResponseEntity unfreezeLock(@PathVariable UUID lockId, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + if (l.getCurrentTask() != null && !l.getCurrentTask().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", + "Das Lock ist durch eine Aufgabe eingefroren und kann nicht manuell entfroren werden.")); + } + + l.setFrozenUntil(null); + cardlockRepository.save(l); + + sendMessage(myId, l.getLockee(), me.getName() + " hat dein Lock wieder entfroren.", + "/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + @Transactional + @PostMapping("/as-keyholder/{lockId}/request-unlock") + public ResponseEntity requestUnlock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) + return ResponseEntity.status(403).build(); + + l.setKeyholderRequestedUnlock(true); + cardlockRepository.save(l); + + sendMessage(myId, l.getLockee(), + "Dein Keyholder hat das Lock freigeschaltet. Du erhältst beim nächsten Laden deinen Entsperrcode.", + "/games/chastity/activelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + @Transactional + @PostMapping("/cardlock/{lockId}/emergency-unlock") + public ResponseEntity requestEmergencyUnlock(@PathVariable UUID lockId, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = cardlockRepository.findById(lockId); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + if (l.isTestLock()) + return ResponseEntity.badRequest().build(); + if (l.getEmergencyUnlockRequestedAt() != null) + return ResponseEntity.status(409).build(); + + l.setEmergencyUnlockRequestedAt(LocalDateTime.now()); + + if (l.getKeyholder() == null) { + // Self-Lock ohne Keyholderin → sofort öffnen + l.setEmergencyAutoUnlocked(true); + l.setKeyholderRequestedUnlock(true); + } else { + // Keyholderin benachrichtigen + sendMessage(myId, l.getKeyholder(), "⚠️ NOTFALL: " + me.getName() + + " bittet dringend um Freigabe des Locks. Bitte reagiere innerhalb einer Stunde, sonst öffnet sich das Lock automatisch.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.EMERGENCY); + } + cardlockRepository.save(l); + return ResponseEntity.noContent().build(); + } + + private String cardLabel(CardEnum card) { + return switch (card) { + case RED -> "Rote Karte"; + case GREEN -> "Grüne Karte"; + case YELLOW -> "Gelbe Karte"; + case TASK -> "Aufgabe"; + case FREEZE -> "Freeze"; + case RESET -> "Reset"; + case DOUBLE_UP -> "Double Up"; + case CUM -> "Kommen"; + case CUM_IN_CAGE -> "Kommen im Käfig"; + }; + } + + @GetMapping("/invitation/{token}") + public void confirmInvitation(@PathVariable String token, jakarta.servlet.http.HttpServletResponse response) + throws Exception { + var invOpt = invitationRepository.findByToken(token); + if (invOpt.isEmpty()) { + response.sendRedirect("/games/chastity/keyholder-invitation-confirmed.html?status=invalid"); + return; + } + var inv = invOpt.get(); + var lockOpt = cardlockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) { + response.sendRedirect("/games/chastity/keyholder-invitation-confirmed.html?status=invalid"); + return; + } + var lock = lockOpt.get(); + lock.setKeyholder(inv.getKeyholderUserId()); + cardlockRepository.save(lock); + invitationRepository.delete(inv); + response.sendRedirect("/games/chastity/keyholder.html"); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.java new file mode 100644 index 0000000..646e3ff --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockEntity.java @@ -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 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 availableCards; + + @Convert(converter = TaskListConverter.class) + @Column(columnDefinition = "TEXT") + private List tasksInQueue; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java new file mode 100644 index 0000000..e379070 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockRepository.java @@ -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 { + + @Modifying + @Query("DELETE FROM CardLockEntity c WHERE c.lockId = :lockId") + void deleteByLockId(UUID lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java new file mode 100644 index 0000000..d955035 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockService.java @@ -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 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); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java new file mode 100644 index 0000000..1cc5ade --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardLockServiceFactory.java @@ -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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.java new file mode 100644 index 0000000..8c4d339 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockRepository.java @@ -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 { + + List findByLockee(UUID lockee); + List findByKeyholderAndUnlockTimeIsNull(UUID keyholder); + boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.java new file mode 100644 index 0000000..e4677d9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateController.java @@ -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 cardCountsMin, + Map cardCountsMax, + Integer pickEveryMinute, + boolean accumulatePicks, + boolean showRemainingCards, + Integer hygineOpeningDurationMinutes, + Integer hygineOpeningEveryMinites, + List tasks, + boolean requiresVerification, + TaskMode taskMode + ) {} + + private Map toDto(CardlockTemplateEntity t) { + Map 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(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + List> result = templateRepository.findByOwner(myId) + .stream().map(this::toDto).collect(Collectors.toList()); + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity> 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> 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 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); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.java new file mode 100644 index 0000000..e825b44 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateEntity.java @@ -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 cardCountsMin; + @Convert(converter = CardCountMapConverter.class) + @Column(columnDefinition = "TEXT") + private Map cardCountsMax; + @Column + private Integer pickEveryMinute; + @Column + private boolean accumulatePicks; + @Column + private boolean showRemainingCards; + @Column + private boolean requiresVerification; + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.java new file mode 100644 index 0000000..09e46ad --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CardlockTemplateRepository.java @@ -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 { + List findByOwner(UUID owner); + long countByOwner(UUID owner); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumCard.java new file mode 100644 index 0000000..9744eb5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumCard.java @@ -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)); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.java new file mode 100644 index 0000000..c10e963 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/CumInCageCard.java @@ -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)); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.java new file mode 100644 index 0000000..e13fc13 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/DoubleUpCard.java @@ -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()); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/FreezeCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/FreezeCard.java new file mode 100644 index 0000000..d311849 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/FreezeCard.java @@ -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()); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/GreenCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/GreenCard.java new file mode 100644 index 0000000..007fba8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/GreenCard.java @@ -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()); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/RedCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/RedCard.java new file mode 100644 index 0000000..89ac95b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/RedCard.java @@ -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()); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/ResetCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/ResetCard.java new file mode 100644 index 0000000..33c53aa --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/ResetCard.java @@ -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()); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/TaskCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/TaskCard.java new file mode 100644 index 0000000..38173b6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/TaskCard.java @@ -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()); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/cardlock/YellowCard.java b/src/main/java/de/oaa/xxx/games/chastity/cardlock/YellowCard.java new file mode 100644 index 0000000..2de9b81 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/cardlock/YellowCard.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.cardlock; + +public class YellowCard implements Card { + + @Override + public CardDTO processCard(CardLockService lock) { + return new CardDTO(CardEnum.YELLOW, lock.yellowCard()); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockController.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockController.java new file mode 100644 index 0000000..eb21a41 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockController.java @@ -0,0 +1,22 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.oaa.xxx.games.chastity.tasks.Task; + +@RestController +@RequestMapping("games/chastity/lock/") +public class BaseLockController { + + @GetMapping("/{lockId}/tasks") + public ResponseEntity> getTasks() { + return null; + } + + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java new file mode 100644 index 0000000..593499e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockEntity.java @@ -0,0 +1,105 @@ +package de.oaa.xxx.games.chastity.common; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.lockcontroll.LockControllType; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.tasks.TaskListConverter; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "active_lock") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "lock_type", discriminatorType = DiscriminatorType.STRING) +public class BaseLockEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID lockId; + @Column(nullable = false) + private String name; + // --- Gemeinsame Settings --- + @Column(nullable = false) + private UUID lockee; + @Column + private UUID keyholder; + @Column(nullable = false) + private boolean testLock; + @Column + private boolean requiresVerification; + @Column + private Integer unlockCodeLength; + @Column + private String unlockCode; + @Enumerated(EnumType.STRING) + @Column(length = 20) + private LockControllType controllType; + + // --- Timing & Hygiene --- + @Column + private LocalDateTime startTime; + @Column + private LocalDateTime unlockTime; + @Column + private LocalDateTime lastHygineOpening; + @Column + private Integer hygineOpeningDurationMinutes; + @Column + private Integer hygineOpeningEveryMinites; + @Column + private LocalDateTime tempOpeningTime; // If null, not while hygine opening + @Column + private Integer tempOpeningDuration; + @Column + private TempOpeningReason tempOpeningReason; + @Column + private LocalDateTime frozenUntil; + + // --- Aufgaben-System (Basis) --- + @Convert(converter = TaskListConverter.class) + @Column(columnDefinition = "TEXT") + private List tasks; + @Column + private String currentTask; + @Column(columnDefinition = "TEXT") + private String currentTaskDescription; + @Column + private LocalDateTime taskUntil; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TaskMode taskMode = TaskMode.RANDOM; + + // --- Notfall- & Keyholder-Status --- + @Column(nullable = false) + private boolean keyholderRequestedUnlock = false; + @Column + private LocalDateTime emergencyUnlockRequestedAt; + @Column(nullable = false) + private boolean emergencyAutoUnlocked = false; + + // Getter & Setter + public TaskMode getTaskMode() { + return taskMode != null ? taskMode : TaskMode.RANDOM; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java new file mode 100644 index 0000000..e6e2598 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BaseLockRepository extends JpaRepository{ + + Optional findByLockee(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java new file mode 100644 index 0000000..868ab2f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockService.java @@ -0,0 +1,310 @@ +package de.oaa.xxx.games.chastity.common; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.community.CommunityTaskVoteEntity; +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.KeyholderNotificationEntity; +import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceEntity; +import de.oaa.xxx.games.chastity.keyholder.KeyholderTaskChoiceRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderVerificationRepository; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; +import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService; +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.user.UserRepository; + +public abstract class BaseLockService { + + private static final Logger LOGGER = LoggerFactory.getLogger(BaseLockService.class); + + protected final CommunityVerificationVoteRepository communityVerificationVoteRepository; + protected final CommunityVerificationRepository communityVerificationRepository; + protected final KeyholderVerificationRepository keyholderVerificationRepository; + protected final GameHistoryRepository gameHistoryRepository; + protected final UserRepository userRepository; + protected final KeyholderNotificationRepository keyholderNotificationRepository; + protected final SystemMessageService systemMessageService; + protected final UnlockCodeHistoryService unlockCodeHistoryService; + protected final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository; + protected final CommunityTaskVoteRepository communityTaskVoteRepository; + + /** Wird von Subklassen gesetzt; steuert wie das physische Schloss (neu) verriegelt wird. */ + protected de.oaa.xxx.games.chastity.lockcontroll.LockControl lockControl; + + public de.oaa.xxx.games.chastity.lockcontroll.LockControl getLockControl() { + return lockControl; + } + + // ── Abstrakte Methoden ──────────────────────────────────────────────────── + + protected abstract BaseLockEntity getLock(); + protected abstract void saveLock(); + protected abstract GameType getGameType(); + /** Wie wird Überschreitung der Hygiene-Öffnung bestraft? CardLock friert ein, TimeLock verlängert die Zeit. */ + protected abstract void applyHygieneOvertime(Long overtime); + + // ── Hook-Methoden (Standard: No-Op) ─────────────────────────────────────── + + /** TimeLock: lockControl.unlock() vor dem finalen Entsperren aufrufen. */ + protected void beforePhysicalUnlock() {} + /** TimeLock: lockControl.lock() nach dem Schließen der Hygiene-Öffnung aufrufen. */ + protected void afterHygieneClosing() {} + + public BaseLockService( + CommunityVerificationVoteRepository communityVerificationVoteRepository, + CommunityVerificationRepository communityVerificationRepository, + KeyholderVerificationRepository keyholderVerificationRepository, + GameHistoryRepository gameHistoryRepository, + UserRepository userRepository, + KeyholderNotificationRepository keyholderNotificationRepository, + SystemMessageService systemMessageService, + UnlockCodeHistoryService unlockCodeHistoryService, + KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + CommunityTaskVoteRepository communityTaskVoteRepository) { + this.communityVerificationVoteRepository = communityVerificationVoteRepository; + this.communityVerificationRepository = communityVerificationRepository; + this.keyholderVerificationRepository = keyholderVerificationRepository; + this.gameHistoryRepository = gameHistoryRepository; + this.userRepository = userRepository; + this.keyholderNotificationRepository = keyholderNotificationRepository; + this.systemMessageService = systemMessageService; + this.unlockCodeHistoryService = unlockCodeHistoryService; + this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository; + this.communityTaskVoteRepository = communityTaskVoteRepository; + } + + // ── Lockee-Prüfung ──────────────────────────────────────────────────────── + + /** + * Prüft ob der Anwender bereits ein aktives Lock (CardLock oder TimeLock) als Lockee hat. + * Ein Lock gilt als aktiv wenn startTime gesetzt und unlockTime null ist. + */ + public static boolean hasActiveLock(UUID lockeeId, CardlockRepository cardlockRepo, + TimeLockRepository timelockRepo) { + return cardlockRepo.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId) + || timelockRepo.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(lockeeId); + } + + // ── Gemeinsame Hilfsmethoden ────────────────────────────────────────────── + + protected Long calcOvertime() { + LocalDateTime now = LocalDateTime.now(); + BaseLockEntity lock = getLock(); + if (lock.getTempOpeningTime() != null && lock.getTempOpeningDuration() != null) { + LocalDateTime dueTime = lock.getTempOpeningTime().plusMinutes(lock.getTempOpeningDuration()); + if (now.isAfter(dueTime)) { + return ChronoUnit.MINUTES.between(dueTime, now); + } + } + return null; + } + + protected void reportKeyholder(Long overtime) { + BaseLockEntity lock = getLock(); + KeyholderNotificationEntity notification = new KeyholderNotificationEntity(); + notification.setLockId(lock.getLockId()); + notification.setLockeeId(lock.getLockee()); + notification.setKeyholderUserId(lock.getKeyholder()); + notification.setViolationTime(LocalDateTime.now()); + notification.setOvertimeMinutes(overtime); + notification.setOpeningReason(de.oaa.xxx.games.chastity.unlock.TempOpeningReason.HYGIENE); + keyholderNotificationRepository.save(notification); + userRepository.findById(lock.getKeyholder()).ifPresent(kh -> + sendMessage(lock.getLockee(), kh.getUserId(), + "Deine Lockee hat die Hygiene-Öffnung um " + overtime + " Minuten überschritten.", + "/games/chastity/keyholder.html?lockId=" + lock.getLockId(), + de.oaa.xxx.social.entity.MessageCause.GAME_STATE)); + } + + protected void sendMessage(UUID senderId, UUID receiverId, String text, String targetUrl, + de.oaa.xxx.social.entity.MessageCause cause) { + systemMessageService.send(senderId, receiverId, text, targetUrl, cause); + } + + // ── Aufgaben ────────────────────────────────────────────────────────────── + + public void task(Task task) { + BaseLockEntity lock = getLock(); + LOGGER.debug("Apply task {}", task); + lock.setCurrentTask(task.getTitle()); + lock.setCurrentTaskDescription(task.getDescription()); + if (task.getMinutes() != null && task.getMinutes() > 0) { + lock.setTaskUntil(LocalDateTime.now().plusMinutes(task.getMinutes())); + } + } + + public String clearTask() { + BaseLockEntity lock = getLock(); + LOGGER.debug("Clear task"); + lock.setCurrentTask(null); + lock.setCurrentTaskDescription(null); + lock.setTaskUntil(null); + return ""; + } + + protected void applyRandomTask() { + LOGGER.debug("Apply random task"); + var tasks = getLock().getTasks(); + if (tasks != null && !tasks.isEmpty()) { + task(tasks.get(new Random().nextInt(tasks.size()))); + } + } + + protected void startKeyholderVote() { + BaseLockEntity lock = getLock(); + KeyholderTaskChoiceEntity choice = new KeyholderTaskChoiceEntity(); + choice.setLockId(lock.getLockId()); + choice.setCreatedAt(LocalDateTime.now()); + choice.setActive(true); + choice.setExpiresAt(LocalDateTime.now().plusHours(1)); + keyholderTaskChoiceRepository.save(choice); + userRepository.findById(lock.getKeyholder()) + .ifPresent(kh -> sendMessage(lock.getLockee(), kh.getUserId(), + "Deine Lockee hat eine Aufgaben-Karte gezogen – wähle eine Aufgabe aus.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE)); + } + + protected void startCommunityVote() { + BaseLockEntity lock = getLock(); + CommunityTaskVoteEntity vote = new CommunityTaskVoteEntity(); + vote.setLockId(lock.getLockId()); + vote.setCreatedAt(LocalDateTime.now()); + vote.setExpiresAt(LocalDateTime.now().plusHours(1)); + vote.setActive(true); + communityTaskVoteRepository.save(vote); + } + + // ── Temporäre Öffnung ───────────────────────────────────────────────────── + + protected void startTempOpening(TempOpeningReason reason, Integer duration) { + BaseLockEntity lock = getLock(); + assert duration != null; + lock.setTempOpeningReason(reason); + lock.setTempOpeningTime(LocalDateTime.now()); + lock.setTempOpeningDuration(duration); + saveLock(); + unlockCodeHistoryService.save(lock.getLockee(), lock.getLockId(), lock.getName(), lock.getUnlockCode(), reason.toString()); + } + + public String endTempOpening() { + var lock = getLock(); + var now = LocalDateTime.now(); + var overtime = calcOvertime(); + if (overtime != null) { + if (lock.getKeyholder() != null) { + reportKeyholder(overtime); + } + applyHygieneOvertime(overtime); + } + afterHygieneClosing(); + if (TempOpeningReason.HYGIENE == lock.getTempOpeningReason()) { + lock.setLastHygineOpening(now); + } + lock.setTempOpeningReason(null); + lock.setTempOpeningDuration(null); + lock.setTempOpeningTime(null); + if (lockControl != null + && lock.getControllType() != de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE) { + lockControl.lock(); + saveLock(); + return lock.getUnlockCode() != null ? lock.getUnlockCode() : ""; + } + var code = CodeCreator.createNumeric(lock.getUnlockCodeLength() != null ? lock.getUnlockCodeLength() : 5); + lock.setUnlockCode(code); + saveLock(); + return code; + } + + // ── Entsperren ──────────────────────────────────────────────────────────── + + public void unlock(String unlockCode) { + BaseLockEntity lock = getLock(); + beforePhysicalUnlock(); + lock.setUnlockTime(LocalDateTime.now()); + + boolean valid = true; + if (lock.isEmergencyAutoUnlocked()) { + valid = false; + LOGGER.debug("Lock invalid - Emergency Auto-Unlock (1h timer)"); + } else if (lock.isTestLock()) { + valid = false; + } else if (Duration.between(lock.getStartTime(), lock.getUnlockTime()).toHours() > 24) { + Set verifications; + if (lock.getKeyholder() != null) { + verifications = keyholderVerificationRepository.findByLockId(lock.getLockId()).stream() + .filter(v -> v.isValid()) + .map(v -> v.getVerificationDate()) + .collect(Collectors.toSet()); + } else { + verifications = communityVerificationRepository.findByLockId(lock.getLockId()).stream() + .filter(v -> v.isValid()) + .map(v -> v.getVerificationDate()) + .collect(Collectors.toSet()); + } + LocalDate current = lock.getStartTime().toLocalDate(); + LocalDate last = lock.getUnlockTime().toLocalDate().minusDays(1); + while (!current.isAfter(last)) { + if (!verifications.contains(current)) { + valid = false; + LOGGER.debug("Lock invalid - no daily verification on {}", current); + break; + } + current = current.plusDays(1); + } + } + + LOGGER.debug("Unlocked at {}", lock.getUnlockTime()); + saveLock(); + + if (lockControl != null) { + lockControl.cleanup(); + } + + if (valid) { + long durationMinutes = Duration.between(lock.getStartTime(), lock.getUnlockTime()).toMinutes(); + GameHistoryEntity entry = new GameHistoryEntity(); + entry.setGameType(getGameType()); + entry.setGameName(lock.getName()); + entry.setStartTime(lock.getStartTime()); + entry.setEndTime(lock.getUnlockTime()); + entry.setDurationMinutes(durationMinutes); + entry.addParticipant(lock.getLockee(), GameRole.LOCKEE); + if (lock.getKeyholder() != null) { + entry.addParticipant(lock.getKeyholder(), GameRole.KEYHOLDER); + } + gameHistoryRepository.save(entry); + + int minutes = (int) durationMinutes; + userRepository.findById(lock.getLockee()).ifPresent(u -> { + u.setLockeeXp(u.getLockeeXp() + minutes); + userRepository.save(u); + }); + if (lock.getKeyholder() != null) { + userRepository.findById(lock.getKeyholder()).ifPresent(u -> { + u.setKeyholderXp(u.getKeyholderXp() + minutes); + userRepository.save(u); + }); + } + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.java new file mode 100644 index 0000000..295ae9a --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateController.java @@ -0,0 +1,116 @@ +package de.oaa.xxx.games.chastity.common; + +import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateEntity; +import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateEntity; +import de.oaa.xxx.user.UserService; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.util.*; + +@RestController +@RequestMapping("/templates") +public class BaseLockTemplateController { + + private final BaseLockTemplateRepository templateRepository; + private final UserService userService; + + public BaseLockTemplateController(BaseLockTemplateRepository templateRepository, + UserService userService) { + this.templateRepository = templateRepository; + this.userService = userService; + } + + @GetMapping + public ResponseEntity> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var pageable = PageRequest.of(page, Math.min(size, 50), Sort.by("name")); + var pageResult = templateRepository.findByOwner(myId, pageable); + + var content = pageResult.getContent().stream().map(t -> { + Map dto = new LinkedHashMap<>(); + dto.put("templateId", t.getTemplateId()); + dto.put("name", t.getName()); + dto.put("lockType", t instanceof CardlockTemplateEntity ? "CARDLOCK" : "TIMELOCK"); + dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites()); + dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes()); + dto.put("taskCount", t.getTasks() != null ? t.getTasks().size() : 0); + dto.put("requiresVerification", t.isRequiresVerification()); + dto.put("published", t.isPublished()); + dto.put("showAuthor", t.isShowAuthor()); + return dto; + }).toList(); + + Map response = new LinkedHashMap<>(); + response.put("content", content); + response.put("page", pageResult.getNumber()); + response.put("totalPages", pageResult.getTotalPages()); + response.put("last", pageResult.isLast()); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + public ResponseEntity> getById(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var opt = templateRepository.findById(id); + if (opt.isEmpty()) return ResponseEntity.notFound().build(); + var t = opt.get(); + if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build(); + + if (t instanceof CardlockTemplateEntity c) { + return ResponseEntity.ok(toCardlockDto(c)); + } else if (t instanceof TimeLockTemplateEntity tl) { + return ResponseEntity.ok(toTimelockDto(tl)); + } + return ResponseEntity.notFound().build(); + } + + private Map toCardlockDto(CardlockTemplateEntity t) { + Map dto = new LinkedHashMap<>(); + dto.put("_type", "CARDLOCK"); + 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; + } + + private Map toTimelockDto(TimeLockTemplateEntity t) { + Map dto = new LinkedHashMap<>(); + dto.put("_type", "TIMELOCK"); + dto.put("templateId", t.getTemplateId()); + dto.put("name", t.getName()); + dto.put("minTimeInMinutes", t.getMinTimeInMinutes()); + dto.put("maxTimeInMinutes", t.getMaxTimeInMinutes()); + dto.put("endTimeVisible", t.isEndTimeVisible()); + dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites()); + dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes()); + dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of()); + dto.put("taskEveryMinutes", t.getTaskEveryMinutes()); + dto.put("minTasksPerDay", t.getMinTasksPerDay()); + dto.put("spinningWheelEntries", t.getSpinningWheelEntries() != null ? t.getSpinningWheelEntries() : List.of()); + dto.put("spinsEveryMinutes", t.getSpinsEveryMinutes()); + dto.put("minSpinsPerDay", t.getMinSpinsPerDay()); + dto.put("requiresVerification", t.isRequiresVerification()); + dto.put("taskMode", t.getTaskCardMode()); + dto.put("penaltyType", t.getPenaltyType()); + dto.put("penaltyValue", t.getPenaltyValue()); + return dto; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.java new file mode 100644 index 0000000..6b7205a --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateEntity.java @@ -0,0 +1,63 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.tasks.TaskListConverter; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "lock_template") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "lock_type", discriminatorType = DiscriminatorType.STRING) +public class BaseLockTemplateEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID templateId; + @Column(nullable = false) + private UUID owner; + @Column + private String name; + @Column + private Integer hygineOpeningDurationMinutes; + @Column + private Integer hygineOpeningEveryMinites; + @Convert(converter = TaskListConverter.class) + @Column(columnDefinition = "TEXT") + private List tasks; + @Column + private boolean requiresVerification; + @Column(nullable = false) + private TaskMode taskMode = TaskMode.RANDOM; + + @Column(nullable = false) + private boolean published = false; + + @Column(nullable = false) + private boolean showAuthor = false; + + @Column(nullable = false) + private long subscriberCount = 0; + + public TaskMode getTaskCardMode() { + return taskMode != null ? taskMode : TaskMode.RANDOM; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.java b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.java new file mode 100644 index 0000000..fa2a404 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/BaseLockTemplateRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BaseLockTemplateRepository extends JpaRepository { + List findByOwner(UUID owner); + List findByOwnerAndPublishedTrue(UUID owner); + Page findByOwner(UUID owner, Pageable pageable); + Page findByPublishedTrue(Pageable pageable); + Page findByPublishedTrueAndNameContainingIgnoreCase(String name, Pageable pageable); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/CodeCreator.java b/src/main/java/de/oaa/xxx/games/chastity/common/CodeCreator.java new file mode 100644 index 0000000..c24c7df --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/CodeCreator.java @@ -0,0 +1,25 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.Random; + +public class CodeCreator { + + private static final String CHARS_AN = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789"; + private static final String CHARS_N = "0123456789"; + + public static String createNumeric(int digits) { + return create(digits, CHARS_N); + } + + public static String createAlphanumeric(int digits) { + return create(digits, CHARS_AN); + } + + private static String create(int digits, String chars) { + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < digits; i++) { + sb.append(chars.charAt(new Random().nextInt(chars.length()))); + } + return sb.toString(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/LockType.java b/src/main/java/de/oaa/xxx/games/chastity/common/LockType.java new file mode 100644 index 0000000..8f412cb --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/LockType.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.games.chastity.common; + +public enum LockType { + + CARD, TIMED; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/PenaltyType.java b/src/main/java/de/oaa/xxx/games/chastity/common/PenaltyType.java new file mode 100644 index 0000000..63defba --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/PenaltyType.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.games.chastity.common; + +public enum PenaltyType { + + ADD, FREEZE, PILLORY; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/TemplateExploreController.java b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateExploreController.java new file mode 100644 index 0000000..47a7fdd --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateExploreController.java @@ -0,0 +1,322 @@ +package de.oaa.xxx.games.chastity.common; + +import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateEntity; +import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateRepository; +import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateEntity; +import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateRepository; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; +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.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/templates") +public class TemplateExploreController { + + private final BaseLockTemplateRepository templateRepository; + private final TemplateSubscriptionRepository subscriptionRepository; + private final TimeLockTemplateRepository timeLockTemplateRepository; + private final CardlockTemplateRepository cardlockTemplateRepository; + private final UserRepository userRepository; + private final UserService userService; + + public TemplateExploreController(BaseLockTemplateRepository templateRepository, + TemplateSubscriptionRepository subscriptionRepository, + TimeLockTemplateRepository timeLockTemplateRepository, + CardlockTemplateRepository cardlockTemplateRepository, + UserRepository userRepository, + UserService userService) { + this.templateRepository = templateRepository; + this.subscriptionRepository = subscriptionRepository; + this.timeLockTemplateRepository = timeLockTemplateRepository; + this.cardlockTemplateRepository = cardlockTemplateRepository; + this.userRepository = userRepository; + this.userService = userService; + } + + // ── Öffentliche Vorlagen entdecken ──────────────────────────────────────── + + @GetMapping("/public") + public ResponseEntity> getPublicTemplates( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "") String q, + Principal principal) { + + UUID myId = userService.requireUser(principal).getUserId(); + + size = Math.min(size, 20); + var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "subscriberCount")); + + Page pageResult = q.isBlank() + ? templateRepository.findByPublishedTrue(pageable) + : templateRepository.findByPublishedTrueAndNameContainingIgnoreCase(q.trim(), pageable); + + Set subscribedIds = subscriptionRepository.findByUserId(myId) + .stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet()); + + List> content = pageResult.getContent().stream() + .map(t -> toPublicDto(t, myId, subscribedIds)) + .toList(); + + return ResponseEntity.ok(Map.of( + "content", content, + "page", pageResult.getNumber(), + "hasMore", !pageResult.isLast() + )); + } + + // ── Einzelne Vorlage per ID (für Detail-Dialog) ────────────────────────── + + @GetMapping("/{id}/public") + public ResponseEntity> getTemplate( + @PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var tOpt = templateRepository.findById(id); + if (tOpt.isEmpty()) return ResponseEntity.notFound().build(); + var t = tOpt.get(); + + // Sichtbar wenn: eigene Vorlage ODER veröffentlicht + if (!t.getOwner().equals(myId) && !t.isPublished()) return ResponseEntity.status(403).build(); + + Set subscribedIds = subscriptionRepository.findByUserId(myId) + .stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet()); + return ResponseEntity.ok(toPublicDto(t, myId, subscribedIds)); + } + + // ── Eigene Vorlagen (für Auswahl-Dropdown) ─────────────────────────────── + + @GetMapping("/mine") + public ResponseEntity>> getMyTemplates(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List> result = templateRepository.findByOwner(myId).stream() + .map(t -> toPublicDto(t, myId, Set.of())) + .toList(); + return ResponseEntity.ok(result); + } + + // ── Abonnierte Vorlagen ─────────────────────────────────────────────────── + + @GetMapping("/subscribed") + public ResponseEntity>> getSubscribedTemplates(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List subs = subscriptionRepository.findByUserId(myId); + Set subscribedIds = subs.stream().map(TemplateSubscriptionEntity::getTemplateId).collect(Collectors.toSet()); + + List> result = new ArrayList<>(); + for (var sub : subs) { + templateRepository.findById(sub.getTemplateId()).ifPresent(t -> { + if (t.isPublished()) { + result.add(toPublicDto(t, myId, subscribedIds)); + } + }); + } + return ResponseEntity.ok(result); + } + + // ── Abonnieren ──────────────────────────────────────────────────────────── + + @PostMapping("/{id}/subscribe") + @Transactional + public ResponseEntity subscribe(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var tOpt = templateRepository.findById(id); + if (tOpt.isEmpty() || !tOpt.get().isPublished()) return ResponseEntity.notFound().build(); + if (tOpt.get().getOwner().equals(myId)) return ResponseEntity.status(409).build(); + if (subscriptionRepository.findByUserIdAndTemplateId(myId, id).isPresent()) + return ResponseEntity.status(409).build(); + + var sub = new TemplateSubscriptionEntity(); + sub.setUserId(myId); + sub.setTemplateId(id); + sub.setSubscribedAt(LocalDateTime.now()); + subscriptionRepository.save(sub); + + var t = tOpt.get(); + t.setSubscriberCount(t.getSubscriberCount() + 1); + templateRepository.save(t); + + return ResponseEntity.noContent().build(); + } + + // ── Abo kündigen ────────────────────────────────────────────────────────── + + @DeleteMapping("/{id}/subscribe") + @Transactional + public ResponseEntity unsubscribe(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var subOpt = subscriptionRepository.findByUserIdAndTemplateId(myId, id); + if (subOpt.isEmpty()) return ResponseEntity.noContent().build(); + + subscriptionRepository.delete(subOpt.get()); + + templateRepository.findById(id).ifPresent(t -> { + t.setSubscriberCount(Math.max(0, t.getSubscriberCount() - 1)); + templateRepository.save(t); + }); + + return ResponseEntity.noContent().build(); + } + + // ── Kopie erstellen (Fork) ──────────────────────────────────────────────── + + @PostMapping("/{id}/fork") + @Transactional + public ResponseEntity> fork(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var tOpt = templateRepository.findById(id); + if (tOpt.isEmpty() || !tOpt.get().isPublished()) return ResponseEntity.notFound().build(); + + var source = tOpt.get(); + String copyName = (source.getName() != null ? source.getName() : "Vorlage") + " (Kopie)"; + + UUID newId; + if (source instanceof TimeLockTemplateEntity tl) { + var copy = new TimeLockTemplateEntity(); + copy.setOwner(myId); + copy.setName(copyName); + copy.setMinTimeInMinutes(tl.getMinTimeInMinutes()); + copy.setMaxTimeInMinutes(tl.getMaxTimeInMinutes()); + copy.setEndTimeVisible(tl.isEndTimeVisible()); + copy.setHygineOpeningDurationMinutes(tl.getHygineOpeningDurationMinutes()); + copy.setHygineOpeningEveryMinites(tl.getHygineOpeningEveryMinites()); + copy.setTasks(tl.getTasks()); + copy.setTaskEveryMinutes(tl.getTaskEveryMinutes()); + copy.setMinTasksPerDay(tl.getMinTasksPerDay()); + copy.setSpinningWheelEntries(tl.getSpinningWheelEntries()); + copy.setSpinsEveryMinutes(tl.getSpinsEveryMinutes()); + copy.setMinSpinsPerDay(tl.getMinSpinsPerDay()); + copy.setRequiresVerification(tl.isRequiresVerification()); + copy.setTaskMode(tl.getTaskMode() != null ? tl.getTaskMode() : TaskMode.RANDOM); + copy.setPenaltyType(tl.getPenaltyType()); + copy.setPenaltyValue(tl.getPenaltyValue()); + newId = timeLockTemplateRepository.save(copy).getTemplateId(); + } else if (source instanceof CardlockTemplateEntity cl) { + var copy = new CardlockTemplateEntity(); + copy.setOwner(myId); + copy.setName(copyName); + copy.setCardCountsMin(cl.getCardCountsMin()); + copy.setCardCountsMax(cl.getCardCountsMax()); + copy.setPickEveryMinute(cl.getPickEveryMinute()); + copy.setAccumulatePicks(cl.isAccumulatePicks()); + copy.setShowRemainingCards(cl.isShowRemainingCards()); + copy.setHygineOpeningDurationMinutes(cl.getHygineOpeningDurationMinutes()); + copy.setHygineOpeningEveryMinites(cl.getHygineOpeningEveryMinites()); + copy.setTasks(cl.getTasks()); + copy.setRequiresVerification(cl.isRequiresVerification()); + copy.setTaskMode(cl.getTaskMode() != null ? cl.getTaskMode() : TaskMode.RANDOM); + newId = cardlockTemplateRepository.save(copy).getTemplateId(); + } else { + return ResponseEntity.status(500).build(); + } + + return ResponseEntity.ok(Map.of("templateId", newId.toString())); + } + + // ── Veröffentlichen ─────────────────────────────────────────────────────── + + @PatchMapping("/{id}/publish") + @Transactional + public ResponseEntity publish(@PathVariable UUID id, Principal principal) { + var me = userService.requireUser(principal); + + var tOpt = templateRepository.findById(id); + if (tOpt.isEmpty()) return ResponseEntity.notFound().build(); + var t = tOpt.get(); + if (!t.getOwner().equals(me.getUserId())) return ResponseEntity.status(403).build(); + + t.setPublished(true); + t.setShowAuthor(me.isProfilBeiVeroeffentlichungenSichtbar()); + templateRepository.save(t); + return ResponseEntity.noContent().build(); + } + + // ── Veröffentlichung entfernen ──────────────────────────────────────────── + + @DeleteMapping("/{id}/publish") + @Transactional + public ResponseEntity unpublish(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var tOpt = templateRepository.findById(id); + if (tOpt.isEmpty()) return ResponseEntity.notFound().build(); + var t = tOpt.get(); + if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build(); + + t.setPublished(false); + t.setShowAuthor(false); + t.setSubscriberCount(0); + templateRepository.save(t); + subscriptionRepository.deleteByTemplateId(id); + return ResponseEntity.noContent().build(); + } + + // ── DTO Helper ──────────────────────────────────────────────────────────── + + private Map toPublicDto(BaseLockTemplateEntity t, UUID myId, Set subscribedIds) { + boolean isOwn = t.getOwner().equals(myId); + boolean isSubscribed = subscribedIds.contains(t.getTemplateId()); + + String authorName = null; + String authorProfilePicture = null; + if (t.isShowAuthor()) { + var authorOpt = userRepository.findById(t.getOwner()); + authorName = authorOpt.map(u -> u.getName()).orElse(null); + authorProfilePicture = authorOpt.map(u -> u.getProfilePicture()).orElse(null); + } + + Map dto = new LinkedHashMap<>(); + dto.put("templateId", t.getTemplateId().toString()); + dto.put("lockType", t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK"); + dto.put("name", t.getName() != null ? t.getName() : ""); + dto.put("subscriberCount", t.getSubscriberCount()); + dto.put("authorName", authorName); + dto.put("authorProfilePicture", authorProfilePicture); + dto.put("isOwnTemplate", isOwn); + dto.put("isSubscribed", isSubscribed); + dto.put("taskCount", t.getTasks() != null ? t.getTasks().size() : 0); + dto.put("requiresVerification", t.isRequiresVerification()); + dto.put("hygieneEnabled", t.getHygineOpeningEveryMinites() != null); + dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites()); + dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes()); + dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of()); + dto.put("taskMode", t.getTaskMode() != null ? t.getTaskMode().name() : "RANDOM"); + + if (t instanceof TimeLockTemplateEntity tl) { + dto.put("minTimeInMinutes", tl.getMinTimeInMinutes()); + dto.put("maxTimeInMinutes", tl.getMaxTimeInMinutes()); + dto.put("endTimeVisible", tl.isEndTimeVisible()); + dto.put("taskEveryMinutes", tl.getTaskEveryMinutes()); + dto.put("minTasksPerDay", tl.getMinTasksPerDay()); + dto.put("spinningWheelEntries", tl.getSpinningWheelEntries() != null ? tl.getSpinningWheelEntries() : List.of()); + dto.put("spinsEveryMinutes", tl.getSpinsEveryMinutes()); + dto.put("minSpinsPerDay", tl.getMinSpinsPerDay()); + dto.put("penaltyType", tl.getPenaltyType() != null ? tl.getPenaltyType().name() : null); + dto.put("penaltyValue", tl.getPenaltyValue()); + } else if (t instanceof CardlockTemplateEntity cl) { + dto.put("cardCountsMin", cl.getCardCountsMin() != null ? cl.getCardCountsMin() : Map.of()); + dto.put("cardCountsMax", cl.getCardCountsMax() != null ? cl.getCardCountsMax() : Map.of()); + dto.put("pickEveryMinute", cl.getPickEveryMinute()); + dto.put("accumulatePicks", cl.isAccumulatePicks()); + dto.put("showRemainingCards",cl.isShowRemainingCards()); + } + + return dto; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.java b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.java new file mode 100644 index 0000000..1b31db7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionEntity.java @@ -0,0 +1,33 @@ +package de.oaa.xxx.games.chastity.common; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "template_subscription") +public class TemplateSubscriptionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private UUID templateId; + + @Column(nullable = false) + private LocalDateTime subscribedAt; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.java b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.java new file mode 100644 index 0000000..a9311fe --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/TemplateSubscriptionRepository.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.games.chastity.common; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface TemplateSubscriptionRepository extends JpaRepository { + Optional findByUserIdAndTemplateId(UUID userId, UUID templateId); + List findByUserId(UUID userId); + long countByTemplateId(UUID templateId); + @Transactional + void deleteByTemplateId(UUID templateId); + @Transactional + void deleteByUserIdAndTemplateId(UUID userId, UUID templateId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/Verification.java b/src/main/java/de/oaa/xxx/games/chastity/common/Verification.java new file mode 100644 index 0000000..038c6d6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/Verification.java @@ -0,0 +1,38 @@ +package de.oaa.xxx.games.chastity.common; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public interface Verification { + + LocalDate getVerificationDate(); + + boolean isValid(); + + VerificationCommonDTO toCommonVerification(); + + String getCode(); + + void setCode(String code); + + LocalDateTime getCreatedAt(); + + void setCreatedAt(LocalDateTime createdAt); + + UUID getKeyholderId(); + + void setKeyholderId(UUID id); + + UUID getLockeeId(); + + void setLockeeId(UUID id); + + UUID getLockId(); + + void setLockId(UUID id); + + UUID getId(); + + void setId(UUID id); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.java b/src/main/java/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.java new file mode 100644 index 0000000..dd8aea4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/common/VerificationCommonDTO.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.common; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record VerificationCommonDTO(UUID verficationId, UUID lockId, String code, + LocalDateTime createdAt, byte[] image, UUID lockeeId, UUID keyholderID) { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.java b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.java new file mode 100644 index 0000000..ff6ff7d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayController.java @@ -0,0 +1,52 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.oaa.xxx.user.UserEntity; +import de.oaa.xxx.user.UserRepository; + +@RestController +@RequestMapping("/games/chastity/community") +public class BaseCommunityDisplayController { + + private final BaseCommunityDisplayRepository baseCommunityDisplayRepository; + private final UserRepository userRepository; + + public BaseCommunityDisplayController(BaseCommunityDisplayRepository baseCommunityDisplayRepository, + UserRepository userRepository) { + this.baseCommunityDisplayRepository = baseCommunityDisplayRepository; + this.userRepository = userRepository; + } + + @GetMapping + public ResponseEntity> getAllDisplays( + @PageableDefault(size = 10, sort = "createdAt", direction = Direction.DESC) Pageable pageable) { + Page page = baseCommunityDisplayRepository.findAll(pageable); + + Set lockeeIds = page.getContent().stream() + .map(BaseCommunityDisplayEntity::getLockeeId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map nameMap = userRepository.findAllById(lockeeIds).stream() + .collect(Collectors.toMap(UserEntity::getUserId, UserEntity::getName)); + + Page result = page.map(e -> + e.toBaseCommunityDisplay(nameMap.getOrDefault(e.getLockeeId(), ""))); + + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.java new file mode 100644 index 0000000..4f9edd2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayDTO.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record BaseCommunityDisplayDTO(UUID displayId, UUID lockId, LocalDateTime createdAt, UUID lockeeId, + UUID keyholderId, String type, String lockeeName) { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.java new file mode 100644 index 0000000..1db476e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayEntity.java @@ -0,0 +1,45 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "community_display") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "display_type", discriminatorType = DiscriminatorType.STRING) +public abstract class BaseCommunityDisplayEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID displayId; + @Column(nullable = false) + private UUID lockId; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(nullable = false) + private UUID lockeeId; + @Column + private UUID keyholderId; + + public abstract String getType(); + + public BaseCommunityDisplayDTO toBaseCommunityDisplay(String lockeeName) { + return new BaseCommunityDisplayDTO(displayId, lockId, createdAt, lockeeId, keyholderId, getType(), lockeeName); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.java new file mode 100644 index 0000000..7bbe1a1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/BaseCommunityDisplayRepository.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BaseCommunityDisplayRepository extends JpaRepository { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryController.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryController.java new file mode 100644 index 0000000..cad9de4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryController.java @@ -0,0 +1,30 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/games/chastity/community/pillory") +public class CommunityPilloryController { + + private CommunityPilloryRepository repo; + + public CommunityPilloryController(CommunityPilloryRepository repo) { + this.repo = repo; + + } + + @GetMapping("/{id}") + public ResponseEntity getPillory(@PathVariable UUID id) { + var pillory = repo.findById(id); + if (pillory.isPresent()) { + return ResponseEntity.ok(pillory.get().toPillory()); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.java new file mode 100644 index 0000000..a31ea7a --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryDTO.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record CommunityPilloryDTO(UUID displayId, UUID lockId, LocalDateTime createdAt, UUID lockeeId, + UUID keyholderId, CommunityPilloryReason reason, String message) { +} + + diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.java new file mode 100644 index 0000000..a199e2c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryEntity.java @@ -0,0 +1,27 @@ +package de.oaa.xxx.games.chastity.community; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@DiscriminatorValue("PILLORY") +public class CommunityPilloryEntity extends BaseCommunityDisplayEntity { + + @Column + private CommunityPilloryReason reason; + @Column + private String message; + + @Override + public String getType() { return "PILLORY"; } + + public CommunityPilloryDTO toPillory() { + return new CommunityPilloryDTO(getDisplayId(), getLockId(), getCreatedAt(), getLockeeId(), getKeyholderId(), + reason, message); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.java new file mode 100644 index 0000000..a764c69 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryReason.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.games.chastity.community; + +public enum CommunityPilloryReason { + + HYGIENE_OPENING_EXEEDED, + KEYHOLDER_DESCESSION; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.java new file mode 100644 index 0000000..646670d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityPilloryRepository.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommunityPilloryRepository extends JpaRepository { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.java new file mode 100644 index 0000000..52237fd --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteController.java @@ -0,0 +1,105 @@ +package de.oaa.xxx.games.chastity.community; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +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.RestController; + +import de.oaa.xxx.games.chastity.common.BaseLockRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/games/chastity/community/taskvote") +public class CommunityTaskVoteController { + + private final UserService userService; + private final CommunityTaskVoteRepository taskVoteRepository; + private final CommunityTaskVoteEntryRepository taskVoteEntryRepository; + private final BaseLockRepository baseLockRepository; + + public CommunityTaskVoteController(UserService userService, + CommunityTaskVoteRepository taskVoteRepository, + CommunityTaskVoteEntryRepository taskVoteEntryRepository, + BaseLockRepository baseLockRepository) { + this.userService = userService; + this.taskVoteRepository = taskVoteRepository; + this.taskVoteEntryRepository = taskVoteEntryRepository; + this.baseLockRepository = baseLockRepository; + } + + @GetMapping("/{displayId}") + public ResponseEntity getVote(@PathVariable UUID displayId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var voteOpt = taskVoteRepository.findById(displayId); + if (voteOpt.isEmpty() || !voteOpt.get().isActive()) { + return ResponseEntity.noContent().build(); + } + var vote = voteOpt.get(); + var lockOpt = baseLockRepository.findById(vote.getLockId()); + if (lockOpt.isEmpty()) { + return ResponseEntity.noContent().build(); + } + var lock = lockOpt.get(); + boolean isOwnLock = lock.getLockee().equals(myId); + var entryList = new ArrayList(); + var tasks = lock.getTasks(); + var userVote = taskVoteEntryRepository.findByDisplayIdAndUserId(displayId, myId); + for (int i = 0; i < tasks.size(); i++) { + var task = tasks.get(i); + boolean myVote = false; + int votes = taskVoteEntryRepository.countByDisplayIdAndTaskIndex(displayId, i); + if (userVote != null && userVote.getTaskIndex() == i) { + myVote = true; + } + entryList.add(new CommunityTaskVoteDisplayEntryDTO(task.getTitle(), + task.getDescription(), task.getMinutes(), votes, myVote)); + } + return ResponseEntity.ok(new CommunityTaskVoteDisplayDTO(displayId, voteOpt.get().getCreatedAt(), displayId, myId, isOwnLock, entryList)); + } + + + @PostMapping("/{displayId}/vote/{taskIndex}") + @Transactional + public ResponseEntity castVote(@PathVariable UUID displayId, @PathVariable int taskIndex, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var voteOpt = taskVoteRepository.findById(displayId); + if (voteOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var vote = voteOpt.get(); + if (!vote.isActive() || vote.getExpiresAt().isBefore(LocalDateTime.now())) + return ResponseEntity.status(409).build(); + + var lockOpt = baseLockRepository.findById(vote.getLockId()); + if (lockOpt.isEmpty()) + return ResponseEntity.notFound().build(); + var lock = lockOpt.get(); + + if (lock.getLockee().equals(myId)) + return ResponseEntity.status(403).build(); + + if (lock.getTasks() == null || taskIndex < 0 || taskIndex >= lock.getTasks().size()) + return ResponseEntity.badRequest().build(); + + if (taskVoteEntryRepository.existsByDisplayIdAndUserId(displayId, myId)) + return ResponseEntity.status(409).build(); + + CommunityTaskVoteEntryEntity entry = new CommunityTaskVoteEntryEntity(); + entry.setDisplayId(displayId); + entry.setUserId(myId); + entry.setTaskIndex(taskIndex); + taskVoteEntryRepository.save(entry); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.java new file mode 100644 index 0000000..f8df8a4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDTO.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record CommunityTaskVoteDTO(UUID displayId, UUID lockId, LocalDateTime createdAt, UUID lockeeId, + UUID keyholderId, boolean active, LocalDateTime expiresAt, int winningTaskIndex, List entries) { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.java new file mode 100644 index 0000000..7ba5a3d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayDTO.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record CommunityTaskVoteDisplayDTO(UUID displayId, LocalDateTime createdAt, UUID lockeeId, UUID keyholderId, + boolean isOwnLock, List entries) { +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.java new file mode 100644 index 0000000..75ec2c5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteDisplayEntryDTO.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.community; + +public record CommunityTaskVoteDisplayEntryDTO(String title, String description, Integer minutes, Integer votes, + boolean ownVote) { +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.java new file mode 100644 index 0000000..f05012c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntity.java @@ -0,0 +1,33 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.List; + +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@DiscriminatorValue("TASK_VOTE") +public class CommunityTaskVoteEntity extends BaseCommunityDisplayEntity { + + @Column + private boolean active; + @Column(nullable = false) + private LocalDateTime expiresAt; + /** null until completed */ + @Column + private Integer winningTaskIndex; + + @Override + public String getType() { return "TASK_VOTE"; } + + public CommunityTaskVoteDTO toCommunityTaskVote(List entries) { + return new CommunityTaskVoteDTO(getDisplayId(), getLockId(), expiresAt, getLockeeId(), getKeyholderId(), active, + expiresAt, 0, entries); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.java new file mode 100644 index 0000000..131df30 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryDTO.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +public record CommunityTaskVoteEntryDTO(UUID entryId, UUID displayId, UUID userId, int taskIndex) { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.java new file mode 100644 index 0000000..0fdfbad --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryEntity.java @@ -0,0 +1,32 @@ +package de.oaa.xxx.games.chastity.community; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "community_task_vote_entry", + uniqueConstraints = @UniqueConstraint(columnNames = {"voteSessionId", "voterUserId"})) +public class CommunityTaskVoteEntryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID entryId; + + @Column(nullable = false) + private UUID displayId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private int taskIndex; + + public CommunityTaskVoteEntryDTO toCommunityTaskVoteEntry() { + return new CommunityTaskVoteEntryDTO(entryId, displayId, userId, taskIndex); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.java new file mode 100644 index 0000000..fa60bce --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteEntryRepository.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.games.chastity.community; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.UUID; + +public interface CommunityTaskVoteEntryRepository extends JpaRepository { + List findByDisplayId(UUID voteSessionId); + boolean existsByDisplayIdAndUserId(UUID displayId, UUID userId); + CommunityTaskVoteEntryEntity findByDisplayIdAndUserId(UUID displayId, UUID userId); + Integer countByDisplayIdAndTaskIndex(UUID displayId, int taskIndex); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.java new file mode 100644 index 0000000..a9487be --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteRepository.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.games.chastity.community; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface CommunityTaskVoteRepository extends JpaRepository { + List findByActiveTrue(); + List findByActiveTrueAndExpiresAtBefore(LocalDateTime time); + boolean existsByLockIdAndActiveTrue(UUID lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.java new file mode 100644 index 0000000..28e317e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityTaskVoteScheduler.java @@ -0,0 +1,114 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.social.SystemMessageService; + +@Component +public class CommunityTaskVoteScheduler { + + private static final Logger LOG = LoggerFactory.getLogger(CommunityTaskVoteScheduler.class); + + private final CommunityTaskVoteRepository communityTaskVoteRepository; + private final CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository; + private final CardlockRepository cardlockRepository; + private final AssignedTaskRepository assignedTaskRepository; + private SystemMessageService systemMessageService; + + public CommunityTaskVoteScheduler(CommunityTaskVoteRepository communityTaskVoteRepository, + CommunityTaskVoteEntryRepository communityTaskVoteEntryRepository, + CardlockRepository cardlockRepository, + AssignedTaskRepository assignedTaskRepository, + SystemMessageService systemMessageService) { + this.communityTaskVoteRepository = communityTaskVoteRepository; + this.communityTaskVoteEntryRepository = communityTaskVoteEntryRepository; + this.cardlockRepository = cardlockRepository; + this.assignedTaskRepository = assignedTaskRepository; + this.systemMessageService = systemMessageService; + } + + @Scheduled(fixedDelay = 60_000) + @Transactional + public void processExpiredVotes() { + var expired = communityTaskVoteRepository + .findByActiveTrueAndExpiresAtBefore(LocalDateTime.now()); + + for (var vote : expired) { + LOG.debug("Processing expired community task vote {}", vote.getDisplayId()); + + var lockOpt = cardlockRepository.findById(vote.getLockId()); + if (lockOpt.isEmpty()) { + vote.setActive(false); + communityTaskVoteRepository.save(vote); + continue; + } + var lock = lockOpt.get(); + List tasks = lock.getTasks(); + if (tasks == null || tasks.isEmpty()) { + vote.setActive(false); + communityTaskVoteRepository.save(vote); + continue; + } + + var entries = communityTaskVoteEntryRepository.findByDisplayId(vote.getDisplayId()); + int winnerIndex; + if (entries.isEmpty()) { + winnerIndex = new Random().nextInt(tasks.size()); + LOG.debug("No votes → random task index {}", winnerIndex); + } else { + int[] counts = new int[tasks.size()]; + for (var e : entries) { + if (e.getTaskIndex() >= 0 && e.getTaskIndex() < tasks.size()) { + counts[e.getTaskIndex()]++; + } + } + int max = Arrays.stream(counts).max().getAsInt(); + List winners = new ArrayList<>(); + for (int i = 0; i < counts.length; i++) { + if (counts[i] == max) winners.add(i); + } + winnerIndex = winners.get(new Random().nextInt(winners.size())); + LOG.debug("Vote winner: task index {} with {} votes", winnerIndex, max); + } + + Task task = tasks.get(winnerIndex); + AssignedTaskEntity assigned = new AssignedTaskEntity(); + assigned.setLockId(lock.getLockId()); + assigned.setTaskTitle(task.getTitle()); + assigned.setTaskDescription(task.getDescription()); + assigned.setTaskText(task.getTitle()); + assigned.setTaskMinutes(task.getMinutes()); + assigned.setAssignedAt(LocalDateTime.now()); + assigned.setAcceptDeadline(LocalDateTime.now().plusHours(1)); + assigned.setStatus("PENDING"); + assignedTaskRepository.save(assigned); + + vote.setActive(false); + vote.setWinningTaskIndex(winnerIndex); + communityTaskVoteRepository.save(vote); + + sendMessage(lock.getLockee(), + "Die Community hat für deine Aufgabe abgestimmt: \"" + task.getTitle() + "\"", + "/games/chastity/activelock.html?lockId=" + lock.getLockId()); + } + } + + private void sendMessage(UUID toId, String text, String targetUrl) { + systemMessageService.send(toId, toId, text, targetUrl, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationController.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationController.java new file mode 100644 index 0000000..b3c221d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationController.java @@ -0,0 +1,127 @@ +package de.oaa.xxx.games.chastity.community; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +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 de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/games/chastity/community/verification") +@Transactional +public class CommunityVerificationController { + + private final CommunityVerificationRepository verificationRepository; + private final CommunityVerificationVoteRepository verificationVoteRepository; + private final UserRepository userRepository; + private final UserService userService; + + public CommunityVerificationController(CommunityVerificationRepository verificationRepository, + CommunityVerificationVoteRepository verificationVoteRepository, + UserRepository userRepository, + UserService userService) { + this.verificationRepository = verificationRepository; + this.verificationVoteRepository = verificationVoteRepository; + this.userRepository = userRepository; + this.userService = userService; + } + + @GetMapping("/{displayId}") + public ResponseEntity get(@PathVariable UUID displayId, Principal principal) { + var optional = verificationRepository.findById(displayId); + if (optional.isEmpty()) { + return ResponseEntity.noContent().build(); + } + var entity = optional.get(); + + boolean isOwnLock = false; + Boolean myVote = null; + if (principal != null) { + var userOpt = userRepository.findByEmail(principal.getName()); + if (userOpt.isPresent()) { + var myId = userOpt.get().getUserId(); + isOwnLock = myId.equals(entity.getLockeeId()); + if (!isOwnLock) { + myVote = verificationVoteRepository + .findByVerificationIdAndUserId(displayId, myId) + .map(CommunityVerificationVoteEntity::isUpvote) + .orElse(null); + } + } + } + + var dto = entity.toVerification(isOwnLock, myVote); + verificationVoteRepository.findAllByVerificationId(displayId).stream() + .map(CommunityVerificationVoteEntity::toVerificationVote) + .forEach(dto.votes()::add); + return ResponseEntity.ok(dto); + } + + @GetMapping("/new") + public ResponseEntity createVerification() { + var verification = new CommunityVerificationEntity(); + verification.setDisplayId(UUID.randomUUID()); + verification.setCode(CodeCreator.createAlphanumeric(6)); + verification.setCreatedAt(LocalDateTime.now()); + verificationRepository.save(verification); + return ResponseEntity.ok(verification.toVerification(true, null)); + } + + @PutMapping("/{displayId}") + public ResponseEntity update(@PathVariable UUID displayId, @RequestBody CommunityVerificationDTO dto, + Principal principal) { + userService.requireUser(principal); + var entity = verificationRepository.findById(displayId).orElse(null); + if (entity == null) { + return ResponseEntity.notFound().build(); + } + if (entity.getCreatedAt().isBefore(LocalDateTime.now().minusHours(1))) { + return ResponseEntity.status(HttpStatus.GONE).build(); + } + if (dto.image() != null) { + entity.setImage(dto.image()); + } + verificationRepository.save(entity); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{verificationId}/vote/") + public ResponseEntity addVote(@PathVariable UUID verificationId, @RequestBody CommunityVerificationVoteDTO dto, + Principal principal) { + var user = userService.requireUser(principal); + if (!verificationRepository.existsById(verificationId)) return ResponseEntity.notFound().build(); + + var vEntity = verificationRepository.findById(verificationId).orElse(null); + if (vEntity == null) return ResponseEntity.notFound().build(); + if (user.getUserId().equals(vEntity.getLockeeId())) return ResponseEntity.status(403).build(); + if (verificationVoteRepository.findByVerificationIdAndUserId(verificationId, user.getUserId()).isPresent()) { + return ResponseEntity.status(409).build(); + } + var vote = new CommunityVerificationVoteEntity(); + vote.setVoteId(UUID.randomUUID()); + vote.setVerificationId(verificationId); + vote.setUserId(user.getUserId()); + vote.setUpvote(dto.upvote()); + if (dto.upvote()) { + vEntity.setCountUpvotes(vEntity.getCountUpvotes() + 1); + } else { + vEntity.setCountDownvotes(vEntity.getCountDownvotes() + 1); + } + verificationVoteRepository.save(vote); + return ResponseEntity.accepted().build(); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.java new file mode 100644 index 0000000..e599dbe --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationDTO.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record CommunityVerificationDTO( + UUID displayId, String code, LocalDateTime verificationTime, + byte[] image, List votes, int upvotes, int downvotes, + boolean isOwnLock, Boolean myVote) {} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.java new file mode 100644 index 0000000..0421628 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationEntity.java @@ -0,0 +1,62 @@ +package de.oaa.xxx.games.chastity.community; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.common.Verification; +import de.oaa.xxx.games.chastity.common.VerificationCommonDTO; +import jakarta.persistence.Column; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@DiscriminatorValue("VERIFICATION") +public class CommunityVerificationEntity extends BaseCommunityDisplayEntity implements Verification { + + @Column(nullable = false) + private String code; + @Column(columnDefinition = "MEDIUMBLOB") + private byte[] image; + @Column + private int countUpvotes; + @Column + private int countDownvotes; + + @Override + public String getType() { return "VERIFICATION"; } + + public CommunityVerificationDTO toVerification(boolean isOwnLock, Boolean myVote) { + return new CommunityVerificationDTO(getDisplayId(), code, getCreatedAt(), image, new ArrayList<>(), countUpvotes, countDownvotes, isOwnLock, myVote); + } + + @Override + public LocalDate getVerificationDate() { + return getCreatedAt().toLocalDate(); + } + + @Override + public boolean isValid() { + return countUpvotes > countDownvotes; + } + + @Override + public VerificationCommonDTO toCommonVerification() { + return new VerificationCommonDTO(getDisplayId(), getLockId(), code, getCreatedAt(), image, getLockeeId(), + getKeyholderId()); + } + + @Override + public UUID getId() { + return getDisplayId(); + } + + @Override + public void setId(UUID id) { + setDisplayId(id); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.java new file mode 100644 index 0000000..8b194c3 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationRepository.java @@ -0,0 +1,20 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommunityVerificationRepository extends JpaRepository { + + org.springframework.data.domain.Page findAllByImageIsNotNull(Pageable pageable); + + java.util.List findByLockId(UUID lockId); + + java.util.List findByLockIdAndCreatedAtBetweenAndImageIsNotNull(UUID lockId, java.time.LocalDateTime from, java.time.LocalDateTime to); + + java.util.List findByLockIdAndCreatedAtBetweenAndImageIsNull(UUID lockId, java.time.LocalDateTime from, java.time.LocalDateTime to); + + org.springframework.data.domain.Page findByKeyholderIdIsNullAndCreatedAtBetweenAndImageIsNotNull( + java.time.LocalDateTime from, java.time.LocalDateTime to, org.springframework.data.domain.Pageable pageable); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.java new file mode 100644 index 0000000..c582a85 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteDTO.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +public record CommunityVerificationVoteDTO (UUID voteId, UUID userId, boolean upvote) {} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.java new file mode 100644 index 0000000..453f52b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteEntity.java @@ -0,0 +1,31 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name="verification_vote") +public class CommunityVerificationVoteEntity { + + @Id + @Column + private UUID voteId; + @Column(nullable = false) + private UUID verificationId; + @Column(nullable = false) + private UUID userId; + @Column(nullable = false) + private boolean upvote; + + public CommunityVerificationVoteDTO toVerificationVote() { + return new CommunityVerificationVoteDTO(voteId, userId, upvote); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.java b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.java new file mode 100644 index 0000000..9474334 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/community/CommunityVerificationVoteRepository.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.chastity.community; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommunityVerificationVoteRepository extends JpaRepository { + + List findAllByVerificationId(UUID verificationId); + + java.util.Optional findByVerificationIdAndUserId(UUID verificationId, UUID userId); + + void deleteAllByVerificationId(UUID verificationId); +} + + diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.java new file mode 100644 index 0000000..3ef4f68 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationEntity.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "keyholder_invitation") +public class KeyholderInvitationEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + @Setter(lombok.AccessLevel.NONE) + private UUID id; + + @Column(nullable = false) + private UUID lockId; + + @Column(nullable = false) + private UUID keyholderUserId; + + @Column + private UUID lockeeUserId; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.java new file mode 100644 index 0000000..818870a --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderInvitationRepository.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; +import java.util.UUID; + +public interface KeyholderInvitationRepository extends JpaRepository { + Optional findByToken(String token); + java.util.List findByKeyholderUserId(UUID keyholderUserId); + java.util.List findByLockeeUserId(UUID lockeeUserId); + java.util.List findByLockId(UUID lockId); + @Transactional + void deleteByLockId(UUID lockId); + @Transactional + void deleteByToken(String token); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java new file mode 100644 index 0000000..ee635a8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationEntity.java @@ -0,0 +1,43 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; + +@Getter +@Setter +@Entity +@Table(name = "keyholder_notification") +public class KeyholderNotificationEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private UUID lockId; + + @Column(nullable = false) + private UUID lockeeId; + + @Column + private UUID keyholderUserId; + + @Column(nullable = false) + private LocalDateTime violationTime; + + @Column(nullable = false) + private long overtimeMinutes; + + @Column(nullable = false) + private boolean notifiedKeyholder = false; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private TempOpeningReason openingReason; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.java new file mode 100644 index 0000000..25a8d9c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderNotificationRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.UUID; + +public interface KeyholderNotificationRepository extends JpaRepository { + List findByKeyholderUserIdAndNotifiedKeyholderFalse(UUID keyholderUserId); + List findByLockId(UUID lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.java new file mode 100644 index 0000000..c308ebb --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferController.java @@ -0,0 +1,389 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +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.PatchMapping; +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 de.oaa.xxx.games.chastity.cardlock.CardEnum; +import de.oaa.xxx.games.chastity.cardlock.CardLockEntity; +import de.oaa.xxx.games.chastity.cardlock.CardLockService; +import de.oaa.xxx.games.chastity.cardlock.CardLockServiceFactory; +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateEntity; +import de.oaa.xxx.games.chastity.common.BaseLockTemplateRepository; +import de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.games.chastity.common.TemplateSubscriptionRepository; +import de.oaa.xxx.games.chastity.lockcontroll.LockControllType; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.games.chastity.timelock.TimeLockAdditionalSettings; +import de.oaa.xxx.games.chastity.timelock.TimeLockEntity; +import de.oaa.xxx.games.chastity.timelock.TimeLockRepository; +import de.oaa.xxx.games.chastity.timelock.TimeLockServiceFactory; +import de.oaa.xxx.games.chastity.timelock.TimeLockTemplateEntity; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.subscription.SubscriptionLimitService; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/keyholder-offers") +public class KeyholderOfferController { + + private final KeyholderOfferRepository offerRepository; + private final BaseLockTemplateRepository templateRepository; + private final TemplateSubscriptionRepository subscriptionRepository; + private final UserRepository userRepository; + private final CardlockRepository cardlockRepository; + private final TimeLockRepository timeLockRepository; + private final CardLockServiceFactory cardLockServiceFactory; + private final TimeLockServiceFactory timeLockServiceFactory; + private final KeyholderInvitationRepository invitationRepository; + private final SystemMessageService systemMessageService; + private final SubscriptionLimitService subscriptionLimitService; + private final UserService userService; + + public KeyholderOfferController( + KeyholderOfferRepository offerRepository, + BaseLockTemplateRepository templateRepository, + TemplateSubscriptionRepository subscriptionRepository, + UserRepository userRepository, + CardlockRepository cardlockRepository, + TimeLockRepository timeLockRepository, + CardLockServiceFactory cardLockServiceFactory, + TimeLockServiceFactory timeLockServiceFactory, + KeyholderInvitationRepository invitationRepository, + SystemMessageService systemMessageService, + SubscriptionLimitService subscriptionLimitService, + UserService userService) { + this.offerRepository = offerRepository; + this.templateRepository = templateRepository; + this.subscriptionRepository = subscriptionRepository; + this.userRepository = userRepository; + this.cardlockRepository = cardlockRepository; + this.timeLockRepository = timeLockRepository; + this.cardLockServiceFactory = cardLockServiceFactory; + this.timeLockServiceFactory = timeLockServiceFactory; + this.invitationRepository = invitationRepository; + this.systemMessageService = systemMessageService; + this.subscriptionLimitService = subscriptionLimitService; + this.userService = userService; + } + + // ── Eigene Angebote ─────────────────────────────────────────────────────── + + @GetMapping("/mine") + public ResponseEntity>> getMyOffers(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List> result = offerRepository.findByOffererId(myId).stream() + .map(this::toDto) + .toList(); + return ResponseEntity.ok(result); + } + + record CreateOfferRequest(UUID templateId, List targetGenders, boolean directStart) {} + + @PostMapping + @Transactional + public ResponseEntity> createOffer( + @RequestBody CreateOfferRequest req, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + // Limit prüfen + long existing = offerRepository.countByOffererId(myId); + if (existing >= subscriptionLimitService.maxKeyholderOffers(myId)) { + return ResponseEntity.status(403).body(Map.of("error", "offer_limit_reached")); + } + + // Template muss dem User gehören oder abonniert sein + var tOpt = templateRepository.findById(req.templateId()); + if (tOpt.isEmpty()) return ResponseEntity.badRequest().build(); + var t = tOpt.get(); + + boolean isOwn = t.getOwner().equals(myId); + boolean isSubscribed = subscriptionRepository.findByUserIdAndTemplateId(myId, req.templateId()).isPresent(); + if (!isOwn && !isSubscribed) return ResponseEntity.status(403).build(); + + String genders = req.targetGenders() != null + ? String.join(",", req.targetGenders()) + : ""; + + String templateType = t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK"; + + KeyholderOfferEntity offer = new KeyholderOfferEntity(); + offer.setOffererId(myId); + offer.setTemplateId(req.templateId()); + offer.setTemplateName(t.getName() != null ? t.getName() : "Unbenannt"); + offer.setTemplateType(templateType); + offer.setTargetGenders(genders); + offer.setDirectStart(req.directStart()); + offer.setAcceptanceCount(0); + offer.setCreatedAt(LocalDateTime.now()); + offerRepository.save(offer); + + return ResponseEntity.ok(toDto(offer)); + } + + @PatchMapping("/{id}") + @Transactional + public ResponseEntity> updateOffer( + @PathVariable UUID id, + @RequestBody CreateOfferRequest req, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var offerOpt = offerRepository.findById(id); + if (offerOpt.isEmpty()) return ResponseEntity.notFound().build(); + var offer = offerOpt.get(); + if (!offer.getOffererId().equals(myId)) return ResponseEntity.status(403).build(); + + // Template darf geändert werden – muss dem User gehören oder abonniert sein + var tOpt = templateRepository.findById(req.templateId()); + if (tOpt.isEmpty()) return ResponseEntity.badRequest().build(); + var t = tOpt.get(); + boolean isOwn = t.getOwner().equals(myId); + boolean isSubscribed = subscriptionRepository.findByUserIdAndTemplateId(myId, req.templateId()).isPresent(); + if (!isOwn && !isSubscribed) return ResponseEntity.status(403).build(); + + String templateType = t instanceof TimeLockTemplateEntity ? "TIMELOCK" : "CARDLOCK"; + String genders = req.targetGenders() != null ? String.join(",", req.targetGenders()) : ""; + + offer.setTemplateId(req.templateId()); + offer.setTemplateName(t.getName() != null ? t.getName() : "Unbenannt"); + offer.setTemplateType(templateType); + offer.setTargetGenders(genders); + offer.setDirectStart(req.directStart()); + offerRepository.save(offer); + + return ResponseEntity.ok(toDto(offer)); + } + + @DeleteMapping("/{id}") + @Transactional + public ResponseEntity deleteOffer(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var offerOpt = offerRepository.findById(id); + if (offerOpt.isEmpty()) return ResponseEntity.notFound().build(); + if (!offerOpt.get().getOffererId().equals(myId)) return ResponseEntity.status(403).build(); + + offerRepository.delete(offerOpt.get()); + return ResponseEntity.noContent().build(); + } + + // ── Angebote eines bestimmten Nutzers (für Profilseite) ────────────────── + + @GetMapping("/user/{userId}") + public ResponseEntity>> getOffersForUser( + @PathVariable UUID userId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List> result = offerRepository.findByOffererId(userId).stream() + .map(o -> toPublicDto(o, myId)) + .toList(); + return ResponseEntity.ok(result); + } + + // ── Öffentliche Angebotsübersicht ───────────────────────────────────────── + + @GetMapping("/public") + public ResponseEntity>> getPublicOffers(Principal principal) { + var me = userService.requireUser(principal); + + String userGender = me.getGeschlecht() != null ? me.getGeschlecht().name() : null; + + List> result = offerRepository.findAllByOrderByAcceptanceCountDesc().stream() + .filter(o -> matchesGender(o, userGender)) + .map(o -> toPublicDto(o, me.getUserId())) + .toList(); + + return ResponseEntity.ok(result); + } + + private boolean matchesGender(KeyholderOfferEntity o, String userGender) { + String tg = o.getTargetGenders(); + if (tg == null || tg.isBlank()) return true; // alle Geschlechter + if (userGender == null) return false; // User ohne Geschlecht: nur unrestricted + return Arrays.asList(tg.split(",")).contains(userGender); + } + + // ── Angebot annehmen (Lock erstellen) ───────────────────────────────────── + + record JoinOfferRequest(LockControllType controllType, Integer unlockCodeLength) {} + + @PostMapping("/{id}/join") + @Transactional + public ResponseEntity> joinOffer( + @PathVariable UUID id, + @RequestBody JoinOfferRequest req, + Principal principal) { + + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var offerOpt = offerRepository.findById(id); + if (offerOpt.isEmpty()) return ResponseEntity.notFound().build(); + var offer = offerOpt.get(); + + if (offer.getOffererId().equals(myId)) + return ResponseEntity.status(409).body(Map.of("error", "own_offer")); + + // Aktives Lock prüfen + if (cardLockServiceFactory.hasActiveLock(myId)) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + var tOpt = templateRepository.findById(offer.getTemplateId()); + if (tOpt.isEmpty()) return ResponseEntity.status(410).body(Map.of("error", "template_gone")); + var template = tOpt.get(); + + var offererOpt = userRepository.findById(offer.getOffererId()); + if (offererOpt.isEmpty()) return ResponseEntity.status(410).body(Map.of("error", "offerer_gone")); + var offerer = offererOpt.get(); + + LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE; + int codeLen = (req.unlockCodeLength() != null && req.unlockCodeLength() >= 1) ? req.unlockCodeLength() : 5; + boolean directStart = offer.isDirectStart(); + UUID keyholderIdIfDirect = directStart ? offer.getOffererId() : null; + + UUID lockId; + String unlockCode = null; + + if (template instanceof TimeLockTemplateEntity tl) { + TimeLockAdditionalSettings settings = new TimeLockAdditionalSettings( + controllType, myId, keyholderIdIfDirect, false, codeLen); + TimeLockEntity lock = new TimeLockEntity(); + timeLockServiceFactory.create(lock).init(tl, settings); + timeLockRepository.save(lock); + lockId = lock.getLockId(); + unlockCode = lock.getUnlockCode(); + + } else if (template instanceof CardlockTemplateEntity cl) { + List cards = buildCardList(cl); + if (cards.isEmpty()) return ResponseEntity.badRequest().body(Map.of("error", "empty_deck")); + + CardLockEntity lock = new CardLockEntity(); + lock.setName(template.getName()); + lock.setLockee(myId); + lock.setKeyholder(keyholderIdIfDirect); + lock.setInitialCards(cards); + lock.setPickEveryMinute(cl.getPickEveryMinute() != null ? cl.getPickEveryMinute() : 60); + lock.setAccumulatePicks(cl.isAccumulatePicks()); + lock.setShowRemainingCards(cl.isShowRemainingCards()); + lock.setHygineOpeningDurationMinutes(cl.getHygineOpeningDurationMinutes()); + lock.setHygineOpeningEveryMinites(cl.getHygineOpeningEveryMinites()); + lock.setTasks(cl.getTasks() != null ? cl.getTasks() : List.of()); + lock.setRequiresVerification(cl.isRequiresVerification()); + lock.setTestLock(false); + lock.setTaskMode(cl.getTaskMode() != null ? cl.getTaskMode() : TaskMode.RANDOM); + lock.setUnlockCodeLength(codeLen); + lock.setControllType(controllType); + + LocalDateTime now = LocalDateTime.now(); + lock.setStartTime(now); + lock.setAvailableCards(new ArrayList<>(cards)); + lock.setOpenPicks(0); + lock.setNextCardIn(now.plusMinutes(lock.getPickEveryMinute())); + if (cl.getHygineOpeningEveryMinites() != null) { + lock.setLastHygineOpening(now); + } + cardlockRepository.save(lock); + + CardLockService initService = cardLockServiceFactory.create(lock); + if (initService.getLockControl() != null) { + initService.getLockControl().lock(); + } else { + lock.setUnlockCode(CodeCreator.createNumeric(codeLen)); + cardlockRepository.save(lock); + } + lockId = lock.getLockId(); + unlockCode = lock.getUnlockCode(); + } else { + return ResponseEntity.status(500).build(); + } + + boolean invitationSent = false; + if (!directStart) { + // Normaler Einladungsworkflow: Keyholder muss bestätigen + KeyholderInvitationEntity inv = new KeyholderInvitationEntity(); + inv.setLockId(lockId); + inv.setKeyholderUserId(offer.getOffererId()); + inv.setLockeeUserId(myId); + inv.setToken(UUID.randomUUID().toString().replace("-", "")); + inv.setCreatedAt(LocalDateTime.now()); + invitationRepository.save(inv); + + systemMessageService.pushInvitationUpdate(offerer.getUserId()); + invitationSent = true; + } else { + // Direktstart: Keyholder wird direkt gesetzt + systemMessageService.pushInvitationUpdate(offerer.getUserId()); + } + + // Annahmezähler erhöhen + offer.setAcceptanceCount(offer.getAcceptanceCount() + 1); + offerRepository.save(offer); + + Map response = new LinkedHashMap<>(); + response.put("lockId", lockId.toString()); + response.put("invitationSent", invitationSent); + if (unlockCode != null) response.put("unlockCode", unlockCode); + return ResponseEntity.ok(response); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private List buildCardList(CardlockTemplateEntity cl) { + List cards = new ArrayList<>(); + Map counts = cl.getCardCountsMax(); + if (counts == null) return cards; + counts.forEach((type, count) -> { + if (count != null && count > 0) { + try { + CardEnum card = CardEnum.valueOf(type); + for (int i = 0; i < count; i++) cards.add(card); + } catch (IllegalArgumentException ignored) {} + } + }); + return cards; + } + + private Map toDto(KeyholderOfferEntity o) { + Map dto = new LinkedHashMap<>(); + dto.put("id", o.getId().toString()); + dto.put("templateId", o.getTemplateId().toString()); + dto.put("templateName", o.getTemplateName()); + dto.put("templateType", o.getTemplateType()); + dto.put("targetGenders", o.getTargetGenders() != null ? Arrays.asList(o.getTargetGenders().split(",")).stream().filter(s -> !s.isBlank()).toList() : List.of()); + dto.put("directStart", o.isDirectStart()); + dto.put("acceptanceCount", o.getAcceptanceCount()); + dto.put("createdAt", o.getCreatedAt().toString()); + return dto; + } + + private Map toPublicDto(KeyholderOfferEntity o, UUID myId) { + Map dto = toDto(o); + dto.put("isOwn", o.getOffererId().equals(myId)); + userRepository.findById(o.getOffererId()).ifPresent(u -> { + dto.put("offererName", u.getName()); + dto.put("offererProfilePic", u.getProfilePicture()); + }); + return dto; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.java new file mode 100644 index 0000000..ce81ec7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferEntity.java @@ -0,0 +1,48 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "keyholder_offer") +public class KeyholderOfferEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID id; + + @Column(nullable = false) + private UUID offererId; + + @Column(nullable = false) + private UUID templateId; + + /** Denormalisiert für schnelle Anzeige */ + @Column(length = 200) + private String templateName; + + /** "CARDLOCK" oder "TIMELOCK" – denormalisiert */ + @Column(length = 20) + private String templateType; + + /** Kommagetrennte Geschlechter: z.B. "WEIBLICH,DIVERS" – leer = alle */ + @Column(length = 100) + private String targetGenders; + + /** true = Lock wird sofort gestartet; false = Keyholder muss erst bestätigen */ + @Column(nullable = false) + private boolean directStart; + + @Column(nullable = false) + private int acceptanceCount = 0; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.java new file mode 100644 index 0000000..a74d775 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderOfferRepository.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface KeyholderOfferRepository extends JpaRepository { + List findByOffererId(UUID offererId); + long countByOffererId(UUID offererId); + List findAllByOrderByAcceptanceCountDesc(); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.java new file mode 100644 index 0000000..ab319de --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceController.java @@ -0,0 +1,155 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.ArrayList; +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.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.RestController; + +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/games/chastity/keyholder/choices") +public class KeyholderTaskChoiceController { + + private final CardlockRepository cardlockRepository; + private final UserRepository userRepository; + private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository; + private final AssignedTaskRepository assignedTaskRepository; + private final SystemMessageService systemMessageService; + private final UserService userService; + + public KeyholderTaskChoiceController(CardlockRepository cardlockRepository, + UserRepository userRepository, + KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + AssignedTaskRepository assignedTaskRepository, + SystemMessageService systemMessageService, + UserService userService) { + this.cardlockRepository = cardlockRepository; + this.userRepository = userRepository; + this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository; + this.assignedTaskRepository = assignedTaskRepository; + this.systemMessageService = systemMessageService; + this.userService = userService; + } + + // ── Keyholder: ausstehende Aufgaben-Karten-Entscheidungen ───────────────── + @GetMapping + @Transactional(readOnly = true) + public ResponseEntity>> getPendingChoices(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + // Alle Locks bei denen ich Keyholder bin + var locks = cardlockRepository.findByKeyholderAndUnlockTimeIsNull(myId); + List> result = new ArrayList<>(); + + for (var lock : locks) { + var pending = keyholderTaskChoiceRepository.findByLockIdAndActiveTrue(lock.getLockId()); + if (pending.isEmpty()) continue; + + var lockee = userRepository.findById(lock.getLockee()).orElse(null); + List> taskList = buildTaskList(lock.getTasks()); + + for (var choice : pending) { + Map m = new LinkedHashMap<>(); + m.put("choiceId", choice.getChoiceId().toString()); + m.put("lockId", lock.getLockId().toString()); + m.put("lockName", lock.getName() != null ? lock.getName() : ""); + m.put("lockeeName", lockee != null ? lockee.getName() : ""); + m.put("createdAt", choice.getCreatedAt().toString()); + m.put("tasks", taskList); + result.add(m); + } + } + return ResponseEntity.ok(result); + } + + record PenaltyRequest(Integer penaltyFreezeMinutes, Integer penaltyRedCards) {} + + @PostMapping("/{choiceId}/choose/{taskIndex}") + @Transactional + public ResponseEntity chooseTask(@PathVariable UUID choiceId, + @PathVariable int taskIndex, + @org.springframework.web.bind.annotation.RequestBody(required = false) PenaltyRequest penalty, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var choiceOpt = keyholderTaskChoiceRepository.findById(choiceId); + if (choiceOpt.isEmpty()) return ResponseEntity.notFound().build(); + var choice = choiceOpt.get(); + + var lockOpt = cardlockRepository.findById(choice.getLockId()); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var lock = lockOpt.get(); + + if (!myId.equals(lock.getKeyholder())) return ResponseEntity.status(403).build(); + if (!choice.isActive()) return ResponseEntity.status(409).build(); + + List tasks = lock.getTasks(); + if (tasks == null || taskIndex < 0 || taskIndex >= tasks.size()) + return ResponseEntity.badRequest().build(); + + Task task = tasks.get(taskIndex); + AssignedTaskEntity assigned = new AssignedTaskEntity(); + assigned.setLockId(lock.getLockId()); + assigned.setTaskTitle(task.getTitle()); + assigned.setTaskDescription(task.getDescription()); + assigned.setTaskText(task.getTitle()); + assigned.setTaskMinutes(task.getMinutes()); + assigned.setAssignedAt(LocalDateTime.now()); + assigned.setAcceptDeadline(LocalDateTime.now().plusHours(1)); + assigned.setStatus("PENDING"); + if (penalty != null) { + assigned.setPenaltyFreezeMinutes(penalty.penaltyFreezeMinutes()); + assigned.setPenaltyRedCards(penalty.penaltyRedCards()); + } + assignedTaskRepository.save(assigned); + + choice.setActive(false); + keyholderTaskChoiceRepository.save(choice); + + sendMessage(myId, lock.getLockee(), + "Dein Keyholder hat eine Aufgabe für dich ausgewählt.", + "/games/chastity/activelock.html?lockId=" + lock.getLockId()); + + return ResponseEntity.noContent().build(); + } + + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private List> buildTaskList(List tasks) { + if (tasks == null) return List.of(); + List> list = new ArrayList<>(); + for (int i = 0; i < tasks.size(); i++) { + Task t = tasks.get(i); + Map m = new LinkedHashMap<>(); + m.put("index", i); + m.put("title", t.getTitle()); + m.put("description", t.getDescription() != null ? t.getDescription() : ""); + m.put("minutes", t.getMinutes() != null ? t.getMinutes() : 0); + list.add(m); + } + return list; + } + + private void sendMessage(UUID fromId, UUID toId, String text, String targetUrl) { + systemMessageService.send(fromId, toId, text, targetUrl, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.java new file mode 100644 index 0000000..8af9a18 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceEntity.java @@ -0,0 +1,31 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "keyholder_task_choice") +public class KeyholderTaskChoiceEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID choiceId; + + @Column(nullable = false) + private UUID lockId; + + @Column(nullable = false) + private boolean active = true; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column + private LocalDateTime expiresAt; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.java new file mode 100644 index 0000000..4673b51 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface KeyholderTaskChoiceRepository extends JpaRepository { + List findByLockIdAndActiveTrue(UUID lockId); + List findByActiveTrueAndExpiresAtBefore(LocalDateTime time); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.java new file mode 100644 index 0000000..86bef6e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderTaskChoiceScheduler.java @@ -0,0 +1,97 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskEntity; +import de.oaa.xxx.games.chastity.tasks.AssignedTaskRepository; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.social.entity.MessageCause; + +@Component +public class KeyholderTaskChoiceScheduler { + + private static final Logger LOG = LoggerFactory.getLogger(KeyholderTaskChoiceScheduler.class); + + private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository; + private final CardlockRepository cardlockRepository; + private final AssignedTaskRepository assignedTaskRepository; + private final SystemMessageService systemMessageService; + + public KeyholderTaskChoiceScheduler(KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + CardlockRepository cardlockRepository, + AssignedTaskRepository assignedTaskRepository, + SystemMessageService systemMessageService) { + this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository; + this.cardlockRepository = cardlockRepository; + this.assignedTaskRepository = assignedTaskRepository; + this.systemMessageService = systemMessageService; + } + + @Scheduled(fixedDelay = 60_000) + @Transactional + public void processExpiredChoices() { + var expired = keyholderTaskChoiceRepository + .findByActiveTrueAndExpiresAtBefore(LocalDateTime.now()); + + for (var choice : expired) { + LOG.debug("Processing expired keyholder task choice {}", choice.getChoiceId()); + + var lockOpt = cardlockRepository.findById(choice.getLockId()); + if (lockOpt.isEmpty()) { + choice.setActive(false); + keyholderTaskChoiceRepository.save(choice); + continue; + } + var lock = lockOpt.get(); + List tasks = lock.getTasks(); + if (tasks == null || tasks.isEmpty()) { + choice.setActive(false); + keyholderTaskChoiceRepository.save(choice); + continue; + } + + int taskIndex = new Random().nextInt(tasks.size()); + Task task = tasks.get(taskIndex); + LOG.debug("Keyholder did not choose in time → random task index {}", taskIndex); + + AssignedTaskEntity assigned = new AssignedTaskEntity(); + assigned.setLockId(lock.getLockId()); + assigned.setTaskTitle(task.getTitle()); + assigned.setTaskDescription(task.getDescription()); + assigned.setTaskText(task.getTitle()); + assigned.setTaskMinutes(task.getMinutes()); + assigned.setAssignedAt(LocalDateTime.now()); + assigned.setAcceptDeadline(LocalDateTime.now().plusHours(1)); + assigned.setStatus("PENDING"); + assignedTaskRepository.save(assigned); + + choice.setActive(false); + keyholderTaskChoiceRepository.save(choice); + + sendMessage(lock.getLockee(), + "Dein Keyholder hat nicht rechtzeitig gewählt – eine zufällige Aufgabe wurde vergeben: \"" + task.getTitle() + "\"", + "/games/chastity/activelock.html?lockId=" + lock.getLockId()); + + if (lock.getKeyholder() != null) { + sendMessage(lock.getKeyholder(), + "Du hast die Aufgabenwahl für \"" + (lock.getName() != null ? lock.getName() : "ein Schloss") + "\" nicht rechtzeitig getroffen. Eine zufällige Aufgabe wurde vergeben.", + "/games/chastity/keyholder.html"); + } + } + } + + private void sendMessage(UUID toId, String text, String targetUrl) { + systemMessageService.send(toId, toId, text, targetUrl, MessageCause.GAME_STATE); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.java new file mode 100644 index 0000000..d7ea4d6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationEntity.java @@ -0,0 +1,64 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.common.Verification; +import de.oaa.xxx.games.chastity.common.VerificationCommonDTO; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "keyholder_verification") +public class KeyholderVerificationEntity implements Verification { + + @Id + @Column + private UUID verificationId; + @Column(nullable = false) + private UUID lockId; + @Column(nullable = false) + private String code; + @Column(nullable = false) + private LocalDateTime createdAt; + @Column(columnDefinition = "MEDIUMBLOB") + private byte[] image; + @Column(nullable = false) + private UUID lockeeId; + @Column(nullable = false) + private UUID keyholderId; + @Column + private boolean accepted; + + @Override + public LocalDate getVerificationDate() { + return createdAt.toLocalDate(); + } + + @Override + public boolean isValid() { + return accepted; + } + + @Override + public VerificationCommonDTO toCommonVerification() { + return new VerificationCommonDTO(verificationId, lockId, code, createdAt, image, lockeeId, keyholderId); + } + + @Override + public UUID getId() { + return verificationId; + } + + @Override + public void setId(UUID id) { + setVerificationId(id); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.java b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.java new file mode 100644 index 0000000..d1d3042 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/keyholder/KeyholderVerificationRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.keyholder; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface KeyholderVerificationRepository extends JpaRepository{ + + java.util.List findByLockId(UUID lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java new file mode 100644 index 0000000..1db9495 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControl.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +public abstract class LockControl { + + protected final LockControlCallback callback; + + public LockControl(LockControlCallback callback) { + this.callback = callback; + } + + public abstract boolean init(); + + public abstract boolean unlock(); + + public abstract boolean lock(); + + public abstract boolean cleanup(); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.java new file mode 100644 index 0000000..dc573dc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlCallback.java @@ -0,0 +1,8 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +public interface LockControlCallback { + + void setUnlockCode(String code); + + int getUnlockcodeLenght(); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java new file mode 100644 index 0000000..144ef63 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControlFactory.java @@ -0,0 +1,42 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository; +import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository; +import de.oaa.xxx.games.chastity.ttlock.TTAuthService; +import de.oaa.xxx.games.chastity.ttlock.TTLockService; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class LockControlFactory { + + private final TTAuthService ttAuthService; + private final TTLockService ttLockService; + private final TTLockConfigRepository ttLockConfigRepository; + private final TTLockUserConfigRepository ttLockUserConfigRepository; + + public LockControlFactory(TTAuthService ttAuthService, + TTLockService ttLockService, + TTLockConfigRepository ttLockConfigRepository, + TTLockUserConfigRepository ttLockUserConfigRepository) { + this.ttAuthService = ttAuthService; + this.ttLockService = ttLockService; + this.ttLockConfigRepository = ttLockConfigRepository; + this.ttLockUserConfigRepository = ttLockUserConfigRepository; + } + + public LockControl create(LockControllType type, LockControlCallback callback, UUID lockeeId) { + return switch (type != null ? type : LockControllType.UNLOCK_CODE) { + case TRUST -> new TrustLockControl(); + case TTLOCK -> new TTLockControl( + ttAuthService, + ttLockService, + ttLockConfigRepository.findById(1L).orElse(null), + ttLockUserConfigRepository.findById(lockeeId).orElse(null), + ttLockUserConfigRepository, + callback); + case UNLOCK_CODE -> new UnlockcodeLockControl(callback); + }; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.java new file mode 100644 index 0000000..fa09d8f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/LockControllType.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +public enum LockControllType { + + TTLOCK, UNLOCK_CODE, TRUST; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.java new file mode 100644 index 0000000..9967127 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/NoInteractionCallback.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +class NoInteractionCallback implements LockControlCallback { + + @Override + public void setUnlockCode(String code) { + } + + @Override + public int getUnlockcodeLenght() { + return 0; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java new file mode 100644 index 0000000..740a378 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TTLockControl.java @@ -0,0 +1,142 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +import de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.games.chastity.ttlock.TTAuthService; +import de.oaa.xxx.games.chastity.ttlock.TTLockConfigEntity; +import de.oaa.xxx.games.chastity.ttlock.TTLockService; +import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigEntity; +import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TTLockControl extends LockControl { + + private static final Logger LOGGER = LoggerFactory.getLogger(TTLockControl.class); + + private final TTAuthService ttAuthService; + private final TTLockService ttLockService; + private final TTLockConfigEntity adminConfig; + private final TTLockUserConfigEntity userConfig; + private final TTLockUserConfigRepository userConfigRepository; + + public TTLockControl(TTAuthService ttAuthService, + TTLockService ttLockService, + TTLockConfigEntity adminConfig, + TTLockUserConfigEntity userConfig, + TTLockUserConfigRepository userConfigRepository, + LockControlCallback callback) { + super(callback); + this.ttAuthService = ttAuthService; + this.ttLockService = ttLockService; + this.adminConfig = adminConfig; + this.userConfig = userConfig; + this.userConfigRepository = userConfigRepository; + } + + @Override + public boolean init() { + return true; + } + + @Override + public boolean lock() { + if (!isConfigValid()) { + LOGGER.warn("TTLock-Konfiguration unvollständig – lock() übersprungen"); + return false; + } + try { + String token = getToken(); + if (token == null) { + LOGGER.error("TTLock: Kein Access Token erhalten"); + return false; + } + + // Neuen PIN erstellen – Länge aus Callback, mindestens 4, maximal 9 (TTLock-Limit) + int pinLength = Math.min(9, Math.max(4, callback.getUnlockcodeLenght())); + String newPin = CodeCreator.createNumeric(pinLength); + Integer newPwdId = ttLockService.addCustomPasscode( + adminConfig.getClientId(), token, + userConfig.getLockId(), newPin); + + if (newPwdId == null) { + LOGGER.error("TTLock: Neuer PIN konnte nicht erstellt werden – alter PIN bleibt erhalten"); + return false; + } + callback.setUnlockCode(newPin); + + // Neuen PIN-ID speichern, dann alten PIN löschen + Integer oldPwdId = userConfig.getCurrentKeyboardPwdId(); + userConfig.setCurrentKeyboardPwdId(newPwdId); + userConfigRepository.save(userConfig); + LOGGER.info("TTLock: Neuer PIN gesetzt (pwdId={})", newPwdId); + + + if (oldPwdId != null) { + ttLockService.deleteCustomPasscode( + adminConfig.getClientId(), token, + userConfig.getLockId(), oldPwdId); + LOGGER.debug("TTLock: Alter PIN {} gelöscht", oldPwdId); + } + + return true; + } catch (Exception e) { + LOGGER.error("TTLock lock() fehlgeschlagen: {}", e.getMessage(), e); + return false; + } + } + + /** + * Löscht den aktuellen PIN vom Schloss, sodass es entsperrt bleibt. + */ + @Override + public boolean unlock() { + if (!isConfigValid() || userConfig.getCurrentKeyboardPwdId() == null) { + return true; // Kein PIN gesetzt – nichts zu tun + } + try { + String token = getToken(); + if (token == null) return false; + + ttLockService.deleteCustomPasscode( + adminConfig.getClientId(), token, + userConfig.getLockId(), userConfig.getCurrentKeyboardPwdId()); + + userConfig.setCurrentKeyboardPwdId(null); + userConfigRepository.save(userConfig); + LOGGER.info("TTLock: PIN gelöscht (Entsperrung)"); + return true; + } catch (Exception e) { + LOGGER.error("TTLock unlock() fehlgeschlagen: {}", e.getMessage(), e); + return false; + } + } + + private String getToken() { + return ttAuthService.getAccessToken( + adminConfig.getClientId(), + adminConfig.getClientSecret(), + userConfig.getUsername(), + userConfig.getPasswordMd5()); + } + + private boolean isConfigValid() { + return adminConfig != null + && adminConfig.getClientId() != null + && adminConfig.getClientSecret() != null + && userConfig != null + && userConfig.getUsername() != null + && userConfig.getPasswordMd5() != null + && userConfig.getLockId() != null; + } + + @Override + public boolean cleanup() { + String token = getToken(); + if (token == null) { + LOGGER.error("TTLock: Kein Access Token erhalten"); + return false; + } + ttLockService.findAndDeleteLocksByName(adminConfig.getClientId(), token, userConfig.getLockId()); + return true; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java new file mode 100644 index 0000000..944e908 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/TrustLockControl.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +public class TrustLockControl extends LockControl { + + public TrustLockControl() { + super(new NoInteractionCallback()); + } + + @Override + public boolean init() { + return true; + } + + @Override + public boolean unlock() { + return true; + } + + @Override + public boolean lock() { + return true; + } + + @Override + public boolean cleanup() { + return true; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java new file mode 100644 index 0000000..82ff99f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockcontroll/UnlockcodeLockControl.java @@ -0,0 +1,32 @@ +package de.oaa.xxx.games.chastity.lockcontroll; + +import de.oaa.xxx.games.chastity.common.CodeCreator; + +public class UnlockcodeLockControl extends LockControl { + + public UnlockcodeLockControl(LockControlCallback callback) { + super(callback); + } + + @Override + public boolean init() { + return true; + } + + @Override + public boolean unlock() { + return true; + } + + @Override + public boolean lock() { + var code = CodeCreator.createNumeric(callback.getUnlockcodeLenght()); + callback.setUnlockCode(code); + return true; + } + + @Override + public boolean cleanup() { + return true; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.java b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.java new file mode 100644 index 0000000..a17b212 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationController.java @@ -0,0 +1,301 @@ +package de.oaa.xxx.games.chastity.lockee; + +import java.security.Principal; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +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 de.oaa.xxx.games.chastity.cardlock.CardLockEntity; +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.common.BaseLockRepository; +import de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.games.chastity.timelock.TimeLockEntity; +import de.oaa.xxx.games.chastity.timelock.TimeLockRepository; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/lockee") +public class LockeeInvitationController { + + private final LockeeInvitationRepository lockeeInvitationRepository; + private final CardlockRepository cardlockRepository; + private final BaseLockRepository baseLockRepository; + private final TimeLockRepository timeLockRepository; + private final UserRepository userRepository; + private final SystemMessageService systemMessageService; + private final UserService userService; + + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + + private static final SecureRandom RNG = new SecureRandom(); + + public LockeeInvitationController(LockeeInvitationRepository lockeeInvitationRepository, + CardlockRepository cardlockRepository, + BaseLockRepository baseLockRepository, + TimeLockRepository timeLockRepository, + UserRepository userRepository, + SystemMessageService systemMessageService, + UserService userService) { + this.lockeeInvitationRepository = lockeeInvitationRepository; + this.cardlockRepository = cardlockRepository; + this.baseLockRepository = baseLockRepository; + this.timeLockRepository = timeLockRepository; + this.userRepository = userRepository; + this.systemMessageService = systemMessageService; + this.userService = userService; + } + + private String generateUnlockCode(int lines) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines; i++) sb.append(RNG.nextInt(10)); + return sb.toString(); + } + + private int randomBetween(Integer min, Integer max) { + if (max == null) max = 60; + if (min == null || min >= max) return max; + return min + new Random().nextInt(max - min); + } + + @GetMapping("/invitations/mine/count") + public ResponseEntity countMyInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + int count = 0; + for (var inv : lockeeInvitationRepository.findByLockeeUserId(myId)) { + var lockOpt = baseLockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) continue; + if (lockOpt.get().getStartTime() != null) continue; + count++; + } + return ResponseEntity.ok(count); + } + + @GetMapping("/invitations/mine") + public ResponseEntity>> getMyInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var invitations = lockeeInvitationRepository.findByLockeeUserId(myId); + List> result = new ArrayList<>(); + for (var inv : invitations) { + var lockOpt = baseLockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) continue; + var lock = lockOpt.get(); + if (lock.getStartTime() != null) continue; // already accepted + var khOpt = userRepository.findById(inv.getKeyholderUserId()); + if (khOpt.isEmpty()) continue; + var kh = khOpt.get(); + Map item = new HashMap<>(); + item.put("lockId", inv.getLockId().toString()); + item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + item.put("keyholderName", kh.getName()); + item.put("keyholderProfilePic", kh.getProfilePicture()); + item.put("token", inv.getToken()); + item.put("createdAt", inv.getCreatedAt().toString()); + item.put("detailsVisible", inv.isDetailsVisible()); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @GetMapping("/invitations/sent") + public ResponseEntity>> getSentInvitations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var invitations = lockeeInvitationRepository.findByKeyholderUserId(myId); + List> result = new ArrayList<>(); + for (var inv : invitations) { + var lockOpt = baseLockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) continue; + var lock = lockOpt.get(); + if (lock.getStartTime() != null) continue; // already accepted + var lockeeOpt = userRepository.findById(inv.getLockeeUserId()); + if (lockeeOpt.isEmpty()) continue; + var lockee = lockeeOpt.get(); + Map item = new HashMap<>(); + item.put("lockId", inv.getLockId().toString()); + item.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + item.put("lockeeName", lockee.getName()); + item.put("lockeeProfilePic", lockee.getProfilePicture()); + item.put("token", inv.getToken()); + item.put("createdAt", inv.getCreatedAt().toString()); + item.put("detailsVisible", inv.isDetailsVisible()); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @DeleteMapping("/invitations/sent/{token}") + @Transactional + public ResponseEntity cancelSentInvitation(@PathVariable String token, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var invOpt = lockeeInvitationRepository.findByToken(token); + if (invOpt.isEmpty()) return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + if (!inv.getKeyholderUserId().equals(myId)) return ResponseEntity.status(403).build(); + + var lockOpt = baseLockRepository.findById(inv.getLockId()); + lockeeInvitationRepository.delete(inv); + + if (lockOpt.isPresent()) { + var lock = lockOpt.get(); + baseLockRepository.delete(lock); + systemMessageService.pushInvitationUpdate(inv.getLockeeUserId()); + } + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/invitation/{token}") + public ResponseEntity> getInvitation(@PathVariable String token, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var invOpt = lockeeInvitationRepository.findByToken(token); + if (invOpt.isEmpty()) return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build(); + + var lockOpt = baseLockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var lock = lockOpt.get(); + + if (lock.getStartTime() != null) return ResponseEntity.status(409).body(Map.of("error", "already_accepted")); + + var khOpt = userRepository.findById(inv.getKeyholderUserId()); + String khName = khOpt.map(u -> u.getName()).orElse("Unbekannt"); + + Map result = new LinkedHashMap<>(); + result.put("lockId", inv.getLockId().toString()); + result.put("lockName", lock.getName() != null ? lock.getName() : "Unbenanntes Lock"); + result.put("keyholderName", khName); + result.put("token", inv.getToken()); + result.put("createdAt", inv.getCreatedAt().toString()); + result.put("detailsVisible", inv.isDetailsVisible()); + + if (inv.isDetailsVisible() && lock instanceof CardLockEntity cardLock && cardLock.getInitialCards() != null) { + Map cardCounts = cardLock.getInitialCards().stream() + .collect(java.util.stream.Collectors.groupingBy( + c -> c.name(), java.util.stream.Collectors.counting())); + result.put("cardCounts", cardCounts); + result.put("pickEveryMinute", cardLock.getPickEveryMinute()); + result.put("accumulatePicks", cardLock.isAccumulatePicks()); + result.put("showRemainingCards", cardLock.isShowRemainingCards()); + result.put("hygineOpeningEveryMinites", cardLock.getHygineOpeningEveryMinites()); + result.put("hygineOpeningDurationMinutes", cardLock.getHygineOpeningDurationMinutes()); + result.put("requiresVerification", cardLock.isRequiresVerification()); + result.put("taskCount", cardLock.getTasks() != null ? cardLock.getTasks().size() : 0); + } + + return ResponseEntity.ok(result); + } + + record AcceptRequest(Integer unlockCodeLines) {} + + @PostMapping("/invitation/{token}/accept") + @Transactional + public ResponseEntity> acceptInvitation(@PathVariable String token, + @RequestBody AcceptRequest req, + Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var invOpt = lockeeInvitationRepository.findByToken(token); + if (invOpt.isEmpty()) return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build(); + + var lockOpt = baseLockRepository.findById(inv.getLockId()); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var lock = lockOpt.get(); + if (lock.getStartTime() != null) return ResponseEntity.status(409).body(Map.of("error", "already_accepted")); + + if (cardlockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId) + || timeLockRepository.existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(myId)) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + int codeLines = (req.unlockCodeLines() != null && req.unlockCodeLines() >= 1) ? req.unlockCodeLines() : 5; + LocalDateTime now = LocalDateTime.now(); + + String unlockCode; + + if (lock instanceof CardLockEntity cardLock) { + unlockCode = generateUnlockCode(codeLines); + cardLock.setStartTime(now); + cardLock.setUnlockCode(unlockCode); + cardLock.setUnlockCodeLength(codeLines); + cardLock.setAvailableCards(new ArrayList<>(cardLock.getInitialCards())); + cardLock.setOpenPicks(0); + cardLock.setNextCardIn(now.plusMinutes(cardLock.getPickEveryMinute())); + if (cardLock.getHygineOpeningEveryMinites() != null) { + cardLock.setLastHygineOpening(now); + } + cardlockRepository.save(cardLock); + } else if (lock instanceof TimeLockEntity timeLock) { + unlockCode = CodeCreator.createNumeric(codeLines); + int unlockMinutes = randomBetween(timeLock.getMinTimeInMinutes(), timeLock.getMaxTimeInMinutes()); + timeLock.setStartTime(now); + timeLock.setEstimatedUnlockTime(now.plusMinutes(unlockMinutes)); + timeLock.setUnlockCode(unlockCode); + timeLock.setUnlockCodeLength(codeLines); + if (timeLock.getHygineOpeningEveryMinites() != null) { + timeLock.setLastHygineOpening(now); + } + timeLockRepository.save(timeLock); + } else { + return ResponseEntity.status(500).build(); + } + + lockeeInvitationRepository.delete(inv); + + systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId()); + + return ResponseEntity.ok(Map.of( + "lockId", lock.getLockId().toString(), + "unlockCode", unlockCode + )); + } + + @DeleteMapping("/invitation/{token}") + @Transactional + public ResponseEntity declineInvitation(@PathVariable String token, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var invOpt = lockeeInvitationRepository.findByToken(token); + if (invOpt.isEmpty()) return ResponseEntity.notFound().build(); + var inv = invOpt.get(); + if (!inv.getLockeeUserId().equals(myId)) return ResponseEntity.status(403).build(); + + var lockOpt = baseLockRepository.findById(inv.getLockId()); + lockeeInvitationRepository.delete(inv); + + if (lockOpt.isPresent()) { + var lock = lockOpt.get(); + baseLockRepository.delete(lock); + systemMessageService.pushInvitationUpdate(inv.getKeyholderUserId()); + } + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.java b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.java new file mode 100644 index 0000000..b51f693 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationEntity.java @@ -0,0 +1,39 @@ +package de.oaa.xxx.games.chastity.lockee; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "lockee_invitation") +public class LockeeInvitationEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + @Setter(lombok.AccessLevel.NONE) + private UUID invitationId; + + @Column(nullable = false) + private UUID lockId; + + @Column(nullable = false) + private UUID lockeeUserId; + + @Column(nullable = false) + private UUID keyholderUserId; + + @Column(nullable = false, unique = true) + private String token; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private boolean detailsVisible = true; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.java b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.java new file mode 100644 index 0000000..8e57c74 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/lockee/LockeeInvitationRepository.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.games.chastity.lockee; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface LockeeInvitationRepository extends JpaRepository { + List findByLockeeUserId(UUID lockeeUserId); + List findByKeyholderUserId(UUID keyholderUserId); + Optional findByToken(String token); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/EntryType.java b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/EntryType.java new file mode 100644 index 0000000..bdf48d7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/EntryType.java @@ -0,0 +1,51 @@ +package de.oaa.xxx.games.chastity.spinningwheel; + +import de.oaa.xxx.games.chastity.timelock.TimeLockService; + +public enum EntryType { + + ADD_TIME { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.addTime(intVal); + } + }, + REMOVE_TIME { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.removeTime(intVal); + } + }, + FREEZE_TIME { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.freeze(intVal); + } + }, + FREEZE { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.freeze(); + } + }, + UNFREEZE { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.unfreeze(); + } + }, + TASK { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.task(); + } + }, + TEXT { + @Override + public void apply(TimeLockService service, Integer intVal, String stringVal) { + service.text(intVal, stringVal); + } + }; + + public abstract void apply(TimeLockService service, Integer intVal, String stringVal); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.java b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.java new file mode 100644 index 0000000..3eba677 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelConverter.java @@ -0,0 +1,39 @@ +package de.oaa.xxx.games.chastity.spinningwheel; + +import java.util.ArrayList; +import java.util.List; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class SpinningWheelConverter implements AttributeConverter, String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List list) { + if (list == null || list.isEmpty()) + return null; + try { + return mapper.writeValueAsString(list); + } catch (Exception e) { + return null; + } + } + + @Override + public List convertToEntityAttribute(String json) { + if (json == null || json.isBlank()) + return new ArrayList<>(); + try { + return new ArrayList<>(mapper.readValue(json, new TypeReference>() { + })); + } catch (Exception e) { + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.java b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.java new file mode 100644 index 0000000..a61bd69 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntity.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.games.chastity.spinningwheel; + +import java.util.List; +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SpinningWheelEntity { + + private UUID wheelId; + private String name; + private List entries; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.java b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.java new file mode 100644 index 0000000..6a67206 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntry.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.chastity.spinningwheel; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SpinningWheelEntry { + + private EntryType type; + private Integer intVal; + private String stringVal; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.java b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.java new file mode 100644 index 0000000..7d4d4f7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/spinningwheel/SpinningWheelEntryEntity.java @@ -0,0 +1,20 @@ +package de.oaa.xxx.games.chastity.spinningwheel; + +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SpinningWheelEntryEntity { + + private UUID entryId; + private EntryType type; + private Integer intVal; + private String stringVal; + + public SpinningWheelEntry toSpinningWheelEntry() { + return new SpinningWheelEntry(type, intVal, stringVal); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.java b/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.java new file mode 100644 index 0000000..a59cb3e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskEntity.java @@ -0,0 +1,55 @@ +package de.oaa.xxx.games.chastity.tasks; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "assigned_task") +public class AssignedTaskEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID taskId; + + @Column(nullable = false) + private UUID lockId; + + @Column + private String taskTitle; + + @Column(columnDefinition = "TEXT") + private String taskDescription; + + @Column(columnDefinition = "TEXT", nullable = false) + private String taskText; + + @Column + private Integer taskMinutes; + + @Column(nullable = false) + private LocalDateTime assignedAt; + + @Column(nullable = false) + private LocalDateTime acceptDeadline; + + /** Wie viele Minuten einfrieren bei Ablehnung / Ablauf (null = keine Freeze-Strafe). */ + @Column + private Integer penaltyFreezeMinutes; + + /** Wie viele rote Karten hinzufügen bei Ablehnung / Ablauf (null = keine). */ + @Column + private Integer penaltyRedCards; + + @Column + private Integer penaltyAddTime; + + /** PENDING | ACCEPTED | DECLINED | EXPIRED */ + @Column(nullable = false) + private String status = "PENDING"; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.java b/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.java new file mode 100644 index 0000000..2f29978 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/tasks/AssignedTaskRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.chastity.tasks; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AssignedTaskRepository extends JpaRepository { + List findByLockIdAndStatus(UUID lockId, String status); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/tasks/Task.java b/src/main/java/de/oaa/xxx/games/chastity/tasks/Task.java new file mode 100644 index 0000000..fcd0d3f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/tasks/Task.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.games.chastity.tasks; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Task { + + private String title; + private String description; + private Integer minutes; + + @Override + public String toString() { + return "Task[title=" + title + ", minutes=" + minutes + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskListConverter.java b/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskListConverter.java new file mode 100644 index 0000000..f887435 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskListConverter.java @@ -0,0 +1,35 @@ +package de.oaa.xxx.games.chastity.tasks; + +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 TaskListConverter implements AttributeConverter, String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List list) { + if (list == null || list.isEmpty()) return null; + try { + return mapper.writeValueAsString(list); + } catch (Exception e) { + return null; + } + } + + @Override + public List convertToEntityAttribute(String json) { + if (json == null || json.isBlank()) return new ArrayList<>(); + try { + return new ArrayList<>(mapper.readValue(json, new TypeReference>() {})); + } catch (Exception e) { + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskMode.java b/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskMode.java new file mode 100644 index 0000000..5dd71f6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/tasks/TaskMode.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.tasks; + +public enum TaskMode { + RANDOM, KEYHOLDER, COMMUNITY; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.java new file mode 100644 index 0000000..447fc3d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockAdditionalSettings.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.util.UUID; + +import de.oaa.xxx.games.chastity.lockcontroll.LockControllType; + +public record TimeLockAdditionalSettings(LockControllType controllType, UUID lockee, UUID keyholder, boolean testlock, Integer unlockCodeLength) { + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java new file mode 100644 index 0000000..f45df26 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockController.java @@ -0,0 +1,867 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.Principal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.imageio.ImageIO; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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 org.springframework.web.multipart.MultipartFile; + +import de.oaa.xxx.games.chastity.community.CommunityVerificationRepository; +import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteEntity; +import de.oaa.xxx.games.chastity.community.CommunityVerificationVoteRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationEntity; +import de.oaa.xxx.games.chastity.keyholder.KeyholderInvitationRepository; +import de.oaa.xxx.games.chastity.lockcontroll.LockControllType; +import de.oaa.xxx.subscription.SubscriptionLimitService; +import de.oaa.xxx.games.chastity.lockee.LockeeInvitationEntity; +import de.oaa.xxx.games.chastity.lockee.LockeeInvitationRepository; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; +import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/keyholder") +public class TimeLockController { + + private final TimeLockRepository timeLockRepository; + private final TimeLockTemplateRepository templateRepository; + private final UserRepository userRepository; + private final UserService userService; + private final KeyholderInvitationRepository invitationRepository; + private final LockeeInvitationRepository lockeeInvitationRepository; + private final SystemMessageService systemMessageService; + private final TimeLockServiceFactory timeLockServiceFactory; + private final CommunityVerificationRepository verificationRepository; + private final CommunityVerificationVoteRepository verificationVoteRepository; + private final SubscriptionLimitService subscriptionLimitService; + private final UnlockCodeHistoryService unlockCodeHistoryService; + + public TimeLockController(TimeLockRepository timeLockRepository, + TimeLockTemplateRepository templateRepository, + UserRepository userRepository, + UserService userService, + KeyholderInvitationRepository invitationRepository, + LockeeInvitationRepository lockeeInvitationRepository, + SystemMessageService systemMessageService, + TimeLockServiceFactory timeLockServiceFactory, + CommunityVerificationRepository verificationRepository, + CommunityVerificationVoteRepository verificationVoteRepository, + SubscriptionLimitService subscriptionLimitService, + UnlockCodeHistoryService unlockCodeHistoryService) { + this.timeLockRepository = timeLockRepository; + this.templateRepository = templateRepository; + this.userRepository = userRepository; + this.userService = userService; + this.invitationRepository = invitationRepository; + this.lockeeInvitationRepository = lockeeInvitationRepository; + this.systemMessageService = systemMessageService; + this.timeLockServiceFactory = timeLockServiceFactory; + this.verificationRepository = verificationRepository; + this.verificationVoteRepository = verificationVoteRepository; + this.subscriptionLimitService = subscriptionLimitService; + this.unlockCodeHistoryService = unlockCodeHistoryService; + } + + // ── Erstellen ──────────────────────────────────────────────────────────────── + + record CreateTimeLockRequest( + UUID templateId, + UUID lockeeUserId, + boolean lockeeDetailsVisible, + UUID keyholder, + boolean testLock, + Integer unlockCodeLength, + LockControllType controllType + ) {} + + @PostMapping("/timelock") + @Transactional + public ResponseEntity> createTimeLock( + @RequestBody CreateTimeLockRequest req, Principal principal) { + + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var templateOpt = templateRepository.findById(req.templateId()); + if (templateOpt.isEmpty()) return ResponseEntity.badRequest().build(); + var template = templateOpt.get(); + + int codeLen = (req.unlockCodeLength() != null && req.unlockCodeLength() >= 1) ? req.unlockCodeLength() : 5; + boolean friendLockee = req.lockeeUserId() != null && !req.lockeeUserId().equals(myId); + + if (friendLockee) { + var lockeeOpt = userRepository.findById(req.lockeeUserId()); + if (lockeeOpt.isEmpty()) return ResponseEntity.badRequest().build(); + var lockee = lockeeOpt.get(); + + if (timeLockServiceFactory.hasActiveLock(req.lockeeUserId())) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + TimeLockEntity lock = buildBaseEntity(template, myId, req.lockeeUserId(), false); + lock.setStartTime(null); + timeLockRepository.save(lock); + + String token = UUID.randomUUID().toString().replace("-", ""); + LockeeInvitationEntity inv = new LockeeInvitationEntity(); + inv.setLockId(lock.getLockId()); + inv.setLockeeUserId(lockee.getUserId()); + inv.setKeyholderUserId(myId); + inv.setToken(token); + inv.setCreatedAt(LocalDateTime.now()); + inv.setDetailsVisible(req.lockeeDetailsVisible()); + lockeeInvitationRepository.save(inv); + + systemMessageService.pushInvitationUpdate(lockee.getUserId()); + + return ResponseEntity.ok(Map.of( + "lockId", lock.getLockId().toString(), + "lockeeInvitationSent", true)); + } + + if (timeLockServiceFactory.hasActiveLock(myId)) + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + + LockControllType controllType = req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE; + if (controllType == LockControllType.TTLOCK && !subscriptionLimitService.hasActivePaidSubscription(myId)) { + return ResponseEntity.status(403).body(Map.of("error", "subscription_required")); + } + + TimeLockAdditionalSettings settings = new TimeLockAdditionalSettings( + req.controllType() != null ? req.controllType() : LockControllType.UNLOCK_CODE, + myId, req.keyholder(), req.testLock(), codeLen); + TimeLockEntity lock = new TimeLockEntity(); + timeLockServiceFactory.create(lock).init(template, settings); + timeLockRepository.save(lock); // Sicherstellen dass auch TRUST-Locks persistiert sind + + boolean keyholderPending = false; + if (req.keyholder() != null) { + var khOpt = userRepository.findById(req.keyholder()); + if (khOpt.isPresent()) { + var kh = khOpt.get(); + KeyholderInvitationEntity inv = new KeyholderInvitationEntity(); + inv.setLockId(lock.getLockId()); + inv.setKeyholderUserId(kh.getUserId()); + inv.setLockeeUserId(myId); + inv.setToken(UUID.randomUUID().toString().replace("-", "")); + inv.setCreatedAt(LocalDateTime.now()); + invitationRepository.save(inv); + + systemMessageService.pushInvitationUpdate(kh.getUserId()); + + keyholderPending = true; + } + } + + return ResponseEntity.ok(Map.of( + "lockId", lock.getLockId().toString(), + "unlockCode", lock.getUnlockCode() != null ? lock.getUnlockCode() : "", + "keyholderPending", keyholderPending)); + } + + // ── State abrufen ──────────────────────────────────────────────────────────── + + @GetMapping("/timelock/{lockId}") + @Transactional + public ResponseEntity> getTimeLock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + + LocalDateTime now = LocalDateTime.now(); + + // Periodic check (daily violations) + TimeLockService service = timeLockServiceFactory.create(l); + service.check(); + + // Auto-unfreeze when frozenUntil has passed + if (l.getFrozenUntil() != null && l.getFrozenUntil().isBefore(now) && l.getFrozenFrom() != null) { + service.unfreeze(); + l.setFrozenFrom(null); + l.setFrozenUntil(null); + timeLockRepository.save(l); + } + + // Emergency auto-unlock after 1h + if (l.getEmergencyUnlockRequestedAt() != null && !l.isKeyholderRequestedUnlock() + && l.getEmergencyUnlockRequestedAt().isBefore(now.minusHours(1))) { + l.setEmergencyAutoUnlocked(true); + l.setKeyholderRequestedUnlock(true); + timeLockRepository.save(l); + } + + boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now); + boolean isFrozen = l.getFrozenFrom() != null + && (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now)); + + // Hygiene state + boolean hygieneEnabled = l.getHygineOpeningEveryMinites() != null; + boolean hygieneOpeningDue = false; + long hygieneSecondsRemaining = 0; + boolean hygieneOpeningActive = l.getTempOpeningTime() != null + && TempOpeningReason.HYGIENE == l.getTempOpeningReason(); + if (hygieneEnabled && !hygieneOpeningActive) { + LocalDateTime lastH = l.getLastHygineOpening(); + if (lastH == null) { + hygieneOpeningDue = true; + } else { + LocalDateTime nextH = lastH.plusMinutes(l.getHygineOpeningEveryMinites()); + long secs = ChronoUnit.SECONDS.between(now, nextH); + if (secs <= 0) hygieneOpeningDue = true; + else hygieneSecondsRemaining = secs; + } + } + + // Spin wheel state + boolean spinEnabled = l.getSpinsEveryMinutes() != null + && l.getSpinningWheelEntries() != null && !l.getSpinningWheelEntries().isEmpty(); + boolean spinDue = false; + String nextSpinIn = null; + if (spinEnabled) { + List times = l.getSpinningWheelTimes(); + if (times == null || times.isEmpty()) { + spinDue = true; + } else { + LocalDateTime next = times.get(times.size() - 1).plusMinutes(l.getSpinsEveryMinutes()); + if (next.isBefore(now)) spinDue = true; + else nextSpinIn = next.toString(); + } + } + + // Task timing state + boolean taskTimingEnabled = l.getTaskEveryMinutes() != null; + String nextTaskIn = null; + if (taskTimingEnabled && l.getCurrentTask() == null) { + List times = l.getTaskTimes(); + LocalDateTime next; + if (times == null || times.isEmpty()) { + next = l.getStartTime() != null + ? l.getStartTime().plusMinutes(l.getTaskEveryMinutes()) : null; + } else { + next = times.get(times.size() - 1).plusMinutes(l.getTaskEveryMinutes()); + } + if (next != null && next.isAfter(now)) nextTaskIn = next.toString(); + } + + // Keyholder info + boolean keyholderInvitationPending = + l.getKeyholder() == null && !invitationRepository.findByLockId(lockId).isEmpty(); + + // Verification state + boolean verificationDue = false; + String verificationPendingId = null; + String verificationPendingCode = null; + long verificationUpvotes = 0; + long verificationDownvotes = 0; + if (l.isRequiresVerification()) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + var completed = verificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId, todayStart, todayEnd); + if (!completed.isEmpty()) { + var todayV = completed.get(0); + var votes = verificationVoteRepository.findAllByVerificationId(todayV.getDisplayId()); + verificationUpvotes = votes.stream().filter(CommunityVerificationVoteEntity::isUpvote).count(); + verificationDownvotes = votes.stream().filter(v -> !v.isUpvote()).count(); + } else { + verificationDue = true; + var pending = verificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNull(lockId, todayStart, todayEnd); + if (!pending.isEmpty()) { + verificationPendingId = pending.get(0).getDisplayId().toString(); + verificationPendingCode = pending.get(0).getCode(); + } + } + } + + Map result = new LinkedHashMap<>(); + result.put("lockId", l.getLockId().toString()); + result.put("name", l.getName() != null ? l.getName() : ""); + result.put("testLock", l.isTestLock()); + result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null); + result.put("endTimeVisible", l.isEndTimeVisible()); + result.put("timeUp", timeUp); + result.put("isFrozen", isFrozen); + result.put("frozenUntil", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null); + + // Only expose unlock time if end time is visible OR time is up + if (l.isEndTimeVisible() || timeUp) { + result.put("unlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null); + } else { + result.put("unlockTime", null); + } + + result.put("currentTask", l.getCurrentTask()); + result.put("currentTaskDescription", l.getCurrentTaskDescription()); + result.put("taskUntil", l.getTaskUntil() != null ? l.getTaskUntil().toString() : null); + + result.put("spinEnabled", spinEnabled); + result.put("spinDue", spinDue); + result.put("nextSpinIn", nextSpinIn); + result.put("spinningWheelEntries", l.getSpinningWheelEntries() != null ? l.getSpinningWheelEntries() : List.of()); + + result.put("taskTimingEnabled", taskTimingEnabled); + result.put("nextTaskIn", nextTaskIn); + + result.put("hygieneEnabled", hygieneEnabled); + result.put("hygieneOpeningDue", hygieneOpeningDue); + result.put("hygieneSecondsRemaining", hygieneSecondsRemaining); + result.put("hygieneOpeningActive", hygieneOpeningActive); + result.put("hygieneOpeningStarted", l.getTempOpeningTime() != null ? l.getTempOpeningTime().toString() : null); + result.put("hygieneDurationMinutes", l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 0); + + result.put("verificationRequired", l.isRequiresVerification()); + result.put("verificationDue", verificationDue); + result.put("verificationPendingId", verificationPendingId); + result.put("verificationPendingCode", verificationPendingCode); + result.put("verificationUpvotes", verificationUpvotes); + result.put("verificationDownvotes", verificationDownvotes); + + result.put("hasKeyholder", l.getKeyholder() != null); + result.put("keyholderInvitationPending", keyholderInvitationPending); + if (l.getKeyholder() != null) { + userRepository.findById(l.getKeyholder()).ifPresent(kh -> { + result.put("keyholderName", kh.getName()); + result.put("keyholderUserId", kh.getUserId().toString()); + result.put("keyholderProfilePic", kh.getProfilePicture()); + }); + } + + result.put("controllType", l.getControllType() != null ? l.getControllType().name() : "UNLOCK_CODE"); + result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock()); + if (l.isKeyholderRequestedUnlock() || l.isTestLock() || timeUp) { + result.put("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : ""); + } + result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null); + result.put("emergencyAutoUnlocked", l.isEmergencyAutoUnlocked()); + result.put("actualUnlockTime", l.getUnlockTime() != null ? l.getUnlockTime().toString() : null); + + return ResponseEntity.ok(result); + } + + // ── Glücksrad drehen ───────────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/spin") + @Transactional + public ResponseEntity> spin(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getEstimatedUnlockTime() == null) return ResponseEntity.status(409).build(); // not started + if (l.getSpinningWheelEntries() == null || l.getSpinningWheelEntries().isEmpty()) + return ResponseEntity.status(409).build(); + + LocalDateTime now = LocalDateTime.now(); + boolean isFrozen = l.getFrozenFrom() != null + && (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now)); + if (isFrozen) return ResponseEntity.status(409).body(Map.of("error", "frozen")); + if (TempOpeningReason.HYGIENE == l.getTempOpeningReason()) + return ResponseEntity.status(409).body(Map.of("error", "hygiene_opening")); + + // Check spin is due + List spinTimes = l.getSpinningWheelTimes(); + if (spinTimes != null && !spinTimes.isEmpty() && l.getSpinsEveryMinutes() != null) { + LocalDateTime next = spinTimes.get(spinTimes.size() - 1).plusMinutes(l.getSpinsEveryMinutes()); + if (next.isAfter(now)) return ResponseEntity.status(409).body(Map.of("error", "not_due")); + } + + TimeLockService service = timeLockServiceFactory.create(l); + SpinningWheelEntry entry = service.spinWheel(); + if (entry == null) return ResponseEntity.status(409).body(Map.of("error", "no_entry")); + + // Record spin time + if (l.getSpinningWheelTimes() == null) l.setSpinningWheelTimes(new ArrayList<>()); + l.getSpinningWheelTimes().add(now); + timeLockRepository.save(l); + + Map result = new LinkedHashMap<>(); + result.put("type", entry.getType().name()); + result.put("intVal", entry.getIntVal()); + result.put("stringVal", entry.getStringVal()); + // Include updated lock time fields + result.put("newUnlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null); + result.put("newFrozenUntil", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null); + result.put("isFrozen", l.getFrozenFrom() != null); + result.put("currentTask", l.getCurrentTask()); + return ResponseEntity.ok(result); + } + + // ── Aufgabe erledigt ────────────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/relock") + @Transactional + public ResponseEntity relock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getControllType() != LockControllType.TTLOCK) return ResponseEntity.status(409).build(); + + var lc = timeLockServiceFactory.create(l).getLockControl(); + if (lc != null) lc.lock(); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/timelock/{lockId}/task/done") + @Transactional + public ResponseEntity taskDone(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getCurrentTask() == null) return ResponseEntity.status(409).build(); + + // Check task timer hasn't expired (still locked) + if (l.getTaskUntil() != null && l.getTaskUntil().isAfter(LocalDateTime.now())) + return ResponseEntity.status(409).body(null); + + timeLockServiceFactory.create(l).clearTask(); + timeLockRepository.save(l); + return ResponseEntity.noContent().build(); + } + + // ── Hygiene-Öffnung starten ─────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/hygiene/start") + @Transactional + public ResponseEntity> startHygiene(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getHygineOpeningEveryMinites() == null) return ResponseEntity.status(409).build(); + if (l.getTempOpeningTime() != null) return ResponseEntity.status(409).body(Map.of("error", "already_open")); + + TimeLockService service = timeLockServiceFactory.create(l); + service.startHygieneOpening(); + + return ResponseEntity.ok(Map.of( + "unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "", + "durationMinutes", l.getHygineOpeningDurationMinutes() != null ? l.getHygineOpeningDurationMinutes() : 0, + "openedAt", l.getTempOpeningTime() != null ? l.getTempOpeningTime().toString() : "")); + } + + // ── Hygiene-Öffnung beenden ─────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/hygiene/end") + @Transactional + public ResponseEntity> endHygiene(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.getTempOpeningTime() == null || TempOpeningReason.HYGIENE != l.getTempOpeningReason()) + return ResponseEntity.status(409).build(); + + TimeLockService service = timeLockServiceFactory.create(l); + String newCode = service.endTempOpening(); + + return ResponseEntity.ok(Map.of( + "newUnlockCode", newCode, + "newUnlockTime", l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : "")); + } + + // ── Verifikation starten ───────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/verification/start") + @Transactional + public ResponseEntity> startVerification(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + + var dto = timeLockServiceFactory.create(l).startVerification(); + return ResponseEntity.ok(Map.of("verificationId", dto.verficationId().toString(), "code", dto.code())); + } + + // ── Verifikation abschließen ────────────────────────────────────────────────── + + @PostMapping(value = "/timelock/{lockId}/verification/{verificationId}/complete", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity completeVerification( + @PathVariable UUID lockId, + @PathVariable UUID verificationId, + @RequestParam MultipartFile image, + Principal principal) throws IOException { + + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + + boolean found = timeLockServiceFactory.create(l).completeVerification(verificationId, scaleImage(image.getBytes(), 1024)); + if (!found) return ResponseEntity.notFound().build(); + + if (l.getKeyholder() != null) { + String meName = userRepository.findById(myId).map(u -> u.getName()).orElse(""); + systemMessageService.send(myId, l.getKeyholder(), + "📸 " + meName + " hat eine Verifikation eingereicht.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + } + return ResponseEntity.noContent().build(); + } + + // ── Lock beenden (Zeit abgelaufen / Test-Lock) ──────────────────────────────── + + @DeleteMapping("/timelock/{lockId}") + @Transactional + public ResponseEntity endLock(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + + LocalDateTime now = LocalDateTime.now(); + boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now); + boolean isFrozen = l.getFrozenFrom() != null + && (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now)); + + if (!l.isTestLock() && !timeUp && !l.isKeyholderRequestedUnlock()) { + return ResponseEntity.status(409).build(); // Not yet unlockable + } + if (isFrozen && !l.isTestLock()) { + return ResponseEntity.status(409).body(null); // Frozen + } + + TimeLockService service = timeLockServiceFactory.create(l); + if (l.getUnlockCode() != null && !l.getUnlockCode().isBlank()) { + String reason = l.isKeyholderRequestedUnlock() ? "KEYHOLDER_UNLOCK" : "LOCK_OPEN"; + unlockCodeHistoryService.save(myId, lockId, l.getName(), l.getUnlockCode(), reason); + } + service.unlock(l.getUnlockCode()); + + // Clean up verifications + var verifications = verificationRepository.findByLockId(lockId); + verifications.forEach(v -> verificationVoteRepository.deleteAllByVerificationId(v.getDisplayId())); + verificationRepository.deleteAll(verifications); + invitationRepository.deleteByLockId(lockId); + timeLockRepository.deleteByLockId(lockId); + + return ResponseEntity.noContent().build(); + } + + // ── Tatsächliche Entsperrzeit setzen ───────────────────────────────────────── + + @PatchMapping("/timelock/{lockId}/unlock-time") + @Transactional + public ResponseEntity setActualUnlockTime(@PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + + LocalDateTime now = LocalDateTime.now(); + boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now); + if (!timeUp && !l.isTestLock() && !l.isKeyholderRequestedUnlock()) + return ResponseEntity.status(409).build(); + + l.setUnlockTime(now); + timeLockRepository.save(l); + return ResponseEntity.noContent().build(); + } + + // ── Keyholder-Ansicht ───────────────────────────────────────────────────────── + + @GetMapping("/timelock/as-keyholder") + public ResponseEntity>> getTimeLocksAsKeyholder(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var locks = timeLockRepository.findByKeyholderAndStartTimeIsNotNullAndUnlockTimeIsNull(myId); + List> result = new ArrayList<>(); + for (var lock : locks) { + var lockeeOpt = userRepository.findById(lock.getLockee()); + if (lockeeOpt.isEmpty()) continue; + var lockee = lockeeOpt.get(); + LocalDateTime now = LocalDateTime.now(); + boolean isFrozen = lock.getFrozenFrom() != null + && (lock.getFrozenUntil() == null || lock.getFrozenUntil().isAfter(now)); + Map item = new LinkedHashMap<>(); + item.put("lockId", lock.getLockId().toString()); + item.put("lockType", "TIMELOCK"); + item.put("lockName", lock.getName() != null ? lock.getName() : "TimeLock"); + item.put("lockeeName", lockee.getName()); + item.put("lockeeId", lockee.getUserId().toString()); + item.put("lockeeProfilePic", lockee.getProfilePicture()); + item.put("startTime", lock.getStartTime() != null ? lock.getStartTime().toString() : null); + item.put("isFrozen", isFrozen); + item.put("emergencyUnlockRequested", lock.getEmergencyUnlockRequestedAt() != null); + result.add(item); + } + return ResponseEntity.ok(result); + } + + @GetMapping("/timelock/as-keyholder/{lockId}") + public ResponseEntity> getTimeLockAsKeyholder( + @PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build(); + + var lockeeOpt = userRepository.findById(l.getLockee()); + if (lockeeOpt.isEmpty()) return ResponseEntity.notFound().build(); + var lockee = lockeeOpt.get(); + + LocalDateTime now = LocalDateTime.now(); + boolean timeUp = l.getEstimatedUnlockTime() != null && l.getEstimatedUnlockTime().isBefore(now); + boolean isFrozen = l.getFrozenFrom() != null + && (l.getFrozenUntil() == null || l.getFrozenUntil().isAfter(now)); + + // Verifikation + boolean verificationDue = false; + boolean verificationDoneToday = false; + String verificationMyVote = null; + String verificationTodayId = null; + if (l.isRequiresVerification()) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + var completed = verificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lockId, todayStart, todayEnd); + if (!completed.isEmpty()) { + verificationDoneToday = true; + var todayV = completed.get(0); + verificationTodayId = todayV.getDisplayId().toString(); + var myVote = verificationVoteRepository + .findAllByVerificationId(todayV.getDisplayId()).stream() + .filter(v -> myId.equals(v.getVoteId())).findFirst(); + verificationMyVote = myVote.map(v -> v.isUpvote() ? "upvote" : "downvote").orElse(null); + } else { + verificationDue = true; + } + } + + Map result = new LinkedHashMap<>(); + result.put("lockId", l.getLockId().toString()); + result.put("lockType", "TIMELOCK"); + result.put("lockName", l.getName() != null ? l.getName() : "TimeLock"); + result.put("lockeeId", lockee.getUserId().toString()); + result.put("lockeeName", lockee.getName()); + result.put("lockeeProfilePic", lockee.getProfilePicture()); + result.put("startTime", l.getStartTime() != null ? l.getStartTime().toString() : null); + result.put("unlockTime", (l.isEndTimeVisible() || timeUp) + ? (l.getEstimatedUnlockTime() != null ? l.getEstimatedUnlockTime().toString() : null) : null); + result.put("timeUp", timeUp); + result.put("isFrozen", isFrozen); + result.put("frozenUntil", l.getFrozenUntil() != null ? l.getFrozenUntil().toString() : null); + result.put("emergencyUnlockRequested", l.getEmergencyUnlockRequestedAt() != null); + result.put("keyholderRequestedUnlock", l.isKeyholderRequestedUnlock()); + result.put("requiresVerification", l.isRequiresVerification()); + result.put("verificationDue", verificationDue); + result.put("verificationDoneToday", verificationDoneToday); + result.put("verificationMyVote", verificationMyVote); + result.put("verificationTodayId", verificationTodayId); + return ResponseEntity.ok(result); + } + + @PostMapping("/timelock/as-keyholder/{lockId}/request-unlock") + @Transactional + public ResponseEntity requestUnlockAsKeyholder( + @PathVariable UUID lockId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build(); + + l.setKeyholderRequestedUnlock(true); + timeLockRepository.save(l); + return ResponseEntity.noContent().build(); + } + + // ── Einfrieren (Keyholder) ──────────────────────────────────────────────────── + + record FreezeRequest(String frozenUntil) {} + + @PostMapping("/timelock/as-keyholder/{lockId}/freeze") + @Transactional + public ResponseEntity freezeTimeLock(@PathVariable UUID lockId, + @RequestBody FreezeRequest req, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build(); + + LocalDateTime until; + try { + until = LocalDateTime.parse(req.frozenUntil()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(Map.of("error", "Ungültiges Datumsformat.")); + } + if (!until.isAfter(LocalDateTime.now())) + return ResponseEntity.badRequest().body(Map.of("error", "Zeitpunkt muss in der Zukunft liegen.")); + + l.setFrozenFrom(LocalDateTime.now()); + l.setFrozenUntil(until); + timeLockRepository.save(l); + + systemMessageService.send(myId, l.getLockee(), + me.getName() + " hat dein Lock bis " + + until.toLocalDate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy")) + " " + + until.toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + + " Uhr eingefroren.", + "/games/chastity/activetimelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/timelock/as-keyholder/{lockId}/freeze") + @Transactional + public ResponseEntity unfreezeTimeLock(@PathVariable UUID lockId, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!myId.equals(l.getKeyholder())) return ResponseEntity.status(403).build(); + + TimeLockService service = timeLockServiceFactory.create(l); + service.unfreeze(); + l.setFrozenFrom(null); + l.setFrozenUntil(null); + timeLockRepository.save(l); + + systemMessageService.send(myId, l.getLockee(), + me.getName() + " hat dein Lock entfroren.", + "/games/chastity/activetimelock.html?lockId=" + lockId, de.oaa.xxx.social.entity.MessageCause.GAME_STATE); + + return ResponseEntity.noContent().build(); + } + + // ── Notfall-Entsperrung ─────────────────────────────────────────────────────── + + @PostMapping("/timelock/{lockId}/emergency-unlock") + @Transactional + public ResponseEntity requestEmergencyUnlock(@PathVariable UUID lockId, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + var lockOpt = timeLockRepository.findById(lockId); + if (lockOpt.isEmpty()) return ResponseEntity.notFound().build(); + var l = lockOpt.get(); + if (!l.getLockee().equals(myId)) return ResponseEntity.status(403).build(); + if (l.isTestLock()) return ResponseEntity.badRequest().build(); + if (l.getEmergencyUnlockRequestedAt() != null) return ResponseEntity.status(409).build(); + + l.setEmergencyUnlockRequestedAt(LocalDateTime.now()); + if (l.getKeyholder() == null) { + // Kein Keyholder: sofort entsperren, Lock als ungültig markieren (keine Historie, keine XP) + l.setEmergencyAutoUnlocked(true); + l.setKeyholderRequestedUnlock(true); + timeLockRepository.save(l); + if (l.getUnlockCode() != null && !l.getUnlockCode().isBlank()) { + unlockCodeHistoryService.save(myId, lockId, l.getName(), l.getUnlockCode(), "EMERGENCY_UNLOCK"); + } + return ResponseEntity.ok(Map.of("unlockCode", l.getUnlockCode() != null ? l.getUnlockCode() : "")); + } else { + systemMessageService.send(myId, l.getKeyholder(), + "⚠️ NOTFALL: " + me.getName() + " bittet dringend um Freigabe des Locks. Bitte reagiere innerhalb einer Stunde.", + "/games/chastity/keyholder.html", de.oaa.xxx.social.entity.MessageCause.EMERGENCY); + timeLockRepository.save(l); + return ResponseEntity.noContent().build(); + } + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────────── + + private TimeLockEntity buildBaseEntity(TimeLockTemplateEntity template, UUID keyholder, UUID lockee, boolean testLock) { + TimeLockEntity lock = new TimeLockEntity(); + lock.setName(template.getName()); + lock.setLockee(lockee); + lock.setKeyholder(keyholder); + lock.setRequiresVerification(template.isRequiresVerification()); + lock.setTestLock(testLock); + lock.setEndTimeVisible(template.isEndTimeVisible()); + lock.setTasks(template.getTasks()); + lock.setTaskMode(template.getTaskCardMode()); + lock.setTaskEveryMinutes(template.getTaskEveryMinutes()); + lock.setMinTasksPerDay(template.getMinTasksPerDay()); + lock.setSpinningWheelEntries(template.getSpinningWheelEntries()); + lock.setSpinsEveryMinutes(template.getSpinsEveryMinutes()); + lock.setMinSpinsPerDay(template.getMinSpinsPerDay()); + lock.setHygineOpeningDurationMinutes(template.getHygineOpeningDurationMinutes()); + lock.setHygineOpeningEveryMinites(template.getHygineOpeningEveryMinites()); + lock.setPenaltyType(template.getPenaltyType()); + lock.setPenaltyValue(template.getPenaltyValue()); + lock.setMinTimeInMinutes(template.getMinTimeInMinutes()); + lock.setMaxTimeInMinutes(template.getMaxTimeInMinutes()); + return lock; + } + + + private byte[] scaleImage(byte[] input, int maxSize) throws IOException { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(input)); + if (original == null) return input; + int w = original.getWidth(), h = original.getHeight(); + if (w <= maxSize && h <= maxSize) return input; + double scale = (double) maxSize / Math.max(w, h); + int newW = (int) (w * scale), newH = (int) (h * scale); + BufferedImage scaled = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(original, 0, 0, newW, newH, null); + g.dispose(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(scaled, "jpeg", out); + return out.toByteArray(); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.java new file mode 100644 index 0000000..bed085d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockEntity.java @@ -0,0 +1,61 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import de.oaa.xxx.games.chastity.common.BaseLockEntity; +import de.oaa.xxx.games.chastity.common.PenaltyType; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelConverter; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +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("TIMELOCK") +public class TimeLockEntity extends BaseLockEntity { + + @Column + private boolean endTimeVisible; + @Column + private Integer minTimeInMinutes; + @Column + private Integer maxTimeInMinutes; + + @Column + private Integer taskEveryMinutes; + @Column + private Integer minTasksPerDay; + + @Convert(converter = SpinningWheelConverter.class) + @Column(columnDefinition = "TEXT") + private List spinningWheelEntries; + @Column + private Integer spinsEveryMinutes; + @Column + private Integer minSpinsPerDay; + + @Column + private PenaltyType penaltyType; + @Column + private Integer penaltyValue; + + @Column + private LocalDateTime estimatedUnlockTime; + + @Column + private LocalDateTime frozenFrom; + + @Column + private List taskTimes; + @Column + private List spinningWheelTimes; + @Column + private LocalDate lastCheck; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java new file mode 100644 index 0000000..8e437f9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockRepository.java @@ -0,0 +1,21 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.util.List; +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 TimeLockRepository extends JpaRepository { + + boolean existsByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee); + + java.util.Optional findFirstByLockeeAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID lockee); + + List findByKeyholderAndStartTimeIsNotNullAndUnlockTimeIsNull(UUID keyholder); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM TimeLockEntity t WHERE t.lockId = :lockId") + void deleteByLockId(UUID lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java new file mode 100644 index 0000000..e0e079b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockService.java @@ -0,0 +1,378 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Random; +import java.util.UUID; + +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.common.CodeCreator; +import de.oaa.xxx.games.chastity.common.VerificationCommonDTO; +import de.oaa.xxx.games.chastity.community.CommunityPilloryEntity; +import de.oaa.xxx.games.chastity.community.CommunityPilloryReason; +import de.oaa.xxx.games.chastity.community.CommunityPilloryRepository; +import de.oaa.xxx.games.chastity.community.CommunityTaskVoteRepository; +import de.oaa.xxx.games.chastity.community.CommunityVerificationEntity; +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.KeyholderVerificationEntity; +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.spinningwheel.SpinningWheelEntry; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +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 TimeLockService extends BaseLockService implements LockControlCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(TimeLockService.class); + private final TimeLockEntity lock; + private final TimeLockRepository timeLockRepository; + private final CommunityPilloryRepository pilloryRepository; + private final LockControlFactory lockControlFactory; + + // lockControl ist in BaseLockService als protected-Feld definiert + + public TimeLockService(TimeLockEntity lock, + CommunityVerificationRepository verificationRepository, + CommunityVerificationVoteRepository verificationVoteRepository, + TimeLockRepository timeLockRepository, + GameHistoryRepository gameHistoryRepository, + UserRepository userRepository, + KeyholderNotificationRepository keyholderNotificationRepository, + KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + KeyholderVerificationRepository keyholderVerificationRepository, + CommunityTaskVoteRepository communityTaskVoteRepository, + CommunityPilloryRepository pilloryRepository, + UnlockCodeHistoryService unlockCodeHistoryService, + SystemMessageService systemMessageService, + LockControlFactory lockControlFactory) { + super(verificationVoteRepository, verificationRepository, keyholderVerificationRepository, + gameHistoryRepository, userRepository, keyholderNotificationRepository, + systemMessageService, unlockCodeHistoryService, + keyholderTaskChoiceRepository, communityTaskVoteRepository); + this.lock = lock; + this.timeLockRepository = timeLockRepository; + this.pilloryRepository = pilloryRepository; + this.lockControlFactory = lockControlFactory; + // lockControl aus Entity-Typ wiederherstellen (für bereits laufende Locks) + if (lock.getControllType() != null) { + this.lockControl = lockControlFactory.create(lock.getControllType(), this, lock.getLockee()); + } + } + + // ── Abstract method implementations ────────────────────────────────────── + + @Override + protected BaseLockEntity getLock() { + return lock; + } + + @Override + protected void saveLock() { + timeLockRepository.save(lock); + } + + @Override + protected GameType getGameType() { + return GameType.TIMELOCK; + } + + @Override + protected void applyHygieneOvertime(Long overtime) { + LOGGER.debug("Apply {} Minutes Overtime"); + lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(overtime * 4)); + LOGGER.debug("New estimated endtime {}", lock.getEstimatedUnlockTime()); + } + + // ── Hook overrides ──────────────────────────────────────────────────────── + + @Override + protected void beforePhysicalUnlock() { + lockControl.unlock(); + } + + @Override + protected void afterHygieneClosing() { + lockControl.lock(); + } + + // ── Initialisation ──────────────────────────────────────────────────────── + + /** + * Initialisiert ein neues Lock anhand eines Template und Laufzeit-Einstellungen. + * Ruft am Ende lockControl.lock() auf – bei UNLOCK_CODE wird dabei der Entsperrcode + * generiert und das Lock bereits persistiert. + */ + public void init(TimeLockTemplateEntity template, TimeLockAdditionalSettings settings) { + de.oaa.xxx.games.chastity.lockcontroll.LockControllType type = + settings.controllType() != null ? settings.controllType() + : de.oaa.xxx.games.chastity.lockcontroll.LockControllType.UNLOCK_CODE; + lock.setControllType(type); + lockControl = lockControlFactory.create(type, this, settings.lockee()); + + LocalDateTime now = LocalDateTime.now(); + lock.setStartTime(now); + lock.setName(template.getName()); + lock.setLockee(settings.lockee()); + lock.setKeyholder(settings.keyholder()); + lock.setRequiresVerification(template.isRequiresVerification()); + lock.setTestLock(settings.testlock()); + lock.setUnlockCodeLength(settings.unlockCodeLength() != null ? settings.unlockCodeLength() : 5); + + Integer minMinutes = template.getMinTimeInMinutes(); + Integer maxMinutes = template.getMaxTimeInMinutes() != null ? template.getMaxTimeInMinutes() : 60; + int unlockTimeMinutes = (minMinutes != null && minMinutes < maxMinutes) + ? minMinutes + new Random().nextInt(maxMinutes - minMinutes) + : maxMinutes; + lock.setEstimatedUnlockTime(now.plusMinutes(unlockTimeMinutes)); + lock.setEndTimeVisible(template.isEndTimeVisible()); + + lock.setTasks(template.getTasks()); + lock.setTaskEveryMinutes(template.getTaskEveryMinutes()); + lock.setMinTasksPerDay(template.getMinTasksPerDay()); + + lock.setSpinningWheelEntries(template.getSpinningWheelEntries()); + lock.setSpinsEveryMinutes(template.getSpinsEveryMinutes()); + lock.setMinSpinsPerDay(template.getMinSpinsPerDay()); + + lock.setHygineOpeningDurationMinutes(template.getHygineOpeningDurationMinutes()); + lock.setHygineOpeningEveryMinites(template.getHygineOpeningEveryMinites()); + if (template.getHygineOpeningEveryMinites() != null) { + lock.setLastHygineOpening(now); + } + + lock.setTaskMode(template.getTaskCardMode()); + lock.setPenaltyType(template.getPenaltyType()); + lock.setPenaltyValue(template.getPenaltyValue()); + lock.setMinTimeInMinutes(template.getMinTimeInMinutes()); + lock.setMaxTimeInMinutes(template.getMaxTimeInMinutes()); + + lockControl.lock(); + } + + // ── Spinning wheel ──────────────────────────────────────────────────────── + + public SpinningWheelEntry spinWheel() { + if (TempOpeningReason.HYGIENE != lock.getTempOpeningReason()) { + var entries = lock.getSpinningWheelEntries(); + var entry = entries.get(new Random().nextInt(entries.size())); + entry.getType().apply(this, entry.getIntVal(), entry.getStringVal()); + return entry; + } + // Nicht während der Hygiene-Öffnung + return null; + } + + // ── Time controls ───────────────────────────────────────────────────────── + + public void addTime(Integer intVal) { + LOGGER.debug("Lock addTime: %s minutes", intVal); + lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(intVal)); + } + + public void removeTime(Integer intVal) { + LOGGER.debug("Lock removeTime: %s minutes", intVal); + lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().minusMinutes(intVal)); + } + + public void freeze(Integer intVal) { + LOGGER.debug("Lock frozen for %s minutes", intVal); + lock.setFrozenFrom(LocalDateTime.now()); + lock.setFrozenUntil(LocalDateTime.now().plusMinutes(intVal)); + } + + public void freeze() { + LOGGER.debug("Lock frozen"); + lock.setFrozenFrom(LocalDateTime.now()); + } + + public void unfreeze() { + if (lock.getFrozenFrom() != null) { + var unfreeTime = lock.getFrozenUntil() != null ? lock.getFrozenUntil() : LocalDateTime.now(); + var diff = ChronoUnit.MINUTES.between(lock.getFrozenFrom(), unfreeTime); + LOGGER.debug("Lock unfrozen - adding %s minutes to the lock", diff); + lock.setEstimatedUnlockTime(lock.getEstimatedUnlockTime().plusMinutes(diff)); + } else { + LOGGER.debug("Lock not frozen - ignore Call"); + } + } + + public void testUnfreeze() { + if (lock.getFrozenUntil().isAfter(LocalDateTime.now())) { + unfreeze(); + } + } + + // ── Tasks ───────────────────────────────────────────────────────────────── + + public void task() { + if (TempOpeningReason.HYGIENE != lock.getTempOpeningReason()) { + switch (lock.getTaskMode()) { + case TaskMode.RANDOM -> applyRandomTask(); + case TaskMode.KEYHOLDER -> startKeyholderVote(); + case TaskMode.COMMUNITY -> { + if (lock.isTestLock()) applyRandomTask(); + else startCommunityVote(); + } + } + } + // Nicht während der Hygiene-Öffnung + } + + public void text(Integer intVal, String stringVal) { + LOGGER.debug("Apply text {}", stringVal); + lock.setCurrentTask(stringVal); + if (intVal != null && intVal > 0) { + lock.setTaskUntil(LocalDateTime.now().plusMinutes(intVal)); + } + } + + // ── Verification ────────────────────────────────────────────────────────── + + /** + * Gibt eine bestehende Verifikation für heute zurück (Idempotenz) oder legt eine neue an. + * Erstellt je nach Keyholder-Präsenz eine KeyholderVerification oder CommunityVerification. + */ + public VerificationCommonDTO startVerification() { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + + if (lock.getKeyholder() != null) { + var existing = keyholderVerificationRepository.findByLockId(lock.getLockId()).stream() + .filter(v -> v.getCreatedAt().toLocalDate().equals(today)) + .findFirst(); + if (existing.isPresent()) return existing.get().toCommonVerification(); + + KeyholderVerificationEntity v = new KeyholderVerificationEntity(); + v.setId(UUID.randomUUID()); + v.setLockId(lock.getLockId()); + v.setLockeeId(lock.getLockee()); + v.setKeyholderId(lock.getKeyholder()); + v.setCode(CodeCreator.createAlphanumeric(6)); + v.setCreatedAt(LocalDateTime.now()); + keyholderVerificationRepository.save(v); + return v.toCommonVerification(); + } else { + var pending = communityVerificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNull(lock.getLockId(), todayStart, todayEnd); + if (!pending.isEmpty()) return pending.get(0).toCommonVerification(); + var completed = communityVerificationRepository + .findByLockIdAndCreatedAtBetweenAndImageIsNotNull(lock.getLockId(), todayStart, todayEnd); + if (!completed.isEmpty()) return completed.get(0).toCommonVerification(); + + CommunityVerificationEntity v = new CommunityVerificationEntity(); + v.setId(UUID.randomUUID()); + v.setLockId(lock.getLockId()); + v.setLockeeId(lock.getLockee()); + v.setCode(CodeCreator.createAlphanumeric(6)); + v.setCreatedAt(LocalDateTime.now()); + communityVerificationRepository.save(v); + return v.toCommonVerification(); + } + } + + /** + * Speichert das Verifikationsbild auf der richtigen Entity (Keyholder oder Community). + * Gibt false zurück wenn die Verifikation nicht gefunden wurde oder nicht zu diesem Lock gehört. + */ + public boolean completeVerification(UUID verificationId, byte[] image) { + if (lock.getKeyholder() != null) { + var vOpt = keyholderVerificationRepository.findById(verificationId); + if (vOpt.isEmpty() || !vOpt.get().getLockId().equals(lock.getLockId())) return false; + var v = vOpt.get(); + v.setImage(image); + keyholderVerificationRepository.save(v); + } else { + var vOpt = communityVerificationRepository.findById(verificationId); + if (vOpt.isEmpty() || !vOpt.get().getLockId().equals(lock.getLockId())) return false; + var v = vOpt.get(); + v.setImage(image); + communityVerificationRepository.save(v); + } + return true; + } + + // ── Penalty & check ─────────────────────────────────────────────────────── + + public void applyPenalty() { + if (lock.getPenaltyType() != null) { + switch (lock.getPenaltyType()) { + case ADD -> addTime(lock.getPenaltyValue()); + case FREEZE -> freeze(); + case PILLORY -> pillory(CommunityPilloryReason.HYGIENE_OPENING_EXEEDED, null); + } + } + } + + public void check() { + if (lock.getStartTime() == null) return; + LocalDate today = LocalDate.now(); + if (!lock.getStartTime().toLocalDate().equals(today)) { + if (lock.getLastCheck() != null || today.isAfter(lock.getLastCheck())) { + LOGGER.info("Check the day before for violations"); + LocalDate yesterday = today.minusDays(1); + boolean violation = false; + if (lock.getMinTasksPerDay() != null) { + if (lock.getMinTasksPerDay() > lock.getTaskTimes().stream().map(LocalDateTime::toLocalDate) + .filter(yesterday::equals).count()) { + violation = true; + } + } + if (lock.getMinSpinsPerDay() != null) { + if (lock.getMinSpinsPerDay() > lock.getSpinningWheelTimes().stream() + .map(LocalDateTime::toLocalDate).filter(yesterday::equals).count()) { + violation = true; + } + } + if (violation) { + applyPenalty(); + } + lock.setLastCheck(today); + timeLockRepository.save(lock); + } + } + } + + public void pillory(CommunityPilloryReason reason, UUID keyholderId) { + CommunityPilloryEntity pillory = new CommunityPilloryEntity(); + pillory.setCreatedAt(LocalDateTime.now()); + pillory.setLockeeId(lock.getLockee()); + pillory.setLockId(lock.getLockId()); + pillory.setReason(reason); + pillory.setKeyholderId(keyholderId); + pilloryRepository.save(pillory); + } + + // ── Hygiene opening ─────────────────────────────────────────────────────── + + public void startHygieneOpening() { + startTempOpening(TempOpeningReason.HYGIENE, lock.getHygineOpeningDurationMinutes()); + } + + // ── LockControlCallback ─────────────────────────────────────────────────── + + @Override + public void setUnlockCode(String code) { + lock.setUnlockCode(code); + timeLockRepository.save(lock); + } + + @Override + public int getUnlockcodeLenght() { + return lock.getUnlockCodeLength(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java new file mode 100644 index 0000000..0905faf --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockServiceFactory.java @@ -0,0 +1,79 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import de.oaa.xxx.games.chastity.cardlock.CardlockRepository; +import de.oaa.xxx.games.chastity.common.BaseLockService; +import de.oaa.xxx.games.chastity.community.CommunityPilloryRepository; +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.LockControlFactory; +import de.oaa.xxx.games.chastity.unlock.UnlockCodeHistoryService; +import de.oaa.xxx.games.history.GameHistoryRepository; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.user.UserRepository; + +@Service +public class TimeLockServiceFactory { + + private final TimeLockRepository timeLockRepository; + private final CommunityVerificationRepository communityVerificationRepository; + private final GameHistoryRepository gameHistoryRepository; + private final UserRepository userRepository; + private final KeyholderNotificationRepository keyholderNotificationRepository; + private final CommunityPilloryRepository pilloryRepository; + private final CommunityTaskVoteRepository communityTaskVoteRepository; + private final KeyholderTaskChoiceRepository keyholderTaskChoiceRepository; + private final KeyholderVerificationRepository keyholderVerificationRepository; + + private final UnlockCodeHistoryService unlockCodeHistoryService; + private final SystemMessageService systemMessageService; + private CommunityVerificationVoteRepository communityVerificationVoteRepository; + private final LockControlFactory lockControlFactory; + private final CardlockRepository cardlockRepository; + + public TimeLockServiceFactory(CommunityVerificationRepository verificationRepository, + CommunityVerificationVoteRepository verificationVoteRepository, TimeLockRepository timeLockRepository, + GameHistoryRepository gameHistoryRepository, UserRepository userRepository, + KeyholderNotificationRepository keyholderNotificationRepository, + KeyholderTaskChoiceRepository keyholderTaskChoiceRepository, + KeyholderVerificationRepository keyholderVerificationRepository, + CommunityTaskVoteRepository communityTaskVoteRepository, CommunityPilloryRepository pilloryRepository, + UnlockCodeHistoryService unlockCodeHistoryService, SystemMessageService systemMessageService, + LockControlFactory lockControlFactory, CardlockRepository cardlockRepository) { + this.communityVerificationVoteRepository = verificationVoteRepository; + this.timeLockRepository = timeLockRepository; + this.communityVerificationRepository = verificationRepository; + this.gameHistoryRepository = gameHistoryRepository; + this.userRepository = userRepository; + this.keyholderNotificationRepository = keyholderNotificationRepository; + this.pilloryRepository = pilloryRepository; + this.unlockCodeHistoryService = unlockCodeHistoryService; + this.systemMessageService = systemMessageService; + this.keyholderTaskChoiceRepository = keyholderTaskChoiceRepository; + this.communityTaskVoteRepository = communityTaskVoteRepository; + this.keyholderVerificationRepository = keyholderVerificationRepository; + this.lockControlFactory = lockControlFactory; + this.cardlockRepository = cardlockRepository; + } + + public boolean hasActiveLock(UUID lockeeId) { + return BaseLockService.hasActiveLock(lockeeId, cardlockRepository, timeLockRepository); + } + + /** + * Erstellt eine neue TimeLockService-Instanz für das gegebene Lock. + */ + public TimeLockService create(TimeLockEntity lock) { + return new TimeLockService(lock, communityVerificationRepository, communityVerificationVoteRepository, + timeLockRepository, gameHistoryRepository, userRepository, keyholderNotificationRepository, + keyholderTaskChoiceRepository, keyholderVerificationRepository, communityTaskVoteRepository, + pilloryRepository, unlockCodeHistoryService, systemMessageService, lockControlFactory); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.java new file mode 100644 index 0000000..8a648a7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplate.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.common.PenaltyType; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.tasks.TaskMode; + +public record TimeLockTemplate(UUID templateId, UUID owner, String name, Integer minTimeInMinutes, + Integer maxTimeInMinutes, boolean endTimeVisible, Integer hygineOpeningDurationMinutes, + Integer hygineOpeningEveryMinites, List tasks, Integer taskEveryMinutes, Integer minTasksPerDay, + List spinningWheelEntries, Integer spinsEveryMinutes, Integer minSpinsPerDay, + boolean requiresVerification, TaskMode taskMode, PenaltyType penaltyType, Integer penaltyValue) { +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.java new file mode 100644 index 0000000..7d7bc47 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateController.java @@ -0,0 +1,153 @@ +package de.oaa.xxx.games.chastity.timelock; + +import de.oaa.xxx.games.chastity.cardlock.CardlockTemplateRepository; +import de.oaa.xxx.games.chastity.common.PenaltyType; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +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 java.security.Principal; +import java.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/timelock/templates") +public class TimeLockTemplateController { + + private final TimeLockTemplateRepository templateRepository; + private final UserService userService; + private final CardlockTemplateRepository cardlockTemplateRepository; + private final SubscriptionLimitService limitService; + + public TimeLockTemplateController(TimeLockTemplateRepository templateRepository, + UserService userService, + CardlockTemplateRepository cardlockTemplateRepository, + SubscriptionLimitService limitService) { + this.templateRepository = templateRepository; + this.userService = userService; + this.cardlockTemplateRepository = cardlockTemplateRepository; + this.limitService = limitService; + } + + record TemplateRequest( + String name, + Integer minTimeInMinutes, + Integer maxTimeInMinutes, + boolean endTimeVisible, + Integer hygineOpeningDurationMinutes, + Integer hygineOpeningEveryMinites, + List tasks, + Integer taskEveryMinutes, + Integer minTasksPerDay, + List spinningWheelEntries, + Integer spinsEveryMinutes, + Integer minSpinsPerDay, + boolean requiresVerification, + TaskMode taskMode, + PenaltyType penaltyType, + Integer penaltyValue + ) {} + + private Map toDto(TimeLockTemplateEntity t) { + Map dto = new LinkedHashMap<>(); + dto.put("templateId", t.getTemplateId()); + dto.put("name", t.getName()); + dto.put("minTimeInMinutes", t.getMinTimeInMinutes()); + dto.put("maxTimeInMinutes", t.getMaxTimeInMinutes()); + dto.put("endTimeVisible", t.isEndTimeVisible()); + dto.put("hygineOpeningEveryMinites", t.getHygineOpeningEveryMinites()); + dto.put("hygineOpeningDurationMinutes", t.getHygineOpeningDurationMinutes()); + dto.put("tasks", t.getTasks() != null ? t.getTasks() : List.of()); + dto.put("taskEveryMinutes", t.getTaskEveryMinutes()); + dto.put("minTasksPerDay", t.getMinTasksPerDay()); + dto.put("spinningWheelEntries", t.getSpinningWheelEntries() != null ? t.getSpinningWheelEntries() : List.of()); + dto.put("spinsEveryMinutes", t.getSpinsEveryMinutes()); + dto.put("minSpinsPerDay", t.getMinSpinsPerDay()); + dto.put("requiresVerification", t.isRequiresVerification()); + dto.put("taskMode", t.getTaskCardMode()); + dto.put("penaltyType", t.getPenaltyType()); + dto.put("penaltyValue", t.getPenaltyValue()); + return dto; + } + + @GetMapping + public ResponseEntity>> list(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + List> result = templateRepository.findByOwner(myId) + .stream().map(this::toDto).collect(Collectors.toList()); + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity> create(@RequestBody TemplateRequest req, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + if (req.maxTimeInMinutes() == null || req.maxTimeInMinutes() < 1) return ResponseEntity.badRequest().build(); + + long totalTemplates = templateRepository.countByOwner(myId) + + cardlockTemplateRepository.countByOwner(myId); + if (totalTemplates >= limitService.maxLockTemplates(myId)) + return ResponseEntity.status(409).header("X-Error", "limit-reached").build(); + + TimeLockTemplateEntity t = new TimeLockTemplateEntity(); + t.setOwner(myId); + applyRequest(t, req); + templateRepository.save(t); + return ResponseEntity.ok(toDto(t)); + } + + @PutMapping("/{id}") + public ResponseEntity> 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(); + TimeLockTemplateEntity t = opt.get(); + if (!t.getOwner().equals(myId)) return ResponseEntity.status(403).build(); + + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + if (req.maxTimeInMinutes() == null || req.maxTimeInMinutes() < 1) return ResponseEntity.badRequest().build(); + + applyRequest(t, req); + templateRepository.save(t); + return ResponseEntity.ok(toDto(t)); + } + + @DeleteMapping("/{id}") + public ResponseEntity 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(TimeLockTemplateEntity t, TemplateRequest req) { + t.setName(req.name()); + t.setMinTimeInMinutes(req.minTimeInMinutes()); + t.setMaxTimeInMinutes(req.maxTimeInMinutes()); + t.setEndTimeVisible(req.endTimeVisible()); + t.setHygineOpeningEveryMinites(req.hygineOpeningEveryMinites()); + t.setHygineOpeningDurationMinutes(req.hygineOpeningDurationMinutes()); + t.setTasks(req.tasks() != null ? req.tasks() : List.of()); + t.setTaskEveryMinutes(req.taskEveryMinutes()); + t.setMinTasksPerDay(req.minTasksPerDay()); + t.setSpinningWheelEntries(req.spinningWheelEntries() != null ? req.spinningWheelEntries() : List.of()); + t.setSpinsEveryMinutes(req.spinsEveryMinutes()); + t.setMinSpinsPerDay(req.minSpinsPerDay()); + t.setRequiresVerification(req.requiresVerification()); + t.setTaskMode(req.taskMode() != null ? req.taskMode() : TaskMode.RANDOM); + t.setPenaltyType(req.penaltyType()); + t.setPenaltyValue(req.penaltyValue()); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.java new file mode 100644 index 0000000..db6e363 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateEntity.java @@ -0,0 +1,76 @@ +package de.oaa.xxx.games.chastity.timelock; + +import java.util.List; + +import de.oaa.xxx.games.chastity.common.BaseLockTemplateEntity; +import de.oaa.xxx.games.chastity.common.PenaltyType; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelConverter; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +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("TIMELOCK") +public class TimeLockTemplateEntity extends BaseLockTemplateEntity { + + @Column + private Integer minTimeInMinutes; + @Column + private Integer maxTimeInMinutes; + @Column + private boolean endTimeVisible; + @Column + private Integer taskEveryMinutes; + @Column + private Integer minTasksPerDay; + @Convert(converter = SpinningWheelConverter.class) + @Column(columnDefinition = "TEXT") + private List spinningWheelEntries; + @Column + private Integer spinsEveryMinutes; + @Column + private Integer minSpinsPerDay; + @Column + private PenaltyType penaltyType; + @Column + private Integer penaltyValue; + + public TimeLockTemplate toTimeLockTemplate() { + return new TimeLockTemplate(getTemplateId(), getOwner(), getName(), minTimeInMinutes, maxTimeInMinutes, endTimeVisible, + getHygineOpeningDurationMinutes(), getHygineOpeningEveryMinites(), getTasks(), taskEveryMinutes, minTasksPerDay, + spinningWheelEntries, spinsEveryMinutes, minSpinsPerDay, isRequiresVerification(), getTaskMode(), penaltyType, + penaltyValue); + } + + public static TimeLockTemplateEntity fromTemplate(TimeLockTemplate template) { + if (template != null) { + TimeLockTemplateEntity entity = new TimeLockTemplateEntity(); + entity.setOwner(template.owner()); + entity.setName(template.name()); + entity.setMinTimeInMinutes(template.minTimeInMinutes()); + entity.setMaxTimeInMinutes(template.maxTimeInMinutes()); + entity.setEndTimeVisible(template.endTimeVisible()); + entity.setHygineOpeningDurationMinutes(template.hygineOpeningDurationMinutes()); + entity.setHygineOpeningEveryMinites(template.hygineOpeningEveryMinites()); + entity.setTasks(template.tasks()); + entity.setTaskEveryMinutes(template.taskEveryMinutes()); + entity.setMinTasksPerDay(template.minTasksPerDay()); + entity.setSpinningWheelEntries(template.spinningWheelEntries()); + entity.setSpinsEveryMinutes(template.spinsEveryMinutes()); + entity.setMinSpinsPerDay(template.minSpinsPerDay()); + entity.setRequiresVerification(template.requiresVerification()); + entity.setTaskMode(template.taskMode() != null ? template.taskMode() : TaskMode.RANDOM); + entity.setPenaltyType(template.penaltyType()); + entity.setPenaltyValue(template.penaltyValue()); + return entity; + } + return null; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.java new file mode 100644 index 0000000..9515948 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.games.chastity.timelock; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface TimeLockTemplateRepository extends JpaRepository { + List findByOwner(UUID owner); + long countByOwner(UUID owner); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.java b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.java new file mode 100644 index 0000000..d12ccd6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateService.java @@ -0,0 +1,92 @@ +package de.oaa.xxx.games.chastity.timelock; + +import static de.oaa.xxx.util.ValidationResult.ERROR; +import static de.oaa.xxx.util.ValidationResult.OK; +import static de.oaa.xxx.util.ValidationResult.WARNING; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.chastity.spinningwheel.EntryType; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.util.ValidationResult; + +public class TimeLockTemplateService { + + private TimeLockTemplateRepository timelockTemplateRepository; + + public TimeLockTemplateService(TimeLockTemplateRepository timelockTemplateRepository) { + this.timelockTemplateRepository = timelockTemplateRepository; + } + + public List all(UUID ownderId) { + return timelockTemplateRepository.findByOwner(ownderId).stream().map(template -> template.toTimeLockTemplate()).toList(); + } + + public boolean safe(TimeLockTemplate template) { + var result = validate(template); + if (ValidationResult.OK == result || ValidationResult.WARNING == result) { + timelockTemplateRepository.save(TimeLockTemplateEntity.fromTemplate(template)); + return true; + } + return false; + } + + public ValidationResult validate(TimeLockTemplate template) { + if (template == null) { + return ERROR; + } + if (template.owner() == null || + template.name() == null || + template.maxTimeInMinutes() == null) { + return ERROR; + } + if (template.taskEveryMinutes() != null) { + if (template.tasks() == null || template.tasks().isEmpty()) { + return ERROR; + } + if (template.spinningWheelEntries() != null && !template.spinningWheelEntries().isEmpty() + && template.spinningWheelEntries().stream().anyMatch(entry -> EntryType.TASK == entry.getType())) { + return ERROR; + } + if (template.minTasksPerDay() != null) { + int minTime = getMinimumTime(template); + if (minTime > (24*60)) { + return ERROR; + } else if (minTime > (12*60)) { + return WARNING; + } + } + } else if (template.minTasksPerDay() != null) { + return ERROR; + } + if (template.spinsEveryMinutes() != null) { + if (template.spinningWheelEntries() == null || template.spinningWheelEntries().isEmpty()) { + return ERROR; + } + if (template.minSpinsPerDay() != null) { + int minTime = template.spinsEveryMinutes() * template.minSpinsPerDay(); + if (minTime > (24*60)) { + return ERROR; + } else if (minTime > (12*60)) { + return WARNING; + } + } + } else if (template.minSpinsPerDay() != null) { + return ERROR; + } + if (template.hygineOpeningEveryMinites() != null && template.hygineOpeningDurationMinutes() == null) { + return ERROR; + } + return OK; + } + + private Integer getMinimumTime(TimeLockTemplate template) { + int votetime = 0; + if (TaskMode.COMMUNITY == template.taskMode() || TaskMode.KEYHOLDER == template.taskMode()) { + votetime = 60 * template.minTasksPerDay(); + } + int waittime = template.taskEveryMinutes() * template.minTasksPerDay(); + return votetime + waittime; + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java new file mode 100644 index 0000000..aaafdf9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTAuthService.java @@ -0,0 +1,38 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import java.util.Map; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +public class TTAuthService { + + private final String AUTH_URL = "https://euapi.ttlock.com/oauth2/token"; + + @SuppressWarnings("unchecked") + public String getAccessToken(String clientId, String clientSecret, String username, String md5Password) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("client_id", clientId); + map.add("client_secret", clientSecret); + map.add("username", username); + map.add("password", md5Password); // MD5 Hash des TTLock-Passworts + map.add("grant_type", "password"); + + HttpEntity> request = new HttpEntity<>(map, headers); + + // Response parsen und access_token extrahieren + Map response = restTemplate.postForObject(AUTH_URL, request, Map.class); + return (String) response.get("access_token"); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java new file mode 100644 index 0000000..f616888 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockCallback.java @@ -0,0 +1,225 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +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 com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.oaa.xxx.games.chastity.common.BaseLockEntity; +import de.oaa.xxx.games.chastity.common.BaseLockRepository; +import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationEntity; +import de.oaa.xxx.games.chastity.keyholder.KeyholderNotificationRepository; +import de.oaa.xxx.games.chastity.unlock.TempOpeningReason; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.user.UserRepository; +import lombok.Data; + +@RestController +@RequestMapping("/api/ttlock/callback") +public class TTLockCallback { + + private static final Logger LOGGER = LoggerFactory.getLogger(TTLockCallback.class); + + private static final int START_WINDOW_MINUTES = 5; + + private final TTLockUserConfigRepository ttLockUserConfigRepository; + private final BaseLockRepository baseLockRepository; + private final KeyholderNotificationRepository keyholderNotificationRepository; + private final UserRepository userRepository; + private final SystemMessageService systemMessageService; + + public TTLockCallback(TTLockUserConfigRepository ttLockUserConfigRepository, + BaseLockRepository baseLockRepository, + KeyholderNotificationRepository keyholderNotificationRepository, + UserRepository userRepository, + SystemMessageService systemMessageService) { + this.ttLockUserConfigRepository = ttLockUserConfigRepository; + this.baseLockRepository = baseLockRepository; + this.keyholderNotificationRepository = keyholderNotificationRepository; + this.userRepository = userRepository; + this.systemMessageService = systemMessageService; + } + + @GetMapping + public String test() { + return "OK"; + } + + @PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public String handleCallback(@RequestParam Map allRequestParams) { + LOGGER.debug("Callback von TTLock erhalten, verarbeite..."); + try { + var wrapper = parse(allRequestParams); + if (Integer.valueOf(1).equals(wrapper.getNotifyType())) { + LOGGER.info("Lock {} wurde aufgeschlossen", wrapper.getLockId()); + checkUser(wrapper); + } else { + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Uninteressantes ereignis: {}", wrapper); + } + } + return "1"; + } catch (Exception e) { + LOGGER.error("Fehler beim Verarbeiten des Callbacks", e); + return "0"; + } + } + + @Transactional + void checkUser(TTLockCallbackWrapper wrapper) { + var userOpt = ttLockUserConfigRepository.findByLockId(wrapper.getLockId()); + if (userOpt.isEmpty()) { + LOGGER.warn("TTLock-Öffnung für unbekanntes Lock {} – nicht in XXX-Sphere registriert", wrapper.getLockId()); + return; + } + + var lockOpt = baseLockRepository.findByLockee(userOpt.get().getUserId()); + if (lockOpt.isEmpty()) { + LOGGER.debug("Kein aktives Lock für Benutzer {} gefunden", userOpt.get().getUserId()); + return; + } + + var lock = lockOpt.get(); + + if (lock.getKeyholder() == null) { + LOGGER.debug("Lock {} hat keinen Keyholder – keine Berechtigungsprüfung notwendig", lock.getLockId()); + return; + } + + // Nur erfolgreiche Öffnungen prüfen + LockRecord record = wrapper.getRecords() != null && !wrapper.getRecords().isEmpty() + ? wrapper.getRecords().get(0) : null; + if (record != null && !Integer.valueOf(1).equals(record.getSuccess())) { + LOGGER.debug("Öffnungsversuch an Lock {} war nicht erfolgreich – ignoriere", lock.getLockId()); + return; + } + + if (isOpeningAuthorized(lock)) { + LOGGER.debug("Öffnung von Lock {} ist berechtigt", lock.getLockId()); + } else { + LOGGER.warn("Unerlaubte Öffnung von Lock {} erkannt – benachrichtige Keyholder {}", + lock.getLockId(), lock.getKeyholder()); + notifyKeyholder(lock); + } + } + + /** + * Prüft, ob eine Öffnung des Schlosses zu diesem Zeitpunkt berechtigt ist. + * Berechtigt sind: + *
              + *
            • Das Spiel ist bereits beendet (unlockTime gesetzt)
            • + *
            • Der Keyholder hat die Entsperrung genehmigt (keyholderRequestedUnlock)
            • + *
            • Es läuft gerade eine temporäre Öffnung (Hygiene, Karte, Aufgabe)
            • + *
            • Das Lock wurde vor Kurzem gestartet – der Anwender hat den Startcode erhalten + * und die Übergabe an das physische Schloss ist noch nicht abgeschlossen
            • + *
            + */ + private boolean isOpeningAuthorized(BaseLockEntity lock) { + // Spiel beendet + if (lock.getUnlockTime() != null) return true; + + // Keyholder hat Entsperrung genehmigt + if (lock.isKeyholderRequestedUnlock()) return true; + + // Aktive temporäre Öffnung (Hygiene, Karte, Aufgabe) + if (lock.getTempOpeningTime() != null) return true; + + // Start-Fenster: Anwender hat beim Lock-Start den Code erhalten und + // hat das Schloss physisch noch nicht übergeben (Relock läuft gerade) + if (lock.getStartTime() != null + && ChronoUnit.MINUTES.between(lock.getStartTime(), LocalDateTime.now()) <= START_WINDOW_MINUTES) { + return true; + } + + return false; + } + + private void notifyKeyholder(BaseLockEntity lock) { + KeyholderNotificationEntity notification = new KeyholderNotificationEntity(); + notification.setLockId(lock.getLockId()); + notification.setLockeeId(lock.getLockee()); + notification.setKeyholderUserId(lock.getKeyholder()); + notification.setViolationTime(LocalDateTime.now()); + notification.setOvertimeMinutes(0); + notification.setNotifiedKeyholder(false); + notification.setOpeningReason(TempOpeningReason.TTLOCK_UNAUTHORIZED); + keyholderNotificationRepository.save(notification); + userRepository.findById(lock.getKeyholder()).ifPresent(kh -> + systemMessageService.send(lock.getLockee(), kh.getUserId(), + "Deine Lockee hat ihr Schloss unerlaubt geöffnet!", + "/games/chastity/keyholder.html?lockId=" + lock.getLockId(), + MessageCause.GAME_STATE)); + } + + private TTLockCallbackWrapper parse(Map params) { + ObjectMapper mapper = new ObjectMapper(); + TTLockCallbackWrapper wrapper = new TTLockCallbackWrapper(); + + try { + if (params.containsKey("lockId")) + wrapper.setLockId(Integer.parseInt(params.get("lockId"))); + if (params.containsKey("notifyType")) + wrapper.setNotifyType(Integer.parseInt(params.get("notifyType"))); + wrapper.setLockMac(params.get("lockMac")); + + String recordsJson = params.get("records"); + if (recordsJson != null && !recordsJson.isEmpty()) { + List recordList = mapper.readValue(recordsJson, new TypeReference>() { + }); + wrapper.setRecords(recordList); + } + } catch (Exception e) { + System.err.println("Fehler beim Parsen des TTLock Callbacks: " + e.getMessage()); + } + + return wrapper; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public class TTLockCallbackWrapper { + private Integer lockId; + private Integer notifyType; + private String lockMac; + private List records; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class LockRecord { + private Integer lockId; + private Integer electricQuantity; + private Long serverDate; + private Integer recordType; + private Integer success; + private String lockMac; + private String keyboardPwd; + private Long lockDate; + private String username; + + public LocalDateTime getLockDateTime() { + if (this.lockDate == null || this.lockDate == 0) return null; + return LocalDateTime.ofInstant( + Instant.ofEpochMilli(this.lockDate), + ZoneId.systemDefault() + ); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java new file mode 100644 index 0000000..d000d2b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigEntity.java @@ -0,0 +1,26 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "ttlock_config") +public class TTLockConfigEntity { + + /** Singleton-Zeile – immer ID 1 */ + @Id + @Column + private Long id = 1L; + + @Column(length = 100) + private String clientId; + + @Column(length = 100) + private String clientSecret; + + @Column(length = 200) + private String baseUrl; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java new file mode 100644 index 0000000..84e7f4f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockConfigRepository.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TTLockConfigRepository extends JpaRepository {} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java new file mode 100644 index 0000000..ecccc87 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockService.java @@ -0,0 +1,169 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import java.util.Collections; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Service +public class TTLockService { + + private final RestTemplate restTemplate; + + private static final String UNLOCK_CODE_NAME = "xxx-unlock-code"; + + public TTLockService() { + restTemplate = new RestTemplate(); + + } + + public TTLockDetailResponse getLockDetail(String clientId, String accessToken, int lockId) { + String url = UriComponentsBuilder.fromUriString("https://euapi.ttlock.com/v3/lock/detail") + .queryParam("clientId", clientId).queryParam("accessToken", accessToken).queryParam("lockId", lockId) + .queryParam("date", System.currentTimeMillis()).toUriString(); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + TTLockDetailResponse response = restTemplate.getForObject(url, TTLockDetailResponse.class); + System.out.println(response); + return response; + } catch (Exception e) { + System.err.println("Fehler beim Abrufen der Details: " + e.getMessage()); + return null; + } + } + + public Integer addCustomPasscode(String clientId, String accessToken, int lockId, String pin) { + + String url = "https://euapi.ttlock.com/v3/keyboardPwd/add"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("clientId", clientId); + map.add("accessToken", accessToken); + map.add("lockId", String.valueOf(lockId)); + map.add("keyboardPwd", pin); // Der 4-9 stellige PIN + map.add("keyboardPwdName", UNLOCK_CODE_NAME); + map.add("addType", "2"); + map.add("keyboardPwdType", "2"); + map.add("date", String.valueOf(System.currentTimeMillis())); + + HttpEntity> request = new HttpEntity<>(map, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(url, request, + TTLockAddPasscodeResponse.class); + if (response.getBody() != null && response.getBody().isSuccess()) { + return response.getBody().getKeyboardPwdId(); + } else { + System.out.println("Fehler von TTLock: " + response.getBody().getErrmsg()); + return null; + } + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public String deleteCustomPasscode(String clientId, String accessToken, int lockId, int keyboardPwdId) { + String url = "https://euapi.ttlock.com/v3/keyboardPwd/delete"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("clientId", clientId); + map.add("accessToken", accessToken); + map.add("lockId", String.valueOf(lockId)); + map.add("keyboardPwdId", String.valueOf(keyboardPwdId)); + map.add("deleteType", "2"); + map.add("date", String.valueOf(System.currentTimeMillis())); + + HttpEntity> request = new HttpEntity<>(map, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + return response.getBody(); + } catch (Exception e) { + return "{\"errcode\":-1, \"errmsg\":\"" + e.getMessage() + "\"}"; + } + } + + public void findAndDeleteLocksByName(String clientId, String accessToken, int lockId) { + ObjectMapper mapper = new ObjectMapper(); + + String listUrl = UriComponentsBuilder.fromUriString("https://euapi.ttlock.com/v3/lock/listKeyboardPwd") + .queryParam("clientId", clientId) + .queryParam("accessToken", accessToken) + .queryParam("lockId", lockId) + .queryParam("pageNo", 1) + .queryParam("pageSize", 100) + .queryParam("date", System.currentTimeMillis()).toUriString(); + + try { + String response = restTemplate.getForObject(listUrl, String.class); + JsonNode root = mapper.readTree(response); + JsonNode list = root.get("list"); + + if (list != null && list.isArray()) { + for (JsonNode unlockcode : list) { + String name = unlockcode.get("keyboardPwdName").asText(); + int passwordId = unlockcode.get("keyboardPwdId").asInt(); + + if (name.equalsIgnoreCase(UNLOCK_CODE_NAME)) { + deleteCustomPasscode(clientId, accessToken, lockId, passwordId); + } + } + } + } catch (Exception e) { + System.err.println("Fehler beim Massenlöschen: " + e.getMessage()); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + @Getter + @Setter + @Data + public static class TTLockDetailResponse { + private int errcode; + private String errmsg; + private String lockName; + private String lockAlias; + private int lockId; + private int electricQuantity; + private String modelNum; + private String featureValue; + private String adminPwd; + private String state; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TTLockAddPasscodeResponse { + private int errcode; + private String errmsg; + private Integer keyboardPwdId; // Die ID des neuen Pins in der Cloud + + public boolean isSuccess() { + return errcode == 0; + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java new file mode 100644 index 0000000..da137aa --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockTest.java @@ -0,0 +1,56 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.oaa.xxx.games.chastity.ttlock.TTLockService.TTLockDetailResponse; + +@RestController +@RequestMapping("/ttlock") +public class TTLockTest { + + private final TTAuthService auth; + private final TTLockService lock; + + private String clientId = "6e5077a84b6a4e1ba0fb6a8da21c6417"; + private String clientSecret = "a2c1d68c7905d52584fc29028937db11"; + private String username= "mario.stoermer@proton.me"; + private String password = "knall666.Halla"; + private int lockId = 30158446; + + public TTLockTest(TTAuthService auth, TTLockService lock) { + this.auth = auth; + this.lock = lock; + } + + @GetMapping("/details") + public ResponseEntity details() { + String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase(); + String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex); + return ResponseEntity.ok(lock.getLockDetail(clientId, token, lockId)); + } + + @GetMapping("/add/{pin}") + public ResponseEntity add(@PathVariable String pin) { + String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase(); + String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex); + return ResponseEntity.ok(lock.addCustomPasscode(clientId, token, lockId, pin)); + } + + @GetMapping("/delete/{id}") + public ResponseEntity remove(@PathVariable Integer id) { + String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase(); + String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex); + return ResponseEntity.ok(lock.deleteCustomPasscode(clientId, token, lockId, id)); + } + + @GetMapping("/delete/all") + public void removeAll() { + String md5Hex = org.apache.commons.codec.digest.DigestUtils.md5Hex(password).toLowerCase(); + String token = auth.getAccessToken(clientId, clientSecret, username, md5Hex); + lock.findAndDeleteLocksByName(clientId, token, lockId); + } +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java new file mode 100644 index 0000000..41b4841 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigEntity.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "ttlock_user_config") +public class TTLockUserConfigEntity { + + @Id + @Column + private UUID userId; + + @Column(length = 200) + private String username; + + /** MD5-Hex des TTLock-Passworts (so wie TTAuthService es erwartet) */ + @Column(length = 32) + private String passwordMd5; + + @Column + private Integer lockId; + + /** ID des aktuell gesetzten PINs auf dem Schloss – wird zum Löschen beim nächsten lock() benötigt */ + @Column + private Integer currentKeyboardPwdId; + + /** true, wenn der Anwender mindestens einmal erfolgreich die Verbindung getestet hat */ + @Column(nullable = false, columnDefinition = "boolean default false") + private boolean testSuccessful; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java new file mode 100644 index 0000000..5fcedb8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/TTLockUserConfigRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.games.chastity.ttlock; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TTLockUserConfigRepository extends JpaRepository { + + Optional findByLockId(Integer lockId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes b/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes new file mode 100644 index 0000000..0d5b861 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/ttlock/unlocktypes @@ -0,0 +1,121 @@ +1-unlock by app + +4-unlock by passcode + +5-Rise the lock (for parking lock) + +6-Lower the lock (for parking lock) + +7-unlock by IC card + +8-unlock by fingerprint + +9-unlock by wrist strap + +10-unlock by Mechanical key + +11-lock by app + +12-unlock by gateway + +29-apply some force on the Lock + +30-Door sensor closed + +31-Door sensor open + +32-open from inside + +33-lock by fingerprint + +34-lock by passcode + +35-lock by IC card + +36-lock by Mechanical key + +37-Use APP button to control the lock (rise, fall, stop, lock), mostly used for roller shutter door + +42-received new local mail + +43-received new other cities' mail + +44-Tamper alert + +45-Auto Lock + +46-unlock by unlock key + +47-lock by lock key + +48-System locked ( Caused by, for example: Using INVALID Passcode/Fingerprint/Card several times) + +49-unlock by hotel card + +50-Unlocked due to the high temperature + +51-Try to unlock with a deleted card + +52-Dead lock with APP + +53-Dead lock with passcode + +54-The car left (for parking lock) + +55-Use remote control lock or unlock lock + +57-Unlock with QR code success + +58-Unlock with QR code failed, it's expired + +59-Double locked + +60-Cancel double lock + +61-Lock with QR code success + +62-Lock with QR code failed, the lock is double locked + +63-Auto unlock at passage mode + +64-Door unclosed alarm + +65-Failed to unlock + +66-Failed to lock + +67-Face unlock success + +68-Face unlock failed - door locked from inside + +69-Lock with face + +71-Face unlock failed - expired or ineffective + +75-Unlocked by App granting + +76-Unlocked by remote granting + +77-Dual authentication Bluetooth unlock verification success, waiting for second user + +78-Dual authentication password unlock verification success, waiting for second user + +79-Dual authentication fingerprint unlock verification success, waiting for second user + +80-Dual authentication IC card unlock verification success, waiting for second user + +81-Dual authentication face card unlock verification success, waiting for second user + +82-Dual authentication wireless key unlock verification success, waiting for second user + +83-Dual authentication palm vein unlock verification success, waiting for second user + +84-Palm vein unlock success + +85-Palm vein unlock success + +86-Lock with palm vein + +88-Palm vein unlock failed - expired or ineffective + +92-Administrator password to unlock \ No newline at end of file diff --git a/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java b/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java new file mode 100644 index 0000000..f121f78 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/unlock/TempOpeningReason.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.chastity.unlock; + +public enum TempOpeningReason { + HYGIENE, CARD, TASK, TTLOCK_UNAUTHORIZED; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.java b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.java new file mode 100644 index 0000000..860315e --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryEntity.java @@ -0,0 +1,39 @@ +package de.oaa.xxx.games.chastity.unlock; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "unlock_code_history") +public class UnlockCodeHistoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Setter(lombok.AccessLevel.NONE) + private UUID id; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private UUID lockId; + + @Column + private String lockName; + + @Column(nullable = false) + private String unlockCode; + + /** GREEN_CARD | HYGIENE_OPEN | HYGIENE_CLOSE | KEYHOLDER_UNLOCK */ + @Column(nullable = false) + private String source; + + @Column(nullable = false) + private LocalDateTime receivedAt; +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.java b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.java new file mode 100644 index 0000000..4de4306 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryRepository.java @@ -0,0 +1,23 @@ +package de.oaa.xxx.games.chastity.unlock; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.UUID; + +public interface UnlockCodeHistoryRepository extends JpaRepository { + + List findByUserIdOrderByReceivedAtDesc(UUID userId, Pageable pageable); + + /** Prüft, ob für dieses Lock und diese Quelle bereits der gleiche Code gespeichert ist. */ + boolean existsByLockIdAndSourceAndUnlockCode(UUID lockId, String source, String unlockCode); + + /** Alle Einträge des Users aufsteigend (für Cleanup: älteste löschen). */ + @Query("SELECT e FROM UnlockCodeHistoryEntity e WHERE e.userId = :userId ORDER BY e.receivedAt ASC") + List findByUserIdOrderByReceivedAtAsc(@Param("userId") UUID userId); + + long countByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.java b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.java new file mode 100644 index 0000000..0de44df --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/chastity/unlock/UnlockCodeHistoryService.java @@ -0,0 +1,52 @@ +package de.oaa.xxx.games.chastity.unlock; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class UnlockCodeHistoryService { + + private static final Logger LOGGER = LoggerFactory.getLogger(UnlockCodeHistoryService.class); + + private final UnlockCodeHistoryRepository unlockCodeHistoryRepository; + + public UnlockCodeHistoryService(UnlockCodeHistoryRepository unlockCodeHistoryRepository) { + this.unlockCodeHistoryRepository = unlockCodeHistoryRepository; + } + + /** + * Speichert einen Entsperrcode-Eintrag in einer eigenen Transaktion, + * damit ein Fehler hier die aufrufende Transaktion nicht zurückrollt. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(UUID userId, UUID lockId, String lockName, String unlockCode, String source) { + if (unlockCode == null || unlockCode.isBlank()) return; + try { + if (unlockCodeHistoryRepository.existsByLockIdAndSourceAndUnlockCode(lockId, source, unlockCode)) return; + UnlockCodeHistoryEntity entry = new UnlockCodeHistoryEntity(); + entry.setUserId(userId); + entry.setLockId(lockId); + entry.setLockName(lockName != null && !lockName.isBlank() ? lockName : "Unbenanntes Lock"); + entry.setUnlockCode(unlockCode); + entry.setSource(source); + entry.setReceivedAt(LocalDateTime.now()); + unlockCodeHistoryRepository.save(entry); + // Nur die letzten 10 behalten + long count = unlockCodeHistoryRepository.countByUserId(userId); + if (count > 10) { + var oldest = unlockCodeHistoryRepository.findByUserIdOrderByReceivedAtAsc(userId); + for (int i = 0; i < count - 10; i++) { + unlockCodeHistoryRepository.delete(oldest.get(i)); + } + } + } catch (Exception e) { + LOGGER.warn("Entsperrcode-Historie konnte nicht gespeichert werden (lockId={}, source={}): {}", lockId, source, e.getMessage()); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Aufgabe.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Aufgabe.java new file mode 100644 index 0000000..3053753 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Aufgabe.java @@ -0,0 +1,52 @@ +package de.oaa.xxx.games.common.aufgaben; + +import java.util.List; +import java.util.UUID; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Aufgabe { + + private UUID aufgabeId; + private String kurzText; + private String text; + private Integer level; + private Integer sekundenVon; + private Integer sekundenBis; + private UUID gruppeId; + private List benoetigtAktiv; + private List benoetigtPassiv; + private List benoetigteToys; + + @Override + public String toString() { + return "Aufgabe[id=" + aufgabeId + ", kurzText=" + kurzText + ", level=" + level + + ", sekunden=" + sekundenVon + "-" + sekundenBis + ", gruppeId=" + gruppeId + "]"; + } + + public boolean isAufgabePassend(int level, CommonMitspieler aktiv, CommonMitspieler passiv) { + if (level != this.level && level - 1 != this.level) { + return false; + } + if (benoetigtPassiv != null) { + for (Werkzeug werkzeug : benoetigtPassiv) { + if (!passiv.isVerfuegbar(werkzeug)) { + return false; + } + } + } + if (benoetigtAktiv == null || benoetigtAktiv.isEmpty()) { + return true; + } else { + for (Werkzeug werkzeug : benoetigtAktiv) { + if (aktiv.isVerfuegbar(werkzeug)) { + return true; + } + } + return false; + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.java new file mode 100644 index 0000000..3062971 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppe.java @@ -0,0 +1,26 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +public class AufgabenGruppe { + + private UUID gruppenId; + private String name; + private String beschreibung; + private String von; + private UUID userId; + private boolean privateGruppe; + private List aufgaben; + private List strafen; + private List sperren; + private List finisher; + private String bild; + private long subscriberCount; + private boolean subscribed; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.java new file mode 100644 index 0000000..2512042 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeDisplay.java @@ -0,0 +1,19 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class AufgabenGruppeDisplay { + + private UUID gruppenId; + private String name; + private String beschreibung; + private UUID userId; + private boolean privateGruppe; + private String bild; + private String von; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.java new file mode 100644 index 0000000..a67fbdb --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeList.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class AufgabenGruppeList { + + private List gruppen; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.java new file mode 100644 index 0000000..103f032 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppePage.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class AufgabenGruppePage { + + private List content; + private int currentPage; + private int totalPages; + private long totalElements; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.java new file mode 100644 index 0000000..a4c6535 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenGruppeService.java @@ -0,0 +1,191 @@ +package de.oaa.xxx.games.common.aufgaben; + +import de.oaa.xxx.games.common.entity.AufgabeEntity; +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; +import de.oaa.xxx.games.common.entity.FinisherEntity; +import de.oaa.xxx.games.common.entity.SperreEntity; +import de.oaa.xxx.games.common.entity.StrafeEntity; +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.SperreRepository; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Service für komplexe AufgabenGruppen-Operationen. + * Kapselt die Kopier-Logik (Systemgruppe → eigene Gruppe) inkl. Toy-Mapping. + */ +@Service +public class AufgabenGruppeService { + + private static final Logger LOGGER = LoggerFactory.getLogger(AufgabenGruppeService.class); + + private final AufgabenGruppeRepository gruppeRepository; + private final AufgabeRepository aufgabeRepository; + private final StrafeRepository strafeRepository; + private final SperreRepository sperreRepository; + private final FinisherRepository finisherRepository; + private final ToyRepository toyRepository; + + public AufgabenGruppeService(AufgabenGruppeRepository gruppeRepository, + AufgabeRepository aufgabeRepository, + StrafeRepository strafeRepository, + SperreRepository sperreRepository, + FinisherRepository finisherRepository, + ToyRepository toyRepository) { + this.gruppeRepository = gruppeRepository; + this.aufgabeRepository = aufgabeRepository; + this.strafeRepository = strafeRepository; + this.sperreRepository = sperreRepository; + this.finisherRepository = finisherRepository; + this.toyRepository = toyRepository; + } + + /** + * Kopiert eine öffentliche (System-)Gruppe in die eigene Sammlung des Users. + * + * @param sourceId UUID der Quellgruppe + * @param userId UUID des Ziel-Users + * @return UUID der neu erstellten Gruppe + * @throws IllegalStateException wenn das Gruppen-Limit (10) erreicht ist + * @throws IllegalArgumentException wenn die Quellgruppe nicht gefunden oder nicht kopierbar ist + */ + @Transactional + public UUID copyGruppe(UUID sourceId, UUID userId) { + if (gruppeRepository.countByUserId(userId) >= 10) { + throw new IllegalStateException("Gruppen-Limit erreicht"); + } + + AufgabenGruppeEntity source = gruppeRepository.findById(sourceId) + .orElseThrow(() -> new IllegalArgumentException("Gruppe nicht gefunden: " + sourceId)); + if (source.isPrivateGruppe()) { + throw new IllegalArgumentException("Privat-Gruppen können nicht kopiert werden"); + } + if (userId.equals(source.getUserId())) { + throw new IllegalArgumentException("Eigene Gruppen können nicht kopiert werden"); + } + + // Toy-Mapping aufbauen: Source-ToyId → Ziel-ToyEntity + Set allSourceToys = new HashSet<>(); + source.getAufgaben().forEach(a -> { if (a.getBenoetigteToys() != null) allSourceToys.addAll(a.getBenoetigteToys()); }); + source.getStrafen().forEach(s -> { if (s.getBenoetigteToys() != null) allSourceToys.addAll(s.getBenoetigteToys()); }); + source.getSperren().forEach(sp -> { if (sp.getBenoetigteToys() != null) allSourceToys.addAll(sp.getBenoetigteToys()); }); + source.getFinisher().forEach(f -> { if (f.getBenoetigteToys() != null) allSourceToys.addAll(f.getBenoetigteToys()); }); + + Map toyMapping = new HashMap<>(); + for (ToyEntity sourceToy : allSourceToys) { + if (sourceToy.getUserId() == null) { + // System-Toy: direkt referenzieren + toyMapping.put(sourceToy.getToyId(), sourceToy); + } else { + // User-Toy: gleichnamiges Toy suchen oder kopieren + ToyEntity mapped = toyRepository.findByNameIgnoreCaseAndUserId(sourceToy.getName(), userId) + .orElseGet(() -> { + ToyEntity tc = new ToyEntity(); + tc.setToyId(UUID.randomUUID()); + tc.setName(sourceToy.getName()); + tc.setBeschreibung(sourceToy.getBeschreibung()); + tc.setBild(sourceToy.getBild()); + tc.setUserId(userId); + return toyRepository.save(tc); + }); + toyMapping.put(sourceToy.getToyId(), mapped); + } + } + + // Neue Gruppe anlegen + AufgabenGruppeEntity copy = new AufgabenGruppeEntity(); + copy.setGruppenId(UUID.randomUUID()); + copy.setName(source.getName()); + copy.setBeschreibung(source.getBeschreibung()); + copy.setVon(source.getVon()); + copy.setBild(source.getBild()); + copy.setUserId(userId); + copy.setPrivateGruppe(true); + gruppeRepository.save(copy); + + // Aufgaben kopieren + for (AufgabeEntity a : source.getAufgaben()) { + AufgabeEntity ac = new AufgabeEntity(); + ac.setAufgabeId(UUID.randomUUID()); + ac.setAufgabenGruppe(copy); + ac.setKurzText(a.getKurzText()); + ac.setText(a.getText()); + ac.setLevel(a.getLevel()); + ac.setSekundenVon(a.getSekundenVon()); + ac.setSekundenBis(a.getSekundenBis()); + ac.setBenoetigtAktiv(a.getBenoetigtAktiv() != null ? new ArrayList<>(a.getBenoetigtAktiv()) : null); + ac.setBenoetigtPassiv(a.getBenoetigtPassiv() != null ? new ArrayList<>(a.getBenoetigtPassiv()) : null); + ac.setBenoetigteToys(mapToys(a.getBenoetigteToys(), toyMapping)); + aufgabeRepository.save(ac); + } + + // Strafen kopieren + for (StrafeEntity s : source.getStrafen()) { + StrafeEntity sc = new StrafeEntity(); + sc.setStrafeId(UUID.randomUUID()); + sc.setAufgabenGruppe(copy); + sc.setKurzText(s.getKurzText()); + sc.setText(s.getText()); + sc.setLevel(s.getLevel()); + sc.setSekundenVon(s.getSekundenVon()); + sc.setSekundenBis(s.getSekundenBis()); + sc.setBenoetigtAktiv(s.getBenoetigtAktiv() != null ? new ArrayList<>(s.getBenoetigtAktiv()) : null); + sc.setBenoetigtPassiv(s.getBenoetigtPassiv() != null ? new ArrayList<>(s.getBenoetigtPassiv()) : null); + sc.setBenoetigteToys(mapToys(s.getBenoetigteToys(), toyMapping)); + strafeRepository.save(sc); + } + + // Sperren kopieren + for (SperreEntity sp : source.getSperren()) { + SperreEntity spc = new SperreEntity(); + spc.setSperreId(UUID.randomUUID()); + spc.setAufgabenGruppe(copy); + spc.setKurzText(sp.getKurzText()); + spc.setText(sp.getText()); + spc.setReleaseText(sp.getReleaseText()); + spc.setMinutenVon(sp.getMinutenVon()); + spc.setMinutenBis(sp.getMinutenBis()); + spc.setSperreFuer(sp.getSperreFuer() != null ? new ArrayList<>(sp.getSperreFuer()) : null); + spc.setBenoetigteToys(mapToys(sp.getBenoetigteToys(), toyMapping)); + sperreRepository.save(spc); + } + + // Finisher kopieren + for (FinisherEntity f : source.getFinisher()) { + FinisherEntity fc = new FinisherEntity(); + fc.setFinisherId(UUID.randomUUID()); + fc.setAufgabenGruppe(copy); + fc.setKurzText(f.getKurzText()); + fc.setText(f.getText()); + fc.setGeschlecht(f.getGeschlecht()); + fc.setBenoetigtAktiv(f.getBenoetigtAktiv() != null ? new ArrayList<>(f.getBenoetigtAktiv()) : null); + fc.setBenoetigtPassiv(f.getBenoetigtPassiv() != null ? new ArrayList<>(f.getBenoetigtPassiv()) : null); + fc.setBenoetigteToys(mapToys(f.getBenoetigteToys(), toyMapping)); + finisherRepository.save(fc); + } + + LOGGER.info("User {} hat AufgabenGruppe {} kopiert (Quelle: {})", userId, copy.getGruppenId(), sourceId); + return copy.getGruppenId(); + } + + private List mapToys(List source, Map mapping) { + if (source == null || source.isEmpty()) return new ArrayList<>(); + return source.stream().map(t -> mapping.getOrDefault(t.getToyId(), t)).toList(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenList.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenList.java new file mode 100644 index 0000000..05d163d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/AufgabenList.java @@ -0,0 +1,25 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class AufgabenList { + + private List aufgaben; + private List sperren; + private List strafen; + private List finisher; + + public int size() { + int size = 0; + if (aufgaben != null) size += aufgaben.size(); + if (sperren != null) size += sperren.size(); + if (strafen != null) size += strafen.size(); + if (getFinisher() != null) size += getFinisher().size(); + return size; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.java new file mode 100644 index 0000000..f8ecd47 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/CommonMitspieler.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.games.common.aufgaben; + +public interface CommonMitspieler { + + boolean isVerfuegbar(Werkzeug werkzeug); +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/DefaultFiller.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/DefaultFiller.java new file mode 100644 index 0000000..b78ce26 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/DefaultFiller.java @@ -0,0 +1,379 @@ +package de.oaa.xxx.games.common.aufgaben; + +import de.oaa.xxx.games.common.entity.AufgabeEntity; +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; +import de.oaa.xxx.games.common.entity.SperreEntity; +import de.oaa.xxx.games.common.entity.StrafeEntity; +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.SperreRepository; +import de.oaa.xxx.games.common.repository.StrafeRepository; +import de.oaa.xxx.games.common.repository.ToyRepository; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import static de.oaa.xxx.games.common.aufgaben.Werkzeug.ANUS; +import static de.oaa.xxx.games.common.aufgaben.Werkzeug.MUND; +import static de.oaa.xxx.games.common.aufgaben.Werkzeug.PENIS; +import static de.oaa.xxx.games.common.aufgaben.Werkzeug.UMSCHNALLDILDO; +import static de.oaa.xxx.games.common.aufgaben.Werkzeug.VAGINA; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@Component +public class DefaultFiller { + + private final AufgabeRepository aufgabeRepository; + private final AufgabenGruppeRepository gruppeRepository; + private final SperreRepository sperreRepository; + private final StrafeRepository strafeRepository; + private final ToyRepository toyRepository; + + public DefaultFiller(AufgabeRepository aufgabeRepository, AufgabenGruppeRepository gruppeRepository, + SperreRepository sperreRepository, StrafeRepository strafeRepository, + ToyRepository toyRepository) { + this.aufgabeRepository = aufgabeRepository; + this.gruppeRepository = gruppeRepository; + this.sperreRepository = sperreRepository; + this.strafeRepository = strafeRepository; + this.toyRepository = toyRepository; + } + + @Transactional + public void fill() { + chastityFemale(); + chastityMale(); + plugs(); + knebel(); + stafen(); + aufgaben(); + } + + void chastityFemale() { + AufgabenGruppeEntity keuschWiebl = createAufgGruppe("Keuschhaltung weiblich", "Enthält verschiedene Aufgaben für Keuschhaltung von weiblichen Spielpartnern", getClass().getClassLoader().getResourceAsStream("femaleCB.png")); + ToyEntity kg = createToy("KG weiblich", "Ein Voll-Keuschheitsgürtel für die Frau"); + ToyEntity kgVaginal = createToy("KG weiblich, Vaginaldildo", "Ein Voll-Keuschheitsgürtel für die Frau inkl. eines Vaginaldildos"); + ToyEntity kgAnal = createToy("KG weiblich, Analdildo", "Ein Voll-Keuschheitsgürtel für die Frau inkl. eines Analdildos"); + ToyEntity kgDouble = createToy("KG weiblich, Vaginal- u. Analdildo", "Ein Voll-Keuschheitsgürtel für die Frau inkl. eines Vaginal- und Analdildos"); + + createSperre("Voll-KG", "{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien", 10, 30, Arrays.asList(kg), Arrays.asList(VAGINA), keuschWiebl); + createSperre("Voll-KG + Vaginaldildo", "{PASSIV} trägt fortan einen Voll-KG mit Vaginaldildo, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien", 10, 30, Arrays.asList(kgVaginal), Arrays.asList(VAGINA), keuschWiebl); + createSperre("Voll-KG + Analdildo", "{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien", 10, 30, Arrays.asList(kgAnal), Arrays.asList(VAGINA, ANUS), keuschWiebl); + createSperre("Voll-KG + Doubleplugged", "{PASSIV} trägt fortan einen Voll-KG mit Vaginal- und Analdildo, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien", 10, 30, Arrays.asList(kgDouble), Arrays.asList(VAGINA, ANUS), keuschWiebl); + } + + void chastityMale() { + AufgabenGruppeEntity keuschMaennl = createAufgGruppe("Keuschhaltung männlich", "Enthält verschiedene Aufgaben für Keuschhaltung von männlichen Spielpartnern", getClass().getClassLoader().getResourceAsStream("maleCB.png")); + ToyEntity kaefig = createToy("Peniskäfig", "Ein gewöhnlicher Peniskäfig"); + ToyEntity kgMaennl = createToy("KG männlich", "Ein Voll-Keuschheitsgürtel für den Mann"); + ToyEntity knMaennlAnal = createToy("KG männlich, Analdildo", "Ein Voll-Keuschheitsgürtel für den Mann inkl. eines Analdildos oder -plugs"); + + createSperre("Peniskäfig", "{PASSIV} trägt fortan einen Peniskäfig, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von seinem Peniskäfig zu befreien", 10, 30, Arrays.asList(kaefig), Arrays.asList(PENIS), keuschMaennl); + createSperre("Voll-KG", "{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien", 10, 30, Arrays.asList(kgMaennl), Arrays.asList(PENIS), keuschMaennl); + createSperre("Voll-KG + Analdildo", "{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder", "{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien", 10, 30, Arrays.asList(knMaennlAnal), Arrays.asList(PENIS, ANUS), keuschMaennl); + } + + void plugs() { + AufgabenGruppeEntity gruppe = createAufgGruppe("Plugs", "Enthält verschiedene Aufgaben für das Tragen von Buttplugs über einen gewissen Zeitraum.", getClass().getClassLoader().getResourceAsStream("plugs.png")); + ToyEntity plugKlein = createToy("Plug klein", "Ein kleiner Buttplug"); + ToyEntity plugMittel = createToy("Plug mittel", "Ein mittelgroßer Buttplug"); + ToyEntity plugGross = createToy("Plug groß", "Ein großer Buttplug"); + ToyEntity plugElektro = createToy("Elektro-Plug", "Ein Elektroplug, der Stromstöße verpasst"); + + createSperre("Plug klein", "{AKTIV} führt {PASSIV} einen kleinen Buttplug in anal ein, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien", 10, 30, Arrays.asList(plugKlein), Arrays.asList(ANUS), gruppe); + createSperre("Plug mittel", "{AKTIV} führt {PASSIV} einen mittelgroßen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien", 10, 30, Arrays.asList(plugMittel), Arrays.asList(ANUS), gruppe); + createSperre("Plug groß", "{AKTIV} führt {PASSIV} einen großen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien", 10, 30, Arrays.asList(plugGross), Arrays.asList(ANUS), gruppe); + createSperre("Elektro-Plug anal", "{AKTIV} führt {PASSIV} einen Elekro-Plug anal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen", "{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien", 10, 30, Arrays.asList(plugElektro), Arrays.asList(ANUS), gruppe); + createSperre("Elektro-Plug vaginal", "{AKTIV} führt {PASSIV} einen Elekto-Plug vaginal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen", "{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien", 10, 30, Arrays.asList(plugElektro), Arrays.asList(VAGINA), gruppe); + } + + void knebel() { + AufgabenGruppeEntity gruppe = createAufgGruppe("Knebel", "Enthält verschiedene Aufgaben für das Tragen von Knebeln über einen gewissen Zeitraum.", getClass().getClassLoader().getResourceAsStream("knebel.png")); + ToyEntity ballKnebel = createToy("Ballknebel", "Ein Ballknebel"); + ToyEntity penisKnebel = createToy("Penisknebel", "Ein Penisknebel"); + ToyEntity aufblKnebel = createToy("Aufblasbarer Knebel", "Ein aufblasbarer Knebel"); + ToyEntity isolationsmaske = createToy("Isolationsmaske", "Eine Isolationsmaske"); + + createSperre("Ballknebel", "{AKTIV}, lege {PASSIV} einen Ballknebel an, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.", 10, 30, Arrays.asList(ballKnebel), Arrays.asList(MUND), gruppe); + createSperre("Penisknebel", "{AKTIV}, lege {PASSIV} einen Dildoknebel an, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.", 10, 30, Arrays.asList(penisKnebel), Arrays.asList(MUND), gruppe); + createSperre("Aufblasbarer Knebel", "{AKTIV}, lege {PASSIV} einen aufblasbaren Knebel an und pumpe diesen soweit auf, dass {PASSIV} noch halbwegs gut atmen kann, dieser ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.", 5, 15, Arrays.asList(aufblKnebel), Arrays.asList(MUND), gruppe); + createSperre("Isolationsmaske", "{AKTIV}, lege {PASSIV} eine Isolationsmaske an, diese ist bis auf weiteres zu tragen.", "{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.", 5, 15, Arrays.asList(isolationsmaske), Arrays.asList(MUND), gruppe); + } + + void stafen() { + AufgabenGruppeEntity strafen = createAufgGruppe("Strafen", "Enthält verschiedene Bestrafungen", getClass().getClassLoader().getResourceAsStream("peitsche.png")); + + ToyEntity gerte = createToy("Gerte", "Eine gewöhnliche Gerte"); + ToyEntity paddel = createToy("Paddel", "Eine gewöhnliches Paddel"); + ToyEntity peitsche = createToy("Peitsche", "Eine gewöhnliche Peitsche"); + ToyEntity penisKnebel = createToy("Doppel-Penisknebel", "Ein Doppel-Penisknebel"); + ToyEntity handfesseln = createToy("Handfesseln", "Fesseln zum Binden der Hände, z.B. Handschellen"); + ToyEntity plugGross = createToy("Plug groß", "Ein großer Buttplug"); + ToyEntity plugElektro = createToy("Elektro-Plug", "Ein Elektroplug, der Stromstöße verpasst"); + ToyEntity plugPump = createToy("Pump-Plug", "Ein aufblasbarer Plug"); + ToyEntity nippelklemmen = createToy("Nippelklemmen", "Nippelklemmen"); + ToyEntity augenbinde = createToy("Augenbinde", "Eine Augenbinde"); + ToyEntity ballKnebel = createToy("Ballknebel", "Ein Ballknebel"); + ToyEntity strapon = createToy("Strapon", "Ein Umschnalldildo"); + ToyEntity kgMann = createToy("KG Mann", "Ein Voll-KG oder Peniskäfig für den Mann"); + ToyEntity kgFrau = createToy("KG Frau", "Ein Voll-KG die Frau"); + ToyEntity dildoKlein = createToy("Dildo klein", "Ein kleiner Dildo"); + ToyEntity dildoGross = createToy("Dildo groß", "Ein großer Dildo"); + + createStrafe("5 Schläge mit flachen Hand", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der flachen Hand auf das Gesäß.", + 1, null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), strafen); + createStrafe("15 Schläge mit flachen Hand", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der flachen Hand auf das Gesäß, {PASSIV} zählt laut mit", + 3, null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), strafen); + createStrafe("5 Schläge mit Gerte", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf das Gesäß.", + 2, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(gerte), strafen); + createStrafe("15 Schläge mit Gerte", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Gerte auf das Gesäß, {PASSIV} zählt laut mit", + 4, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(gerte), strafen); + createStrafe("5 Schläge mit Paddel", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf das Gesäß.", + 2, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(paddel), strafen); + createStrafe("15 Schläge mit Paddel", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit dem Paddel auf das Gesäß, {PASSIV} zählt laut mit", + 4, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(paddel), strafen); + createStrafe("5 Schläge mit Peitsche", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf das Gesäß.", + 3, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(peitsche), strafen); + createStrafe("15 Schläge mit Peitsche", "{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Peitsche auf das Gesäß, {PASSIV} zählt laut mit", + 5, null, null, Collections.emptyList(), Collections.emptyList(), Arrays.asList(peitsche), strafen); + createStrafe("Schläge auf Klitoris mit Hand", "{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Hand auf die Klitoris, {PASSIV} zählt laut mit", + 4, null, null, Collections.emptyList(), Arrays.asList(VAGINA), Collections.emptyList(), strafen); + createStrafe("Schläge auf Klitoris mit Peitsche", "{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf die Klitoris, {PASSIV} zählt laut mit", + 5, null, null, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(peitsche), strafen); + createStrafe("Schläge auf Klitoris mit Paddel", "{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf die Klitoris, {PASSIV} zählt laut mit", + 5, null, null, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(paddel), strafen); + createStrafe("Schläge auf Klitoris mit Gerte", "{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf die Klitoris, {PASSIV} zählt laut mit", + 5, null, null, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(gerte), strafen); + createStrafe("5 Ohrfeigen", "{PASSIV} stellt sich mit dem Rücken zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Ohrfeigen, {PASSIV} zählt laut mit", + 5, null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), strafen); + createStrafe("Elektroplug anal", "{AKTIV} führt {PASSIV} anal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} 'STOP' sagt, dann fängt {AKTIV} wieder bei null an", + 5, 30, 90, Collections.emptyList(), Arrays.asList(ANUS), Arrays.asList(plugElektro), strafen); + createStrafe("Elektroplug vaginal", "{AKTIV} führt {PASSIV} vaginal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} 'STOP' sagt, dann fängt {AKTIV} wieder bei null an", + 5, 30, 90, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(plugElektro), strafen); + createStrafe("Pumpplug anal", "{AKTIV} führt {PASSIV} anal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} 'STOP' sagt, dann fängt {AKTIV} wieder bei null an", + 5, 30, 90, Collections.emptyList(), Arrays.asList(ANUS), Arrays.asList(plugPump), strafen); + createStrafe("Pumpplug vaginal", "{AKTIV} führt {PASSIV} vaginal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} 'STOP' sagt, dann fängt {AKTIV} wieder bei null an", + 5, 30, 90, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(plugPump), strafen); + createStrafe("Facesitting (Vagina)", "{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen", + 2, 90, 180, Arrays.asList(VAGINA, ANUS), Arrays.asList(MUND), Collections.emptyList(), strafen); + createStrafe("Facesitting gefesselt (Vagina)", "{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen", + 4, 90, 180, Arrays.asList(VAGINA, ANUS), Arrays.asList(MUND), Arrays.asList(handfesseln), strafen); + createStrafe("Facesitting (Penis)", "{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen", + 2, 90, 180, Arrays.asList(PENIS, ANUS), Arrays.asList(MUND), Collections.emptyList(), strafen); + createStrafe("Facesitting gefesselt (Penis)", "{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen", + 4, 90, 180, Arrays.asList(VAGINA, PENIS), Arrays.asList(MUND), Arrays.asList(handfesseln), strafen); + createStrafe("Facesitting Doppelpenisknebel", "{PASSIV} liegt auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal", + 3, 60, 120, Arrays.asList(VAGINA), Arrays.asList(MUND), Arrays.asList(penisKnebel), strafen); + createStrafe("Facesitting Doppelpenisknebel gefesselt", "{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal", + 3, 60, 120, Arrays.asList(VAGINA), Arrays.asList(MUND), Arrays.asList(penisKnebel, handfesseln), strafen); + createStrafe("Nippelklemmen", "{AKTIV} legt {PASSIV} Nippelklemmen an, {AKTIV} zieht an der Kette und erhöht ganz langsam die Intensität bis {PASSIV} 'STOP' sagt, dann fängt {AKTIV} wieder bei null an", + 3, 30, 90, Collections.emptyList(), Collections.emptyList(), Arrays.asList(nippelklemmen), strafen); + createStrafe("Nippelbehandlung", "{AKTIV} nimmt die Nippel von {PASSIV} zwischen die Finger und erhöht langsam den Druck bis {PASSIV} 'STOP' sagt", + 2, null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), strafen); + createStrafe("Hilflos liegen lassen", "{AKTIV} fesselt, knebelt und verbindet die Augen von {PASSIV}. {AKTIV} lässt {PASSIV} wehrlos liegen, bei Ablauf der Zeit erlöst {AKTIV} {PASSIV} mit einem beherzten Platsch auf den Po", + 4, 300, 600, Collections.emptyList(), Collections.emptyList(), Arrays.asList(handfesseln, ballKnebel, augenbinde), strafen); + createStrafe("Strapon reiten", "{PASSIV} liegt auf dem Rücken und trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}", + 3, 60, 180, Arrays.asList(VAGINA, ANUS), Collections.emptyList(), Arrays.asList(strapon), strafen); + createStrafe("Strapon reiten gefesselt", "{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}", + 4, 60, 180, Arrays.asList(VAGINA, ANUS), Collections.emptyList(), Arrays.asList(strapon, handfesseln), strafen); + createStrafe("Teaseblowjob mit dem Strapon", "{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} gibt dem Umschnalldildo einen Blowjob in 69er Position und präsentiert {PASSIV} dabei den Intimbereich", + 5, 180, 300, Arrays.asList(VAGINA), Collections.emptyList(), Arrays.asList(kgMann, plugGross, handfesseln, strapon), strafen); + createStrafe("Teasereiten mit Strapon", "{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} reitet den Umschnalldildo von {PASSIV}.", + 5, 180, 300, Arrays.asList(VAGINA), Collections.emptyList(), Arrays.asList(kgMann, plugGross, handfesseln, strapon), strafen); + createStrafe("Tease mit Selbstbefriedigung (Mann KG)", "{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber", + 4, 240, 360, Arrays.asList(VAGINA), Collections.emptyList(), Arrays.asList(kgMann, plugGross, ballKnebel, handfesseln), strafen); + createStrafe("Tease mit Selbstbefriedigung (Frau KG)", "{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber", + 4, 240, 360, Arrays.asList(PENIS), Collections.emptyList(), Arrays.asList(kgFrau, plugGross, ballKnebel, handfesseln), strafen); + createStrafe("Blowjob auf allen vieren", "{AKTIV}, zwinge {PASSIV} vor dir auf die Knie, führe dein Glied (oder Strap on) in den Mund von {PASSIV} ein und zeig mit einem Deepthroat, wer das sagen hat", + 5, 30, 90, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(MUND), Collections.emptyList(), strafen); + createStrafe("Oralsex mit kleinem Dildo in der Vagina", "{PASSIV}, geh auf die Knie und reite vaginal einen kleinen Dildo, befriedige dabei {AKTIV} oral.", + 2, 60, 120, Arrays.asList(VAGINA, PENIS), Arrays.asList(VAGINA), Arrays.asList(dildoKlein), strafen); + createStrafe("Oralsex mit großen Dildo in der Vagina", "{PASSIV}, geh auf die Knie und reite vaginal einen großen Dildo, befriedige dabei {AKTIV} oral.", + 4, 60, 120, Arrays.asList(VAGINA, PENIS), Arrays.asList(VAGINA), Arrays.asList(dildoGross), strafen); + createStrafe("Oralsex mit kleinem Dildo im Anus", "{PASSIV}, geh auf die Knie und reite anal einen kleinen Dildo, befriedige dabei {AKTIV} oral.", + 3, 60, 120, Arrays.asList(VAGINA, PENIS), Arrays.asList(ANUS), Arrays.asList(dildoKlein), strafen); + createStrafe("Oralsex mit großen Dildo im Anus", "{PASSIV}, geh auf die Knie und reite anal einen großen Dildo, befriedige dabei {AKTIV} oral.", + 4, 60, 120, Arrays.asList(VAGINA, PENIS), Arrays.asList(ANUS), Arrays.asList(dildoGross), strafen); + createStrafe("Vagina dehnen", "{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Vagina von {PASSIV} ein, bis {PASSIV} 'STOP' sagt", + 2, null, null, Collections.emptyList(), Arrays.asList(VAGINA), Collections.emptyList(), strafen); + createStrafe("Anus dehnen", "{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Anus von {PASSIV} ein, bis {PASSIV} 'STOP' sagt", + 2, null, null, Collections.emptyList(), Arrays.asList(ANUS), Collections.emptyList(), strafen); + createStrafe("Vaginalsex in Missionarstellung und Breathplay", "{AKTIV} dringt in Missionarsstellung in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu", + 4, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), strafen); + createStrafe("Analsex in Missionarstellung und Breathplay", "{AKTIV} dringt in Missionarsstellung anal in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu", + 4, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(ANUS), Collections.emptyList(), strafen); + } + + void aufgaben() { + AufgabenGruppeEntity aufgaben = createAufgGruppe("Aufgaben", "Enthält verschiedene Sex-Aufgaben.", getClass().getClassLoader().getResourceAsStream("sex.png")); + + ToyEntity vibrator = createToy("Vibrator", "Ein herkömmlicher Vibrator."); + ToyEntity dildoKlein = createToy("Dildo klein", "Ein kleiner Dildo"); + ToyEntity dildoGross = createToy("Dildo groß", "Ein großer Dildo"); + + createAufgabe("Hintern präsentieren", "{AKTIV}, zeig {PASSIV} deinen Hintern, gib dir selber dabei ein oder zwei Klappse auf den Po", + 1, null, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Hals küssen", "{AKTIV}, küsse den Hals von {PASSIV} leidenschaftlich", + 1, 30, 60, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Bauchnabel küssen", "{AKTIV}, zeichne mit Küssen den Bauchnabel von {PASSIV} nach", + 1, 30, 60, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Ohren knabbern", "{AKTIV}, knabber leidenschaftlich an den Ohrläppchen von {PASSIV}", + 1, 30, 60, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Berühren ohne anfassen", "{AKTIV}, berühre den gesamten Körper von {PASSIV} ohne die Hände zu verwenden", + 2, 60, 120, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Nacken küssen", "{PASSIV} sitzt vor {AKTIV}, {AKTIV} küsste leidenschaftlich den Nacken von {PASSIV}", + 1, 60, 120, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Brust küssen", "{AKTIV}, küsse die Brust von {PASSIV} ohne die Nippel zu berühren", + 1, 60, 120, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Nippel verwöhnen", "{AKTIV}, verwöhne die Nippel von {PASSIV} mit Küssen", + 2, 60, 120, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Hintern küssen", "{AKTIV}, küsse den Hintern von {PASSIV} ohne den Anus zu berühren", + 1, 60, 120, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Intimkuss durch Unterwäsche", "{AKTIV}, küsse den Intimbereich von {PASSIV} durch die Unterwäsche", + 2, 60, 120, Arrays.asList(MUND), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Brustmassage", "{AKTIV}, massiere die Brust von {PASSIV} leidenschaftlich", + 1, 60, 120, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Hinternmassage", "{AKTIV}, massiere den Hintern von {PASSIV} leidenschaftlich", + 1, 60, 120, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Rückenmassage", "{AKTIV}, massiere den Rücken von {PASSIV} leidenschaftlich", + 1, 60, 120, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Oberschenkelmassage", "{AKTIV}, massiere die Oberschenkel von {PASSIV} leidenschaftlich", + 1, 60, 120, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), aufgaben); + createAufgabe("Klitoris mit Vibrator verwöhnen", "{AKTIV}, verwöhne die Klitoris von {PASSIV} mit einem Vibrator", + 3, 30, 180, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(vibrator), aufgaben); + createAufgabe("Cunnilingus und Finger in Vagina", "{AKTIV}, verwöhne die Klitoris von {PASSIV} mit dem Mund, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein", + 3, 30, 180, Arrays.asList(MUND), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Klitoris mit Fingern verwöhnen und Finger in Vagina", "{AKTIV}, verwöhne die Klitoris von {PASSIV} mit der Hand, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein", + 4, 30, 180, Collections.emptyList(), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Eichel mit Vibrator verwöhnen", "{AKTIV}, verwöhne die Eichel von {PASSIV} mit einem Vibrator", + 3, 30, 180, Collections.emptyList(), Arrays.asList(PENIS), Arrays.asList(vibrator), aufgaben); + createAufgabe("Felatio", "{AKTIV}, verwöhne die Eichel von {PASSIV} mit dem Mund", + 3, 30, 180, Arrays.asList(MUND), Arrays.asList(PENIS), Collections.emptyList(), aufgaben); + createAufgabe("Handjob", "{AKTIV}, verwöhne die Eichel von {PASSIV} mit der Hand", + 3, 30, 180, Collections.emptyList(), Arrays.asList(PENIS), Collections.emptyList(), aufgaben); + createAufgabe("Facesitting", "{AKTIV} liegt auf dem Rücken, {PASSIV} sitzt auf seinem Gesicht. {AKTIV}, verwöhne die Vagina von {PASSIV} mit dem Mund", + 4, 60, 180, Arrays.asList(MUND), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("69er-Position", "69er-Zeit: {AKTIV} liegt oben. {PASSIV}, falls du verschlossen bist, ziehe einen Strap on an, damit {AKTIV} auch was zu tun hat.", + 4, 60, 180, Arrays.asList(VAGINA, MUND), Arrays.asList(MUND), Collections.emptyList(), aufgaben); + createAufgabe("Kleiner Dildo vaginal", "{AKTIV}, führe {PASSIV} einen kleinen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem", + 3, 30, 180, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(dildoKlein), aufgaben); + createAufgabe("Großer Dildo vaginal", "{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem", + 4, 30, 180, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(dildoGross), aufgaben); + createAufgabe("Großer Dildo vaginal schnell", "{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und bewege selbigen möglichst schnell rein und raus", + 5, 30, 60, Collections.emptyList(), Arrays.asList(VAGINA), Arrays.asList(dildoGross), aufgaben); + createAufgabe("Missionarstellung langsam", "{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen", + 3, 60, 180, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Missionarstellung schnell", "{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen", + 4, 30, 90, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Missionarstellung Vollgas", "{AKTIV} dringt in Missionarstellung in {PASSIV} ein und gibt vollgas", + 5, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Reiterstellung langsam", "{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} bestimmt das Tempo", + 3, 60, 180, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Reiterstellung schnell", "{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} versucht das Tempo hoch zu halten", + 4, 60, 120, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Reiterstellung vollgas", "{PASSIV} setzt sich in Reiterstellung auf {AKTIV} und gibt vollgas", + 5, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Doggystyle langsam", "{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen", + 3, 60, 180, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Doggystyle schnell", "{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen", + 4, 60, 120, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Doggystyle vollgas", "{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas", + 5, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Doggystyle vollgas keinen Mucks", "{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas. {PASSIV} darf dabei keinen Laut von sich geben.", + 5, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Doggystyle Tempo bestimmt die 'gefickte' Person", "{AKTIV} dringt in Hundestellung in {PASSIV} ein. {AKTIV} hält still und {PASSIV} gibt das Tempo vor", + 3, 60, 180, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Löffelchen langsam", "{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen", + 3, 60, 180, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Löffelchen schnell", "{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen", + 4, 60, 120, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + createAufgabe("Löffelchen vollgas", "{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und gibt vollgas", + 5, 30, 60, Arrays.asList(PENIS, UMSCHNALLDILDO), Arrays.asList(VAGINA), Collections.emptyList(), aufgaben); + } + + private AufgabeEntity createAufgabe(String kurzText, String text, Integer level, Integer sekundenVon, Integer sekundenBis, + List benoetigtAktiv, List benoetigtPassiv, List benoetigteToys, + AufgabenGruppeEntity gruppe) { + AufgabeEntity entity = new AufgabeEntity(); + entity.setAufgabeId(UUID.randomUUID()); + entity.setKurzText(kurzText); + entity.setText(text); + entity.setLevel(level); + entity.setSekundenVon(sekundenVon); + entity.setSekundenBis(sekundenBis); + entity.setBenoetigtAktiv(benoetigtAktiv); + entity.setBenoetigtPassiv(benoetigtPassiv); + entity.setBenoetigteToys(benoetigteToys); + entity.setAufgabenGruppe(gruppe); + aufgabeRepository.save(entity); + return entity; + } + + private StrafeEntity createStrafe(String kurzText, String text, Integer level, Integer sekundenVon, Integer sekundenBis, + List benoetigtAktiv, List benoetigtPassiv, List benoetigteToys, + AufgabenGruppeEntity gruppe) { + StrafeEntity entity = new StrafeEntity(); + entity.setStrafeId(UUID.randomUUID()); + entity.setKurzText(kurzText); + entity.setText(text); + entity.setLevel(level); + entity.setSekundenVon(sekundenVon); + entity.setSekundenBis(sekundenBis); + entity.setBenoetigtAktiv(benoetigtAktiv); + entity.setBenoetigtPassiv(benoetigtPassiv); + entity.setBenoetigteToys(benoetigteToys); + entity.setAufgabenGruppe(gruppe); + strafeRepository.save(entity); + return entity; + } + + private SperreEntity createSperre(String kurzText, String text, String releaseText, Integer von, Integer bis, + List toys, List sperreFuer, AufgabenGruppeEntity gruppe) { + SperreEntity entity = new SperreEntity(); + entity.setSperreId(UUID.randomUUID()); + entity.setKurzText(kurzText); + entity.setText(text); + entity.setReleaseText(releaseText); + entity.setMinutenVon(von); + entity.setMinutenBis(bis); + entity.setBenoetigteToys(toys); + entity.setSperreFuer(sperreFuer); + entity.setAufgabenGruppe(gruppe); + sperreRepository.save(entity); + return entity; + } + + private AufgabenGruppeEntity createAufgGruppe(String name, String beschreibung, InputStream stream) { + AufgabenGruppeEntity entity = new AufgabenGruppeEntity(); + entity.setGruppenId(UUID.randomUUID()); + entity.setUserId(null); + entity.setName(name); + entity.setBeschreibung(beschreibung); + entity.setPrivateGruppe(false); + try { + if (stream != null) { + entity.setBild(stream.readAllBytes()); + } + } catch (IOException e) { + e.printStackTrace(); + } + gruppeRepository.save(entity); + return entity; + } + + private ToyEntity createToy(String name, String beschreibung) { + ToyEntity toy = new ToyEntity(); + toy.setToyId(UUID.randomUUID()); + toy.setName(name); + toy.setBeschreibung(beschreibung); + toyRepository.save(toy); + return toy; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Favorit.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Favorit.java new file mode 100644 index 0000000..891deb3 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Favorit.java @@ -0,0 +1,15 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class Favorit { + + private UUID favoritId; + private UUID userId; + private UUID aufgabenGruppeId; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/FavoritList.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/FavoritList.java new file mode 100644 index 0000000..0e66478 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/FavoritList.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class FavoritList { + + private List favoriten; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Finisher.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Finisher.java new file mode 100644 index 0000000..1a47182 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Finisher.java @@ -0,0 +1,47 @@ +package de.oaa.xxx.games.common.aufgaben; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.bdsm.GeschlechtEnum; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Finisher { + + private UUID finisherId; + private String kurzText; + private String text; + private GeschlechtEnum geschlecht; + private List benoetigtAktiv; + private List benoetigtPassiv; + private List benoetigteToys; + private UUID gruppeId; + + @Override + public String toString() { + return "Finisher[id=" + finisherId + ", kurzText=" + kurzText + ", geschlecht=" + geschlecht + ", gruppeId=" + gruppeId + "]"; + } + + public boolean isAufgabePassend(CommonMitspieler aktiv, CommonMitspieler passiv) { + if (benoetigtPassiv != null) { + for (Werkzeug werkzeug : benoetigtPassiv) { + if (!passiv.isVerfuegbar(werkzeug)) { + return false; + } + } + } + if (benoetigtAktiv == null || benoetigtAktiv.isEmpty()) { + return true; + } else { + for (Werkzeug werkzeug : benoetigtAktiv) { + if (aktiv.isVerfuegbar(werkzeug)) { + return true; + } + } + return false; + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/ImageScaler.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/ImageScaler.java new file mode 100644 index 0000000..09df2d0 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/ImageScaler.java @@ -0,0 +1,58 @@ +package de.oaa.xxx.games.common.aufgaben; + +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +public class ImageScaler { + + private static final int MAX_SIZE = 128; + + public byte[] scale(byte[] origBytes) { + try (ByteArrayInputStream bais = new ByteArrayInputStream(origBytes)) { + BufferedImage orig = ImageIO.read(bais); + if (orig == null) { + return origBytes; + } + + int origWidth = orig.getWidth(); + int origHeight = orig.getHeight(); + + // Bereits klein genug – unverändern zurückgeben + if (origWidth <= MAX_SIZE && origHeight <= MAX_SIZE) { + return origBytes; + } + + // Seitenverhältnis beibehalten: längste Seite auf MAX_SIZE + int newWidth, newHeight; + if (origWidth >= origHeight) { + newWidth = MAX_SIZE; + newHeight = Math.max(1, Math.round((float) MAX_SIZE * origHeight / origWidth)); + } else { + newHeight = MAX_SIZE; + newWidth = Math.max(1, Math.round((float) MAX_SIZE * origWidth / origHeight)); + } + + BufferedImage scaled = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.drawImage(orig, 0, 0, newWidth, newHeight, null); + g.dispose(); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(scaled, "png", baos); + return baos.toByteArray(); + } + } catch (IOException e) { + LoggerFactory.getLogger(ImageScaler.class).error("Fehler beim Skalieren des Bildes", e); + return origBytes; + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Sperre.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Sperre.java new file mode 100644 index 0000000..45c5074 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Sperre.java @@ -0,0 +1,38 @@ +package de.oaa.xxx.games.common.aufgaben; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.bdsm.BdsmMitspieler; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Sperre { + + private UUID sperreId; + private String kurzText; + private String text; + private String releaseText; + private UUID gruppeId; + private List sperreFuer; + private Integer minutenVon; + private Integer minutenBis; + private List benoetigteToys; + + @Override + public String toString() { + return "Sperre[id=" + sperreId + ", kurzText=" + kurzText + + ", minuten=" + minutenVon + "-" + minutenBis + ", fuer=" + sperreFuer + ", gruppeId=" + gruppeId + "]"; + } + + public boolean isAufgabePassend(BdsmMitspieler passiv) { + for (Werkzeug werkzeug : sperreFuer) { + if (!passiv.isVerfuegbar(werkzeug)) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Strafe.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Strafe.java new file mode 100644 index 0000000..e9365ff --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Strafe.java @@ -0,0 +1,47 @@ +package de.oaa.xxx.games.common.aufgaben; + +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.bdsm.BdsmMitspieler; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Strafe { + + private UUID strafeId; + private Integer level; + private String text; + private String kurzText; + private Integer sekundenVon; + private Integer sekundenBis; + private UUID gruppeId; + private List benoetigtAktiv; + private List benoetigtPassiv; + private List benoetigteToys; + + public boolean isAufgabePassend(int level, BdsmMitspieler aktiv, BdsmMitspieler passiv) { + if (level != this.level && level - 1 != this.level) { + return false; + } + if (benoetigtPassiv != null) { + for (Werkzeug werkzeug : benoetigtPassiv) { + if (!passiv.isVerfuegbar(werkzeug)) { + return false; + } + } + } + if (benoetigtAktiv == null || benoetigtAktiv.isEmpty()) { + return true; + } else { + for (Werkzeug werkzeug : benoetigtAktiv) { + if (aktiv.isVerfuegbar(werkzeug)) { + return true; + } + } + return false; + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Toy.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Toy.java new file mode 100644 index 0000000..fe12a70 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Toy.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class Toy { + + private UUID toyId; + private String name; + private String beschreibung; + private UUID userId; + private String bild; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyList.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyList.java new file mode 100644 index 0000000..ed35647 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyList.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ToyList { + + private List systemToys; + private List userToys; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyPage.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyPage.java new file mode 100644 index 0000000..0c76b81 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/ToyPage.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.games.common.aufgaben; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ToyPage { + + private List content; + private int currentPage; + private int totalPages; + private long totalElements; +} diff --git a/src/main/java/de/oaa/xxx/games/common/aufgaben/Werkzeug.java b/src/main/java/de/oaa/xxx/games/common/aufgaben/Werkzeug.java new file mode 100644 index 0000000..84f3f5c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/aufgaben/Werkzeug.java @@ -0,0 +1,21 @@ +package de.oaa.xxx.games.common.aufgaben; + +public enum Werkzeug { + + MUND("Mund", "Ob die Person gewillt ist den Mund einzusetzen."), + VAGINA("Vagina", "Ob die Person über eine Vagina verfügt und gewillt ist diese einzusetzen."), + PENIS("Penis", "Ob die Person über einen Penis verfügt und gewillt ist diesen einzusetzen."), + ANUS("Anus", "Ob die Person gewillt ist den Anus einzusetzen."), + UMSCHNALLDILDO("Umschnall-Dildo", "Ob die Person über einen Umschnall-Dildo verfügt und gewillt ist diesen einzusetzen."); + + private final String anzeige; + private final String beschreibung; + + Werkzeug(String anzeige, String beschreibung) { + this.anzeige = anzeige; + this.beschreibung = beschreibung; + } + + public String beschreibungsText() { return beschreibung; } + public String anzeigeText() { return anzeige; } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/AufgabeEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/AufgabeEntity.java new file mode 100644 index 0000000..c92aa33 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/AufgabeEntity.java @@ -0,0 +1,97 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.CascadeType; +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.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.common.aufgaben.Aufgabe; +import de.oaa.xxx.games.common.aufgaben.Werkzeug; + +@Getter +@Setter +@Entity +@Table(name = "aufgabe") +public class AufgabeEntity { + + @Id + @Column + private UUID aufgabeId; + @Column + private String kurzText; + @Column(columnDefinition = "TEXT") + private String text; + @Column + private Integer level; + @Column + private Integer sekundenVon; + @Column + private Integer sekundenBis; + @ManyToOne + @JoinColumn(name = "gruppeId") + private AufgabenGruppeEntity aufgabenGruppe; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "aufgabe_benoetigtAktiv", joinColumns = @JoinColumn(name = "aufgabeId")) + @Column(name = "werkzeug") + private List benoetigtAktiv; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "aufgabe_benoetigtPassiv", joinColumns = @JoinColumn(name = "aufgabeId")) + @Column(name = "werkzeug") + private List benoetigtPassiv; + @ManyToMany(cascade = CascadeType.DETACH) + @JoinTable(name = "aufgabeToy", joinColumns = {@JoinColumn(name = "aufgabeId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")}) + private List benoetigteToys; + + @Override + public String toString() { + return "AufgabeEntity[id=" + aufgabeId + ", kurzText=" + kurzText + ", level=" + level + + ", sekunden=" + sekundenVon + "-" + sekundenBis + "]"; + } + + public Aufgabe toAufgabe() { + Aufgabe aufgabe = new Aufgabe(); + aufgabe.setAufgabeId(aufgabeId); + aufgabe.setBenoetigtAktiv(benoetigtAktiv != null ? new ArrayList<>(benoetigtAktiv) : new ArrayList<>()); + aufgabe.setBenoetigteToys(benoetigteToys != null ? benoetigteToys.stream().map(ToyEntity::toToy).toList() : new ArrayList<>()); + aufgabe.setBenoetigtPassiv(benoetigtPassiv != null ? new ArrayList<>(benoetigtPassiv) : new ArrayList<>()); + aufgabe.setGruppeId(aufgabenGruppe.getGruppenId()); + aufgabe.setKurzText(kurzText); + aufgabe.setLevel(level); + aufgabe.setSekundenBis(sekundenBis); + aufgabe.setSekundenVon(sekundenVon); + aufgabe.setText(text); + return aufgabe; + } + + public static AufgabeEntity create(Aufgabe aufgabe, AufgabenGruppeEntity aufgabenGruppeEntity, List toys) { + AufgabeEntity entity = new AufgabeEntity(); + entity.setAufgabeId(UUID.randomUUID()); + entity.setAufgabenGruppe(aufgabenGruppeEntity); + entity.setBenoetigtAktiv(aufgabe.getBenoetigtAktiv()); + entity.setBenoetigteToys(toys != null ? toys : new ArrayList<>()); + entity.setBenoetigtPassiv(aufgabe.getBenoetigtPassiv()); + entity.setKurzText(aufgabe.getKurzText()); + entity.setLevel(aufgabe.getLevel()); + entity.setSekundenBis(aufgabe.getSekundenBis()); + entity.setSekundenVon(aufgabe.getSekundenVon()); + entity.setText(aufgabe.getText()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.java new file mode 100644 index 0000000..09efbae --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/AufgabenGruppeEntity.java @@ -0,0 +1,95 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.common.aufgaben.AufgabenGruppe; +import de.oaa.xxx.games.common.aufgaben.AufgabenGruppeDisplay; + +@Getter +@Setter +@Entity +@Table(name = "aufgaben_gruppe") +public class AufgabenGruppeEntity { + + @Id + @Column + private UUID gruppenId; + @Column + private String name; + @Column + private String beschreibung; + @Column + private UUID userId; + @Column + private boolean privateGruppe; + @Lob + @Column(columnDefinition = "BLOB") + private byte[] bild; + @Column + private String von; + @OneToMany(mappedBy = "aufgabenGruppe") + private List aufgaben; + @OneToMany(mappedBy = "aufgabenGruppe") + private List strafen; + @OneToMany(mappedBy = "aufgabenGruppe") + private List sperren; + @OneToMany(mappedBy = "aufgabenGruppe") + private List finisher; + + @Override + public String toString() { + return "AufgabenGruppeEntity[gruppenId=" + gruppenId + ", name=" + name + ", userId=" + userId + + ", privat=" + privateGruppe + ", von=" + von + "]"; + } + + public AufgabenGruppe toAufgabenGruppe() { + AufgabenGruppe gruppe = new AufgabenGruppe(); + gruppe.setGruppenId(gruppenId); + gruppe.setUserId(userId); + gruppe.setName(name); + gruppe.setBeschreibung(beschreibung); + gruppe.setPrivateGruppe(privateGruppe); + gruppe.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null); + gruppe.setVon(von); + gruppe.setAufgaben(aufgaben.stream().map(AufgabeEntity::toAufgabe).toList()); + gruppe.setStrafen(strafen.stream().map(StrafeEntity::toStrafe).toList()); + gruppe.setSperren(sperren.stream().map(SperreEntity::toSperre).toList()); + gruppe.setFinisher(finisher.stream().map(FinisherEntity::toFinisher).toList()); + return gruppe; + } + + public static AufgabenGruppeEntity create(AufgabenGruppe gruppe) { + AufgabenGruppeEntity entity = new AufgabenGruppeEntity(); + entity.setGruppenId(UUID.randomUUID()); + entity.setName(gruppe.getName()); + entity.setBeschreibung(gruppe.getBeschreibung()); + entity.setUserId(gruppe.getUserId()); + entity.setPrivateGruppe(gruppe.isPrivateGruppe()); + entity.setBild(gruppe.getBild() != null ? Base64.getDecoder().decode(gruppe.getBild()) : null); + entity.setVon(gruppe.getVon()); + return entity; + } + + public AufgabenGruppeDisplay toAufgabenGruppeDisplay() { + AufgabenGruppeDisplay display = new AufgabenGruppeDisplay(); + display.setGruppenId(gruppenId); + display.setUserId(userId); + display.setName(name); + display.setBeschreibung(beschreibung); + display.setPrivateGruppe(privateGruppe); + display.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null); + display.setVon(von); + return display; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/FavoritEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/FavoritEntity.java new file mode 100644 index 0000000..f9a45a9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/FavoritEntity.java @@ -0,0 +1,48 @@ +package de.oaa.xxx.games.common.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; + +import de.oaa.xxx.games.common.aufgaben.Favorit; + +@Getter +@Setter +@Entity +@Table(name = "favorit") +public class FavoritEntity { + + @Id + @Column + private UUID favoritId; + @Column + private UUID userId; + @Column + private UUID aufgabenGruppeId; + + @Override + public String toString() { + return "FavoritEntity[favoritId=" + favoritId + ", userId=" + userId + ", gruppeId=" + aufgabenGruppeId + "]"; + } + + public Favorit toFavorit() { + Favorit favorit = new Favorit(); + favorit.setAufgabenGruppeId(aufgabenGruppeId); + favorit.setFavoritId(favoritId); + favorit.setUserId(userId); + return favorit; + } + + public static FavoritEntity fromFavorit(Favorit favorit, UUID userId) { + FavoritEntity entity = new FavoritEntity(); + entity.setFavoritId(UUID.randomUUID()); + entity.setAufgabenGruppeId(favorit.getAufgabenGruppeId()); + entity.setUserId(userId); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/FinisherEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/FinisherEntity.java new file mode 100644 index 0000000..36aabe6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/FinisherEntity.java @@ -0,0 +1,89 @@ +package de.oaa.xxx.games.common.entity; + +import de.oaa.xxx.games.bdsm.GeschlechtEnum; +import de.oaa.xxx.games.common.aufgaben.Finisher; +import de.oaa.xxx.games.common.aufgaben.Werkzeug; +import jakarta.persistence.CascadeType; +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.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "finisher") +public class FinisherEntity { + + @Id + @Column + private UUID finisherId; + @Column + private String kurzText; + @Column(columnDefinition = "TEXT") + private String text; + @Enumerated(EnumType.STRING) + @Column + private GeschlechtEnum geschlecht; + @ManyToOne + @JoinColumn(name = "gruppeId") + private AufgabenGruppeEntity aufgabenGruppe; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "finisher_benoetigtAktiv", joinColumns = @JoinColumn(name = "finisherId")) + @Column(name = "werkzeug") + private List benoetigtAktiv; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "finisher_benoetigtPassiv", joinColumns = @JoinColumn(name = "finisherId")) + @Column(name = "werkzeug") + private List benoetigtPassiv; + @ManyToMany(cascade = CascadeType.DETACH) + @JoinTable(name = "finisherToy", joinColumns = {@JoinColumn(name = "finisherId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")}) + private List benoetigteToys; + + @Override + public String toString() { + return "FinisherEntity[id=" + finisherId + ", kurzText=" + kurzText + ", geschlecht=" + geschlecht + "]"; + } + + public Finisher toFinisher() { + Finisher finisher = new Finisher(); + finisher.setFinisherId(finisherId); + finisher.setKurzText(kurzText); + finisher.setText(text); + finisher.setGeschlecht(geschlecht); + finisher.setBenoetigtAktiv(benoetigtAktiv != null ? new ArrayList<>(benoetigtAktiv) : new ArrayList<>()); + finisher.setBenoetigtPassiv(benoetigtPassiv != null ? new ArrayList<>(benoetigtPassiv) : new ArrayList<>()); + finisher.setBenoetigteToys(benoetigteToys != null ? benoetigteToys.stream().map(ToyEntity::toToy).toList() : new ArrayList<>()); + finisher.setGruppeId(aufgabenGruppe.getGruppenId()); + return finisher; + } + + public static FinisherEntity create(Finisher finisher, AufgabenGruppeEntity aufgabenGruppeEntity, List toys) { + FinisherEntity entity = new FinisherEntity(); + entity.setFinisherId(UUID.randomUUID()); + entity.setAufgabenGruppe(aufgabenGruppeEntity); + entity.setKurzText(finisher.getKurzText()); + entity.setText(finisher.getText()); + entity.setGeschlecht(finisher.getGeschlecht()); + entity.setBenoetigtAktiv(finisher.getBenoetigtAktiv()); + entity.setBenoetigtPassiv(finisher.getBenoetigtPassiv()); + entity.setBenoetigteToys(toys != null ? toys : new ArrayList<>()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/GruppenAboEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/GruppenAboEntity.java new file mode 100644 index 0000000..762a2b1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/GruppenAboEntity.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "gruppen_abo") +public class GruppenAboEntity { + + @Id + @Column + private UUID aboId; + + @Column + private UUID userId; + + @ManyToOne + @JoinColumn(name = "gruppenId") + private AufgabenGruppeEntity aufgabenGruppe; + + @Override + public String toString() { + return "GruppenAboEntity[aboId=" + aboId + ", userId=" + userId + + ", gruppe=" + (aufgabenGruppe != null ? aufgabenGruppe.getName() : null) + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/SperreEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/SperreEntity.java new file mode 100644 index 0000000..5b8134f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/SperreEntity.java @@ -0,0 +1,90 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.CascadeType; +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.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.common.aufgaben.Sperre; +import de.oaa.xxx.games.common.aufgaben.Werkzeug; + +@Getter +@Setter +@Entity +@Table(name = "sperre") +public class SperreEntity { + + @Id + @Column + private UUID sperreId; + @Column + private String kurzText; + @Column(columnDefinition = "TEXT") + private String text; + @Column(columnDefinition = "TEXT") + private String releaseText; + @ManyToOne + @JoinColumn(name = "gruppeId") + private AufgabenGruppeEntity aufgabenGruppe; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "sperre_sperreFuer", joinColumns = @JoinColumn(name = "sperreId")) + @Column(name = "werkzeug") + private List sperreFuer; + @Column + private Integer minutenVon; + @Column + private Integer minutenBis; + @ManyToMany(cascade = CascadeType.DETACH) + @JoinTable(name = "sperreToy", joinColumns = {@JoinColumn(name = "sperreId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")}) + private List benoetigteToys; + + @Override + public String toString() { + return "SperreEntity[id=" + sperreId + ", kurzText=" + kurzText + + ", minuten=" + minutenVon + "-" + minutenBis + ", fuer=" + sperreFuer + "]"; + } + + public Sperre toSperre() { + Sperre sperre = new Sperre(); + sperre.setSperreId(sperreId); + sperre.setGruppeId(aufgabenGruppe.getGruppenId()); + sperre.setKurzText(kurzText); + sperre.setMinutenBis(minutenBis); + sperre.setMinutenVon(minutenVon); + sperre.setReleaseText(releaseText); + sperre.setSperreFuer(sperreFuer != null ? new ArrayList<>(sperreFuer) : new ArrayList<>()); + sperre.setText(text); + sperre.setBenoetigteToys(benoetigteToys != null ? benoetigteToys.stream().map(ToyEntity::toToy).toList() : new ArrayList<>()); + return sperre; + } + + public static SperreEntity create(Sperre sperre, AufgabenGruppeEntity aufgabenGruppeEntity, List toys) { + SperreEntity entity = new SperreEntity(); + entity.setSperreId(UUID.randomUUID()); + entity.setAufgabenGruppe(aufgabenGruppeEntity); + entity.setBenoetigteToys(toys != null ? toys : new ArrayList<>()); + entity.setKurzText(sperre.getKurzText()); + entity.setMinutenBis(sperre.getMinutenBis()); + entity.setMinutenVon(sperre.getMinutenVon()); + entity.setReleaseText(sperre.getReleaseText()); + entity.setSperreFuer(sperre.getSperreFuer()); + entity.setText(sperre.getText()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/StrafeEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/StrafeEntity.java new file mode 100644 index 0000000..d95b476 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/StrafeEntity.java @@ -0,0 +1,97 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.CascadeType; +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.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import de.oaa.xxx.games.common.aufgaben.Strafe; +import de.oaa.xxx.games.common.aufgaben.Werkzeug; + +@Getter +@Setter +@Entity +@Table(name = "strafe") +public class StrafeEntity { + + @Id + @Column + private UUID strafeId; + @Column + private String kurzText; + @Column + private Integer level; + @Column(columnDefinition = "TEXT") + private String text; + @Column + private Integer sekundenVon; + @Column + private Integer sekundenBis; + @ManyToOne + @JoinColumn(name = "gruppeId") + private AufgabenGruppeEntity aufgabenGruppe; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "strafe_benoetigtAktiv", joinColumns = @JoinColumn(name = "strafeId")) + @Column(name = "werkzeug") + private List benoetigtAktiv; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class) + @CollectionTable(name = "strafe_benoetigtPassiv", joinColumns = @JoinColumn(name = "strafeId")) + @Column(name = "werkzeug") + private List benoetigtPassiv; + @ManyToMany(cascade = CascadeType.DETACH) + @JoinTable(name = "strafeToy", joinColumns = {@JoinColumn(name = "strafeId")}, inverseJoinColumns = {@JoinColumn(name = "toyId")}) + private List benoetigteToys; + + @Override + public String toString() { + return "StrafeEntity[id=" + strafeId + ", kurzText=" + kurzText + ", level=" + level + + ", sekunden=" + sekundenVon + "-" + sekundenBis + "]"; + } + + public Strafe toStrafe() { + Strafe strafe = new Strafe(); + strafe.setStrafeId(strafeId); + strafe.setBenoetigtAktiv(benoetigtAktiv != null ? new ArrayList<>(benoetigtAktiv) : new ArrayList<>()); + strafe.setBenoetigteToys(benoetigteToys != null ? benoetigteToys.stream().map(ToyEntity::toToy).toList() : new ArrayList<>()); + strafe.setBenoetigtPassiv(benoetigtPassiv != null ? new ArrayList<>(benoetigtPassiv) : new ArrayList<>()); + strafe.setGruppeId(aufgabenGruppe.getGruppenId()); + strafe.setKurzText(kurzText); + strafe.setLevel(level); + strafe.setSekundenBis(sekundenBis); + strafe.setSekundenVon(sekundenVon); + strafe.setText(text); + return strafe; + } + + public static StrafeEntity create(Strafe strafe, AufgabenGruppeEntity aufgabenGruppeEntity, List toys) { + StrafeEntity entity = new StrafeEntity(); + entity.setStrafeId(UUID.randomUUID()); + entity.setAufgabenGruppe(aufgabenGruppeEntity); + entity.setBenoetigtAktiv(strafe.getBenoetigtAktiv()); + entity.setBenoetigteToys(toys != null ? toys : new ArrayList<>()); + entity.setBenoetigtPassiv(strafe.getBenoetigtPassiv()); + entity.setKurzText(strafe.getKurzText()); + entity.setLevel(strafe.getLevel()); + entity.setSekundenBis(strafe.getSekundenBis()); + entity.setSekundenVon(strafe.getSekundenVon()); + entity.setText(strafe.getText()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/entity/ToyEntity.java b/src/main/java/de/oaa/xxx/games/common/entity/ToyEntity.java new file mode 100644 index 0000000..c688a67 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/entity/ToyEntity.java @@ -0,0 +1,59 @@ +package de.oaa.xxx.games.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.util.Base64; +import java.util.UUID; + +import de.oaa.xxx.games.common.aufgaben.Toy; + +@Getter +@Setter +@Entity +@Table(name = "toy") +public class ToyEntity { + + @Id + @Column + private UUID toyId; + @Column + private String name; + @Column + private String beschreibung; + @Column + private UUID userId; + @Lob + @Column(columnDefinition = "BLOB") + private byte[] bild; + + @Override + public String toString() { + return "ToyEntity[toyId=" + toyId + ", name=" + name + ", userId=" + userId + "]"; + } + + public Toy toToy() { + Toy toy = new Toy(); + toy.setToyId(toyId); + toy.setName(name); + toy.setBeschreibung(beschreibung); + toy.setUserId(userId); + toy.setBild(bild != null ? Base64.getEncoder().encodeToString(bild) : null); + return toy; + } + + public static ToyEntity create(Toy toy) { + ToyEntity entity = new ToyEntity(); + entity.setToyId(UUID.randomUUID()); + entity.setName(toy.getName()); + entity.setBeschreibung(toy.getBeschreibung()); + entity.setUserId(toy.getUserId()); + entity.setBild(toy.getBild() != null ? Base64.getDecoder().decode(toy.getBild()) : null); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/AufgabeRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/AufgabeRepository.java new file mode 100644 index 0000000..9cb0532 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/AufgabeRepository.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import de.oaa.xxx.games.common.entity.AufgabeEntity; +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; + +import java.util.List; +import java.util.UUID; + +public interface AufgabeRepository extends JpaRepository { + + List findByAufgabenGruppeIn(List gruppen); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.java new file mode 100644 index 0000000..db813fe --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/AufgabenGruppeRepository.java @@ -0,0 +1,47 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; + +import java.util.List; +import java.util.UUID; + +public interface AufgabenGruppeRepository extends JpaRepository { + + @Query("select age from AufgabenGruppeEntity age where age.userId = :userId") + List findByUserId(@Param("userId") UUID userId); + + long countByUserId(UUID userId); + + Page findByUserIdIsNull(Pageable pageable); + + Page findByUserId(UUID userId, Pageable pageable); + + @Query("select age from AufgabenGruppeEntity age where (age.privateGruppe = false or age.userId = :userId) and (:search is null or age.name like :search)") + List listWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable); + + @Query("select age from AufgabenGruppeEntity age where age.privateGruppe = false and (:search is null or age.name like :search)") + List listPublicWithSearch(@Param("search") String search, PageRequest pageable); + + @Query("select age from AufgabenGruppeEntity age where age.privateGruppe = false and age.userId is not null and age.userId <> :userId and (:name is null or lower(age.name) like lower(:name))") + List findPublicFromOthers(@Param("userId") UUID userId, @Param("name") String name); + + @Query("SELECT g FROM AufgabenGruppeEntity g WHERE (g.privateGruppe = false OR g.userId = :userId) AND g.strafen IS EMPTY AND g.sperren IS EMPTY AND (:search IS NULL OR g.name LIKE :search)") + List listVanillaSafeWithUserAndSearch(@Param("userId") UUID userId, @Param("search") String search, PageRequest pageable); + + @Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.privateGruppe = false AND g.userId IS NOT NULL AND g.userId <> :userId AND g.strafen IS EMPTY AND g.sperren IS EMPTY AND (:name IS NULL OR LOWER(g.name) LIKE LOWER(:name))") + List findVanillaSafePublicFromOthers(@Param("userId") UUID userId, @Param("name") String name); + + @Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId = :userId AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)") + Page findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable); + + @Query("SELECT g FROM AufgabenGruppeEntity g WHERE g.userId IS NULL AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)") + Page findSystemGroupsWithContent(Pageable pageable); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/FavoritRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/FavoritRepository.java new file mode 100644 index 0000000..6f40ca7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/FavoritRepository.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import de.oaa.xxx.games.common.entity.FavoritEntity; + +import java.util.List; +import java.util.UUID; + +public interface FavoritRepository extends JpaRepository { + + List findByUserId(UUID userId); + + List findByUserIdAndAufgabenGruppeId(UUID userId, UUID aufgabenGruppeId); + + void deleteByAufgabenGruppeId(UUID aufgabenGruppeId); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/FinisherRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/FinisherRepository.java new file mode 100644 index 0000000..a6093bf --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/FinisherRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import de.oaa.xxx.games.common.entity.FinisherEntity; + +import java.util.UUID; + +public interface FinisherRepository extends JpaRepository { +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/GruppenAboRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/GruppenAboRepository.java new file mode 100644 index 0000000..81a9dd7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/GruppenAboRepository.java @@ -0,0 +1,30 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; +import de.oaa.xxx.games.common.entity.GruppenAboEntity; + +import java.util.List; +import java.util.UUID; + +public interface GruppenAboRepository extends JpaRepository { + + List findByUserId(UUID userId); + + boolean existsByUserIdAndAufgabenGruppe(UUID userId, AufgabenGruppeEntity gruppe); + + void deleteByUserIdAndAufgabenGruppe(UUID userId, AufgabenGruppeEntity gruppe); + + long countByAufgabenGruppe(AufgabenGruppeEntity gruppe); + + void deleteByAufgabenGruppe(AufgabenGruppeEntity gruppe); + + @Query(value = "SELECT a FROM GruppenAboEntity a JOIN a.aufgabenGruppe g WHERE a.userId = :userId AND g.privateGruppe = false AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)", + countQuery = "SELECT COUNT(a) FROM GruppenAboEntity a JOIN a.aufgabenGruppe g WHERE a.userId = :userId AND g.privateGruppe = false AND (g.aufgaben IS NOT EMPTY OR g.finisher IS NOT EMPTY)") + Page findByUserIdWithContent(@Param("userId") UUID userId, Pageable pageable); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/SperreRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/SperreRepository.java new file mode 100644 index 0000000..0126028 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/SperreRepository.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; +import de.oaa.xxx.games.common.entity.SperreEntity; + +import java.util.List; +import java.util.UUID; + +public interface SperreRepository extends JpaRepository { + + List findByAufgabenGruppeIn(List gruppen); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/StrafeRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/StrafeRepository.java new file mode 100644 index 0000000..6dfe55a --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/StrafeRepository.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import de.oaa.xxx.games.common.entity.AufgabenGruppeEntity; +import de.oaa.xxx.games.common.entity.StrafeEntity; + +import java.util.List; +import java.util.UUID; + +public interface StrafeRepository extends JpaRepository { + + List findByAufgabenGruppeIn(List gruppen); +} diff --git a/src/main/java/de/oaa/xxx/games/common/repository/ToyRepository.java b/src/main/java/de/oaa/xxx/games/common/repository/ToyRepository.java new file mode 100644 index 0000000..8a9014c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/common/repository/ToyRepository.java @@ -0,0 +1,42 @@ +package de.oaa.xxx.games.common.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import de.oaa.xxx.games.common.entity.ToyEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ToyRepository extends JpaRepository { + + Page findByUserIdIsNull(Pageable pageable); + + Page findByUserId(UUID userId, Pageable pageable); + long countByUserId(UUID userId); + + List findByUserId(UUID userId); + + boolean existsByNameIgnoreCaseAndUserIdIsNull(String name); + + boolean existsByNameIgnoreCaseAndUserId(String name, UUID userId); + + boolean existsByNameIgnoreCaseAndUserIdIsNullAndToyIdNot(String name, UUID toyId); + + boolean existsByNameIgnoreCaseAndUserIdAndToyIdNot(String name, UUID userId, UUID toyId); + + Optional findByNameIgnoreCaseAndUserId(String name, UUID userId); + + @Query("SELECT COUNT(a) FROM AufgabeEntity a JOIN a.benoetigteToys t WHERE t.toyId = :toyId") + long countAufgabeUsage(@Param("toyId") UUID toyId); + + @Query("SELECT COUNT(s) FROM StrafeEntity s JOIN s.benoetigteToys t WHERE t.toyId = :toyId") + long countStrafeUsage(@Param("toyId") UUID toyId); + + @Query("SELECT COUNT(sp) FROM SperreEntity sp JOIN sp.benoetigteToys t WHERE t.toyId = :toyId") + long countSperreUsage(@Param("toyId") UUID toyId); +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryController.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryController.java new file mode 100644 index 0000000..8690b5c --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryController.java @@ -0,0 +1,84 @@ +package de.oaa.xxx.games.history; + +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/gamehistory") +public class GameHistoryController { + + private final UserRepository userRepository; + private final GameHistoryRepository gameHistoryRepository; + private final UserService userService; + + public GameHistoryController(UserRepository userRepository, GameHistoryRepository gameHistoryRepository, UserService userService) { + this.userRepository = userRepository; + this.gameHistoryRepository = gameHistoryRepository; + this.userService = userService; + } + + @GetMapping + @Transactional(readOnly = true) + public ResponseEntity>> get(@RequestParam UUID userId, Principal principal) { + userService.requireUser(principal); + + var result = gameHistoryRepository.findByParticipantUserId(userId).stream() + .map(e -> { + Map item = new LinkedHashMap<>(); + item.put("historyId", e.getHistoryId()); + item.put("gameType", e.getGameType()); + item.put("gameName", e.getGameName() != null ? e.getGameName() : ""); + item.put("lockName", e.getGameName() != null ? e.getGameName() : ""); + item.put("startTime", e.getStartTime().toString()); + item.put("unlockTime", e.getEndTime().toString()); + item.put("durationMinutes", e.getDurationMinutes()); + + List> participants = e.getParticipants().stream() + .map(p -> { + Map pm = new LinkedHashMap<>(); + pm.put("userId", p.getUserId()); + pm.put("role", p.getRole()); + userRepository.findById(p.getUserId()).ifPresent(u -> { + pm.put("name", u.getName()); + pm.put("picture", u.getProfilePicture()); + }); + return pm; + }) + .toList(); + item.put("participants", participants); + + // Abwärtskompatible Felder für benutzer.html (wird später angepasst) + e.getParticipants().stream() + .filter(p -> p.getUserId().equals(userId)) + .findFirst() + .ifPresent(own -> item.put("role", own.getRole().name())); + + e.getParticipants().stream() + .filter(p -> !p.getUserId().equals(userId)) + .findFirst() + .ifPresent(partner -> userRepository.findById(partner.getUserId()).ifPresent(u -> { + if (GameRole.LOCKEE == partner.getRole()) { + item.put("lockeeName", u.getName()); + } else if (GameRole.KEYHOLDER == partner.getRole()) { + item.put("keyholderName", u.getName()); + } + item.put("partnerPic", u.getProfilePicture()); + })); + + return item; + }).toList(); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryDTO.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryDTO.java new file mode 100644 index 0000000..2849c6b --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryDTO.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.history; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record GameHistoryDTO( + UUID historyId, + GameType gameType, + LocalDateTime startTime, + LocalDateTime endTime, + String gameName, + long durationMinutes, + List participants +) { + public record ParticipantDTO(UUID userId, GameRole role) {} +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryEntity.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryEntity.java new file mode 100644 index 0000000..f8f57f5 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryEntity.java @@ -0,0 +1,49 @@ +package de.oaa.xxx.games.history; + +import jakarta.persistence.*; +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 = "game_history") +public class GameHistoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID historyId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private GameType gameType; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Column + private String gameName; + + @Column(nullable = false, columnDefinition = "BIGINT DEFAULT 0") + private long durationMinutes; + + @OneToMany(mappedBy = "history", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List participants = new ArrayList<>(); + + public void addParticipant(UUID userId, GameRole role) { + GameHistoryParticipantEntity p = new GameHistoryParticipantEntity(); + p.setUserId(userId); + p.setRole(role); + p.setHistory(this); + participants.add(p); + } +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantEntity.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantEntity.java new file mode 100644 index 0000000..a5ad051 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantEntity.java @@ -0,0 +1,30 @@ +package de.oaa.xxx.games.history; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "game_history_participant") +public class GameHistoryParticipantEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column + private UUID participantId; + + @Column(nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private GameRole role; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "history_id", nullable = false) + private GameHistoryEntity history; +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantRepository.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantRepository.java new file mode 100644 index 0000000..b64881f --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryParticipantRepository.java @@ -0,0 +1,8 @@ +package de.oaa.xxx.games.history; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface GameHistoryParticipantRepository extends JpaRepository { +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameHistoryRepository.java b/src/main/java/de/oaa/xxx/games/history/GameHistoryRepository.java new file mode 100644 index 0000000..d4a81fc --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameHistoryRepository.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.history; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.UUID; + +public interface GameHistoryRepository extends JpaRepository { + + @Query("SELECT DISTINCT h FROM GameHistoryEntity h JOIN h.participants p WHERE p.userId = :userId ORDER BY h.endTime DESC") + List findByParticipantUserId(@Param("userId") UUID userId); + + @Query("SELECT DISTINCT h FROM GameHistoryEntity h JOIN h.participants p WHERE p.userId = :userId AND h.gameType = :gameType ORDER BY h.endTime DESC") + List findByParticipantUserIdAndGameType(@Param("userId") UUID userId, @Param("gameType") GameType gameType); +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameRole.java b/src/main/java/de/oaa/xxx/games/history/GameRole.java new file mode 100644 index 0000000..f7e5562 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameRole.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.games.history; + +public enum GameRole { + LOCKEE, + KEYHOLDER, + PLAYER +} diff --git a/src/main/java/de/oaa/xxx/games/history/GameType.java b/src/main/java/de/oaa/xxx/games/history/GameType.java new file mode 100644 index 0000000..04d1a86 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/history/GameType.java @@ -0,0 +1,8 @@ +package de.oaa.xxx.games.history; + +public enum GameType { + CARDLOCK, + TIMELOCK, + BDSM, + VANILLA +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.java b/src/main/java/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.java new file mode 100644 index 0000000..f9febec --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/VanillaAufgabeAnzeige.java @@ -0,0 +1,23 @@ +package de.oaa.xxx.games.vanilla; + +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class VanillaAufgabeAnzeige { + + private String nameAktiverMitspieler; + private String aufgabeText; + private Integer timer; + private Integer level; + private UUID mitspielerId; + private boolean eigenesGeraet; + + @Override + public String toString() { + return "VanillaAufgabeAnzeige[mitspieler=" + nameAktiverMitspieler + ", level=" + level + ", timer=" + timer + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/VanillaGame.java b/src/main/java/de/oaa/xxx/games/vanilla/VanillaGame.java new file mode 100644 index 0000000..ad168df --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/VanillaGame.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.vanilla; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter @Setter +public class VanillaGame { + private UUID sessionId; + private UUID userId; + private UUID setupId; + private Integer aufgabenProLevel; + private Integer level; + private Integer aufgabenAufAktuellemLevel; + private LocalDateTime startZeit; + private LocalDateTime letzteAktivitaet; +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.java b/src/main/java/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.java new file mode 100644 index 0000000..4a262b2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/VanillaGameDurchfuehren.java @@ -0,0 +1,119 @@ +package de.oaa.xxx.games.vanilla; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.oaa.xxx.games.bdsm.GeschlechtEnum; +import de.oaa.xxx.games.common.aufgaben.Aufgabe; +import de.oaa.xxx.games.common.aufgaben.AufgabenList; +import de.oaa.xxx.games.vanilla.entity.VanillaGameEntity; + +public class VanillaGameDurchfuehren { + + private final AufgabenList aufgabenList; + private final List mitspieler = new ArrayList<>(); + private int aufgabenProLevel; + private int level; + private int aufgabenAufAktuellemLevel; + + public VanillaGameDurchfuehren(VanillaGameEntity entity) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + aufgabenList = objectMapper.readValue(entity.getAufgaben(), AufgabenList.class); + entity.getMitspieler().forEach(m -> mitspieler.add(m.toMitspieler())); + 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 int getLevel() { return level; } + public int getAufgabenAufAktuellemLevel() { return aufgabenAufAktuellemLevel; } + + public VanillaAufgabeAnzeige getNext() { + checkLevel(); + if (level == 6) return null; + // Fallback + VanillaMitspieler aktiv = findeMitspielerMitRolle(); + VanillaMitspieler passiv = findeMitspielerMitRolle(aktiv); + + VanillaAufgabeAnzeige anzeige = findeAufgabe(aktiv, passiv); + if (anzeige != null) return anzeige; + + + VanillaAufgabeAnzeige fallback = new VanillaAufgabeAnzeige(); + fallback.setNameAktiverMitspieler(aktiv != null ? aktiv.getName() : ""); + if (aktiv != null) { fallback.setMitspielerId(aktiv.getId()); fallback.setEigenesGeraet(aktiv.isEigenesGeraet()); } + fallback.setAufgabeText("Keine passende Aufgabe gefunden. Überbrückt die Zeit mit Kuscheln."); + fallback.setTimer(120); + return fallback; + } + + public void backToLvl5() { this.level = 5; this.aufgabenAufAktuellemLevel = 0; } + + public List getFinisher() { + var list = new ArrayList(); + List.of(GeschlechtEnum.WEIBLICH, GeschlechtEnum.DIVERS, GeschlechtEnum.MAENNLICH).forEach(geschlecht -> { + mitspieler.stream().forEach(cumming -> { + var partner = findeMitspielerMitRolle(cumming); + var finishers = aufgabenList.getFinisher().stream() + .filter(f -> geschlecht == f.getGeschlecht() && f.isAufgabePassend(cumming, partner)) + .toList(); + VanillaAufgabeAnzeige anzeige = new VanillaAufgabeAnzeige(); + anzeige.setNameAktiverMitspieler(cumming.getName()); + anzeige.setMitspielerId(cumming.getId()); + anzeige.setEigenesGeraet(cumming.isEigenesGeraet()); + if (!finishers.isEmpty()) { + var f = finishers.get(new Random().nextInt(finishers.size())); + anzeige.setAufgabeText(getAnzeigeText(f.getText(), cumming.getName(), partner != null ? partner.getName() : "")); + } else { + 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 VanillaAufgabeAnzeige findeAufgabe(VanillaMitspieler aktiv, VanillaMitspieler passiv) { + List passende = aufgabenList.getAufgaben().stream() + .filter(a -> a.isAufgabePassend(level, aktiv, passiv)) + .toList(); + if (passende.isEmpty()) return null; + Aufgabe aufgabe = passende.get(new Random().nextInt(passende.size())); + VanillaAufgabeAnzeige anzeige = new VanillaAufgabeAnzeige(); + anzeige.setNameAktiverMitspieler(aktiv.getName()); + anzeige.setMitspielerId(aktiv.getId()); + anzeige.setEigenesGeraet(aktiv.isEigenesGeraet()); + anzeige.setAufgabeText(getAnzeigeText(aufgabe.getText(), aktiv.getName(), passiv != null ? passiv.getName() : "")); + if (aufgabe.getSekundenVon() != null && aufgabe.getSekundenBis() != null && aufgabe.getSekundenBis() > 0) { + anzeige.setTimer(new Random().nextInt(aufgabe.getSekundenVon(), aufgabe.getSekundenBis() + 1)); + } + return anzeige; + } + + private VanillaMitspieler findeMitspielerMitRolle() { + return findeMitspielerMitRolle(null); + } + + private VanillaMitspieler findeMitspielerMitRolle(VanillaMitspieler ausschliessen) { + List kandidaten = mitspieler.stream() + .filter(m -> !m.equals(ausschliessen)) + .toList(); + if (kandidaten.isEmpty()) return null; + return kandidaten.get(new Random().nextInt(kandidaten.size())); + } + + private String getAnzeigeText(String text, String aktiv, String passiv) { + if (text == null) return ""; + return text.replace("{AKTIV}", aktiv != null ? aktiv : "").replace("{PASSIV}", passiv != null ? passiv : ""); + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/VanillaMitspieler.java b/src/main/java/de/oaa/xxx/games/vanilla/VanillaMitspieler.java new file mode 100644 index 0000000..cfa46be --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/VanillaMitspieler.java @@ -0,0 +1,21 @@ +package de.oaa.xxx.games.vanilla; +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 VanillaMitspieler implements CommonMitspieler { + private UUID id; + private UUID userId; + private String name; + private List verfuegbareWerkzeuge; + private boolean eigenesGeraet; + + public boolean isVerfuegbar(Werkzeug werkzeug) { + return verfuegbareWerkzeuge != null && verfuegbareWerkzeuge.contains(werkzeug); + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAboController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAboController.java new file mode 100644 index 0000000..dd99b0d --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAboController.java @@ -0,0 +1,154 @@ +package de.oaa.xxx.games.vanilla.controller; + +import java.security.Principal; +import java.util.List; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +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; + +@RestController +@RequestMapping("/vanilla/abo") +@Transactional +public class VanillaAboController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaAboController.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 VanillaAboController(GruppenAboRepository aboRepository, + AufgabenGruppeRepository gruppeRepository, + UserService userService) { + this.aboRepository = aboRepository; + this.gruppeRepository = gruppeRepository; + this.userService = userService; + } + + // ── Abonnierte Gruppen laden (nur vanilla-safe) ── + + @GetMapping("/list") + public ResponseEntity listSubscribed( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size, + Principal principal) { + UserEntity user = userService.requireUser(principal); + + Page dbPage = aboRepository.findByUserIdWithContent( + user.getUserId(), PageRequest.of(page, size, Sort.by("aufgabenGruppe.name"))); + List dtos = dbPage.getContent().stream() + .map(a -> enrich(a.getAufgabenGruppe(), user.getUserId(), true)) + .toList(); + AufgabenGruppePage result = new AufgabenGruppePage(); + result.setContent(dtos); + result.setCurrentPage(dbPage.getNumber()); + result.setTotalPages(dbPage.getTotalPages()); + result.setTotalElements(dbPage.getTotalElements()); + return ResponseEntity.ok(result); + } + + // ── Entdecken (nur vanilla-safe Gruppen von anderen) ── + + @GetMapping("/discover") + public ResponseEntity 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 dtos = gruppeRepository + .findVanillaSafePublicFromOthers(user.getUserId(), namePattern).stream() + .map(g -> enrich(g, user.getUserId(), aboRepository.existsByUserIdAndAufgabenGruppe(user.getUserId(), g))) + .sorted(java.util.Comparator.comparingLong(AufgabenGruppe::getSubscriberCount).reversed() + .thenComparing(AufgabenGruppe::getName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + + int total = dtos.size(); + int start = page * size; + List content = start >= total ? List.of() : dtos.subList(start, Math.min(start + size, total)); + AufgabenGruppePage discoverPage = new AufgabenGruppePage(); + discoverPage.setContent(content); + discoverPage.setCurrentPage(page); + discoverPage.setTotalPages(total == 0 ? 1 : (int) Math.ceil((double) total / size)); + discoverPage.setTotalElements(total); + return ResponseEntity.ok(discoverPage); + } + + // ── Abonnieren (nur vanilla-safe) ── + + @PostMapping("/{gruppenId}") + public ResponseEntity 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(); + } + // Vanilla-safe validation + if (!gruppe.getStrafen().isEmpty() || !gruppe.getSperren().isEmpty()) { + return ResponseEntity.status(403).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 Vanilla-Gruppe {} abonniert", user.getUserId(), gruppenId); + return ResponseEntity.status(201).build(); + } + + // ── Abonnement kündigen ── + + @DeleteMapping("/{gruppenId}") + public ResponseEntity 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 Vanilla-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; + } + + +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.java new file mode 100644 index 0000000..a54d721 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabeController.java @@ -0,0 +1,126 @@ +package de.oaa.xxx.games.vanilla.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("/vanilla/aufgabe") +@Transactional +public class VanillaAufgabeController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaAufgabeController.class); + + private final AufgabeRepository aufgabeRepository; + private final AufgabenGruppeRepository gruppeRepository; + private final ToyRepository toyRepository; + private final UserService userService; + private final SubscriptionLimitService limitService; + + public VanillaAufgabeController(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 get(@PathVariable UUID aufgabeId) { + return aufgabeRepository.findById(aufgabeId) + .map(entity -> ResponseEntity.ok(entity.toAufgabe())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 toys = resolveToys(aufgabe.getBenoetigteToys()); + AufgabeEntity entity = AufgabeEntity.create(aufgabe, gruppeEntity, toys); + aufgabeRepository.save(entity); + LOGGER.debug("Vanilla-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 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("Vanilla-Aufgabe {} aktualisiert", aufgabeId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.java new file mode 100644 index 0000000..8c988be --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaAufgabenGruppeController.java @@ -0,0 +1,270 @@ +package de.oaa.xxx.games.vanilla.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("/vanilla/gruppe") +@Transactional +public class VanillaAufgabenGruppeController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaAufgabenGruppeController.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 VanillaAufgabenGruppeController(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 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 dbPage = gruppeRepository.findByUserIdWithContent( + user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); + AufgabenGruppePage result = new AufgabenGruppePage(); + result.setContent(dbPage.getContent().stream().map(entity -> { + AufgabenGruppe g = entity.toAufgabenGruppe(); + g.setSubscriberCount(aboRepository.countByAufgabenGruppe(entity)); + return g; + }).toList()); + result.setCurrentPage(dbPage.getNumber()); + result.setTotalPages(dbPage.getTotalPages()); + result.setTotalElements(dbPage.getTotalElements()); + return ResponseEntity.ok(result); + } + + @GetMapping("/list/system") + public ResponseEntity listSystem( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { + Page dbPage = gruppeRepository.findSystemGroupsWithContent( + PageRequest.of(page, size, Sort.by("name"))); + AufgabenGruppePage r = new AufgabenGruppePage(); + r.setContent(dbPage.getContent().stream() + .map(AufgabenGruppeEntity::toAufgabenGruppe).toList()); + r.setCurrentPage(dbPage.getNumber()); + r.setTotalPages(dbPage.getTotalPages()); + r.setTotalElements(dbPage.getTotalElements()); + return ResponseEntity.ok(r); + } + + // ── Bestehende Endpunkte ── + + @GetMapping("/all") + public ResponseEntity 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.listVanillaSafeWithUserAndSearch(userId, searchPattern, PageRequest.of(0, 500)) + .stream().map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); + return ResponseEntity.ok(list); + } + + @GetMapping("/own") + public ResponseEntity getOwn(@RequestParam UUID userId) { + AufgabenGruppeList list = new AufgabenGruppeList(); + list.setGruppen(gruppeRepository.findByUserId(userId).stream() + .filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty()) + .map(AufgabenGruppeEntity::toAufgabenGruppeDisplay).toList()); + return ResponseEntity.ok(list); + } + + @GetMapping("/{gruppeId}") + public ResponseEntity get(@PathVariable UUID gruppeId) { + return gruppeRepository.findById(gruppeId) + .filter(g -> g.getStrafen().isEmpty() && g.getSperren().isEmpty()) + .map(entity -> ResponseEntity.ok(entity.toAufgabenGruppe())) + .orElse(ResponseEntity.status(403).build()); + } + + // ── Anlegen ── + + @PostMapping + public ResponseEntity 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 Vanilla-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 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(); + // Vanilla-safe check: cannot edit a non-vanilla-safe group + if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) 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 Vanilla-AufgabenGruppe {} aktualisiert", user.getUserId(), gruppeId); + return ResponseEntity.ok().build(); + } + + // ── Kopieren (Systemgruppe → eigene) ── + + @PostMapping("/copy/{gruppeId}") + public ResponseEntity copy(@PathVariable UUID gruppeId, Principal principal) { + UserEntity user = resolveUser(principal); + if (user == null) return ResponseEntity.status(401).build(); + // Only allow copying vanilla-safe groups + AufgabenGruppeEntity source = gruppeRepository.findById(gruppeId).orElse(null); + if (source == null) return ResponseEntity.notFound().build(); + if (!source.getStrafen().isEmpty() || !source.getSperren().isEmpty()) return ResponseEntity.status(403).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 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(); + // Only allow deletion of vanilla-safe groups + if (!entity.getStrafen().isEmpty() || !entity.getSperren().isEmpty()) 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 delete(@RequestBody AufgabenGruppe gruppe) { + try { + gruppeRepository.findById(gruppe.getGruppenId()).ifPresent(entity -> { + if (entity.getStrafen().isEmpty() && entity.getSperren().isEmpty()) { + gruppeRepository.delete(entity); + } + }); + 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); + } + +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.java new file mode 100644 index 0000000..8d3c035 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaEinladungController.java @@ -0,0 +1,248 @@ +package de.oaa.xxx.games.vanilla.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.vanilla.entity.VanillaEinladungEntity; +import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity.Status; +import de.oaa.xxx.games.vanilla.repository.VanillaEinladungRepository; +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("/vanilla/einladung") +@Transactional +public class VanillaEinladungController { + + private final VanillaEinladungRepository einladungRepository; + private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final SystemMessageService systemMessageService; + private final UserService userService; + + public VanillaEinladungController(VanillaEinladungRepository 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> 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(); + + // Vanilla: max 1 Gast-Slot – prüfen ob bereits eine aktive Einladung für dieses Setup existiert + boolean slotOccupied = einladungRepository.findBySetupId(req.setupId()).stream() + .anyMatch(e -> e.getStatus() == Status.PENDING + || e.getStatus() == Status.ACCEPTED_OWN + || e.getStatus() == Status.ACCEPTED_HOST); + if (slotOccupied) { + return ResponseEntity.status(409).build(); + } + + // Prüfen ob Person bereits aktiv eingeladen + 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)); + + VanillaEinladungEntity entity = new VanillaEinladungEntity(); + 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 result = new LinkedHashMap<>(); + result.put("einladungId", entity.getEinladungId()); + return ResponseEntity.ok(result); + } + + @DeleteMapping("/{id}") + public ResponseEntity cancelEinladung(@PathVariable UUID id, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + VanillaEinladungEntity 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>> getBySetupId(@RequestParam UUID setupId, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findBySetupId(setupId).stream() + .map(this::toMap).toList(); + return ResponseEntity.ok(list); + } + + @GetMapping("/meine-aktive") + public ResponseEntity> 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 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>> getPending(Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findByInviteeIdAndStatus(userId, Status.PENDING) + .stream().map(e -> { + Map 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>> getSent(Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + List> list = einladungRepository.findByInviterIdAndStatus(userId, Status.PENDING) + .stream().map(e -> { + Map 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> getById(@PathVariable("id") UUID id, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + VanillaEinladungEntity 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 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 spielerDatenEinreichen(@PathVariable UUID id, @RequestBody SpielerDatenRequest req, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + VanillaEinladungEntity 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 antwort(@PathVariable UUID id, @RequestBody AntwortRequest req, Principal principal) { + UUID userId = currentUserId(principal); + if (userId == null) return ResponseEntity.status(401).build(); + VanillaEinladungEntity 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 toMap(VanillaEinladungEntity e) { + Map 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()); + m.put("createdAt", e.getCreatedAt().toString()); + userRepository.findById(e.getInviteeId()).ifPresent(u -> m.put("inviteeName", u.getName())); + return m; + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.java new file mode 100644 index 0000000..0b171e4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFavoritController.java @@ -0,0 +1,105 @@ +package de.oaa.xxx.games.vanilla.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.AufgabenGruppeRepository; +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("/vanilla/favorit") +@Transactional +public class VanillaFavoritController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaFavoritController.class); + + private final FavoritRepository favoritRepository; + private final AufgabenGruppeRepository gruppeRepository; + private final UserService userService; + + public VanillaFavoritController(FavoritRepository favoritRepository, + AufgabenGruppeRepository gruppeRepository, + UserService userService) { + this.favoritRepository = favoritRepository; + this.gruppeRepository = gruppeRepository; + this.userService = userService; + } + + @GetMapping("/{favoritId}") + public ResponseEntity get(@PathVariable UUID favoritId) { + return favoritRepository.findById(favoritId) + .map(entity -> ResponseEntity.ok(entity.toFavorit())) + .orElse(ResponseEntity.noContent().build()); + } + + @GetMapping + public ResponseEntity all(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + List entities = favoritRepository.findByUserId(userId); + FavoritList result = new FavoritList(); + // Only return favorites pointing to vanilla-safe groups + result.setFavoriten(entities.stream() + .filter(f -> { + var gruppe = gruppeRepository.findById(f.getAufgabenGruppeId()).orElse(null); + return gruppe != null && gruppe.getStrafen().isEmpty() && gruppe.getSperren().isEmpty(); + }) + .map(FavoritEntity::toFavorit).toList()); + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity create(@RequestBody Favorit favorit, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + if (favorit.getAufgabenGruppeId() == null) { + return ResponseEntity.badRequest().build(); + } + // Validate vanilla-safe + var gruppe = gruppeRepository.findById(favorit.getAufgabenGruppeId()).orElse(null); + if (gruppe == null || !gruppe.getStrafen().isEmpty() || !gruppe.getSperren().isEmpty()) { + return ResponseEntity.status(403).build(); + } + List existing = favoritRepository.findByUserIdAndAufgabenGruppeId(userId, favorit.getAufgabenGruppeId()); + FavoritEntity entity; + if (existing.isEmpty()) { + entity = FavoritEntity.fromFavorit(favorit, userId); + favoritRepository.save(entity); + LOGGER.debug("User {} hat Vanilla-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 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(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.java new file mode 100644 index 0000000..31d9144 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFillerController.java @@ -0,0 +1,34 @@ +package de.oaa.xxx.games.vanilla.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("/vanilla/filler") +public class VanillaFillerController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaFillerController.class); + + private final DefaultFiller defaultFiller; + + public VanillaFillerController(DefaultFiller defaultFiller) { + this.defaultFiller = defaultFiller; + } + + @PostMapping + public ResponseEntity fill() { + try { + defaultFiller.fill(); + return ResponseEntity.ok().build(); + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.java new file mode 100644 index 0000000..3a15a53 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaFinisherController.java @@ -0,0 +1,116 @@ +package de.oaa.xxx.games.vanilla.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("/vanilla/finisher") +@Transactional +public class VanillaFinisherController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaFinisherController.class); + + private final FinisherRepository finisherRepository; + private final AufgabenGruppeRepository gruppeRepository; + private final ToyRepository toyRepository; + + public VanillaFinisherController(FinisherRepository finisherRepository, + AufgabenGruppeRepository gruppeRepository, + ToyRepository toyRepository) { + this.finisherRepository = finisherRepository; + this.gruppeRepository = gruppeRepository; + this.toyRepository = toyRepository; + } + + @GetMapping("/{finisherId}") + public ResponseEntity get(@PathVariable UUID finisherId) { + return finisherRepository.findById(finisherId) + .map(entity -> ResponseEntity.ok(entity.toFinisher())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 toys = resolveToys(finisher.getBenoetigteToys()); + FinisherEntity entity = FinisherEntity.create(finisher, gruppeEntity, toys); + finisherRepository.save(entity); + LOGGER.debug("Vanilla-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 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("Vanilla-Finisher {} aktualisiert", finisherId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping + public ResponseEntity 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 resolveToys(List toys) { + if (toys == null || toys.isEmpty()) return new ArrayList<>(); + List ids = toys.stream() + .filter(t -> t.getToyId() != null) + .map(Toy::getToyId) + .toList(); + if (ids.isEmpty()) return new ArrayList<>(); + return toyRepository.findAllById(ids); + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaGameController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaGameController.java new file mode 100644 index 0000000..c247ad7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaGameController.java @@ -0,0 +1,399 @@ +package de.oaa.xxx.games.vanilla.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.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.oaa.xxx.games.common.aufgaben.AufgabenList; +import de.oaa.xxx.games.vanilla.VanillaAufgabeAnzeige; +import de.oaa.xxx.games.vanilla.VanillaGame; +import de.oaa.xxx.games.vanilla.VanillaGameDurchfuehren; +import de.oaa.xxx.games.vanilla.VanillaMitspieler; +import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity; +import de.oaa.xxx.games.vanilla.entity.VanillaGameEntity; +import de.oaa.xxx.games.vanilla.entity.VanillaMitspielerEntity; +import de.oaa.xxx.games.vanilla.repository.VanillaEinladungRepository; +import de.oaa.xxx.games.vanilla.repository.VanillaGameRepository; +import de.oaa.xxx.games.vanilla.repository.VanillaMitspielerRepository; +import de.oaa.xxx.social.SystemMessageService; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.user.UserService; + +@RestController +@RequestMapping("/vanilla") +@Transactional +public class VanillaGameController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaGameController.class); + /** + * Kurzlebiger In-Memory-Marker: Sessions die ordentlich über spielAbgeschlossen + * beendet wurden. + */ + private static final Set ORDENTLICH_BEENDET = Collections.synchronizedSet(new HashSet<>()); + + private final VanillaGameRepository sessionRepository; + private final VanillaMitspielerRepository mitspielerRepository; + private final VanillaEinladungRepository einladungRepository; + + private final ObjectMapper objectMapper; + private final SystemMessageService systemMessageService; + private final UserService userService; + + public VanillaGameController(VanillaGameRepository sessionRepository, + VanillaMitspielerRepository mitspielerRepository, + VanillaEinladungRepository einladungRepository, + ObjectMapper objectMapper, + SystemMessageService systemMessageService, + UserService userService) { + this.sessionRepository = sessionRepository; + this.mitspielerRepository = mitspielerRepository; + this.einladungRepository = einladungRepository; + this.objectMapper = objectMapper; + this.systemMessageService = systemMessageService; + this.userService = userService; + } + + @GetMapping("/{sessionId}") + public ResponseEntity getBySessionId(@PathVariable UUID sessionId) { + return sessionRepository.findById(sessionId) + .map(entity -> ResponseEntity.ok(toSession(entity))) + .orElse(ResponseEntity.noContent().build()); + } + + @GetMapping + public ResponseEntity getByUserId(@org.springframework.web.bind.annotation.RequestParam UUID userId) { + return sessionRepository.findByUserId(userId) + .map(entity -> ResponseEntity.ok(toSession(entity))) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity create(@RequestBody VanillaGame session, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + var existingOpt = sessionRepository.findByUserId(userId); + if (existingOpt.isPresent()) { + VanillaGameEntity existing = existingOpt.get(); + if (existing.getAufgaben() != null) return ResponseEntity.status(409).build(); + // Unvollständige Session (aufgaben=null) bereinigen + mitspielerRepository.deleteAll(existing.getMitspieler()); + sessionRepository.delete(existing); + } + VanillaGameEntity entity = new VanillaGameEntity(); + 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.setLevel(1); + entity.setSetupId(session.getSetupId()); + sessionRepository.save(entity); + LOGGER.debug("VanillaGame gestartet [sessionId={}, userId={}, aufgabenProLevel={}]", + entity.getSessionId(), entity.getUserId(), entity.getAufgabenProLevel()); + return ResponseEntity.created( + ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(entity.getSessionId()).toUri() + ).build(); + } + + @DeleteMapping + public ResponseEntity deleteSession(@RequestBody VanillaGame session) { + return sessionRepository.findById(session.getSessionId()) + .map(entity -> { + mitspielerRepository.deleteAll(entity.getMitspieler()); + sessionRepository.delete(entity); + return ResponseEntity.accepted().build(); + }) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping("/{sessionId}/abgeschlossen") + public ResponseEntity spielAbgeschlossen(@PathVariable UUID sessionId) { + VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + ORDENTLICH_BEENDET.add(sessionId); + mitspielerRepository.deleteAll(entity.getMitspieler()); + sessionRepository.delete(entity); + return ResponseEntity.accepted().build(); + } + + /** Prüft ob eine Session ordentlich (nicht abgebrochen) beendet wurde. */ + @GetMapping("/{sessionId}/beendet") + public ResponseEntity istBeendet(@PathVariable UUID sessionId) { + if (ORDENTLICH_BEENDET.remove(sessionId)) return ResponseEntity.ok().build(); + return ResponseEntity.notFound().build(); + } + + @DeleteMapping("/{sessionId}/verlassen") + public ResponseEntity verlasseSpiel(@PathVariable UUID sessionId, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) return ResponseEntity.notFound().build(); + + VanillaMitspielerEntity 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 Vanilla-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)); + + mitspielerRepository.deleteAll(session.getMitspieler()); + sessionRepository.delete(session); + return ResponseEntity.accepted().build(); + } + + @PostMapping("/{sessionId}/aufgaben") + public ResponseEntity setAufgaben(@RequestBody AufgabenList list, @PathVariable UUID sessionId) { + try { + if (list.size() > 1000) { + return ResponseEntity.badRequest().build(); + } + String aufgaben = objectMapper.writeValueAsString(list); + VanillaGameEntity 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() == VanillaEinladungEntity.Status.ACCEPTED_OWN + || e.getStatus() == VanillaEinladungEntity.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 getNextAufgabe(@PathVariable UUID sessionId) { + try { + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null || session.getAufgaben() == null) { + return ResponseEntity.badRequest().build(); + } + session.setLetzteAktivitaet(LocalDateTime.now()); + VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(session); + VanillaAufgabeAnzeige 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 Vanilla-Aufgabe [sessionId={}, level={}, aufgaben={}/{}]", + sessionId, session.getLevel(), session.getAufgabenAufAktuellemLevel(), + session.getAufgabenProLevel()); + } + return ResponseEntity.ok(next); + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping("/{sessionId}/mitspieler") + public ResponseEntity addMitspieler(@RequestBody VanillaMitspieler mitspieler, @PathVariable UUID sessionId) { + if (mitspieler.getName() == null || mitspieler.getVerfuegbareWerkzeuge() == null || mitspieler.getVerfuegbareWerkzeuge().isEmpty()) { + return ResponseEntity.badRequest().build(); + } + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) { + return ResponseEntity.badRequest().build(); + } + // Max 2 Mitspieler (1 Host + max 1 Gast) + if (session.getMitspieler().size() >= 2) { + return ResponseEntity.status(409).build(); + } + VanillaMitspielerEntity entity = new VanillaMitspielerEntity(); + entity.setMitspielerId(UUID.randomUUID()); + entity.setName(mitspieler.getName()); + entity.setWerkzeuge(new ArrayList<>(mitspieler.getVerfuegbareWerkzeuge())); + entity.setUserId(mitspieler.getUserId()); + entity.setEigenesGeraet(mitspieler.isEigenesGeraet()); + entity.setSession(session); + mitspielerRepository.save(entity); + return ResponseEntity.accepted().build(); + } + + @GetMapping("/{sessionId}/finisher") + public ResponseEntity> getFinisher(@PathVariable UUID sessionId) { + try { + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) return ResponseEntity.badRequest().build(); + VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(session); + return ResponseEntity.ok(durchfuehren.getFinisher()); + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + return ResponseEntity.internalServerError().build(); + } + } + + @PostMapping("/{sessionId}/backToLevel5") + public ResponseEntity backToLevel5(@PathVariable UUID sessionId) { + try { + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) return ResponseEntity.badRequest().build(); + VanillaGameDurchfuehren durchfuehren = new VanillaGameDurchfuehren(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> getMeinMitspieler(@PathVariable UUID sessionId, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + VanillaGameEntity 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 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 AbschliessenResponse(List abgelaufeneSperren) {} + + @PostMapping("/{sessionId}/active-task/abschliessen") + public ResponseEntity activeTaskAbschliessen( + @PathVariable UUID sessionId, @RequestBody AbschliessenRequest req) { + VanillaGameEntity session = sessionRepository.findById(sessionId).orElse(null); + if (session == null) return ResponseEntity.notFound().build(); + // Vanilla: keine Sperren/Strafen – einfach activeTask löschen + session.setActiveTaskJson(null); + session.setTaskStartedAt(null); + sessionRepository.save(session); + return ResponseEntity.ok(new AbschliessenResponse(List.of())); + } + + record ActiveTaskRequest(String taskJson, LocalDateTime timerStartedAt) {} + record ActiveTaskResponse(String taskJson, Long elapsedSeconds) {} + + @PutMapping("/{sessionId}/active-task") + public ResponseEntity setActiveTask(@PathVariable UUID sessionId, @RequestBody ActiveTaskRequest req) { + VanillaGameEntity 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 clearActiveTask(@PathVariable UUID sessionId) { + VanillaGameEntity 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 getActiveTask(@PathVariable UUID sessionId) { + VanillaGameEntity 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)); + } + + // ── Debug-Endpoint: vollständiger Entity-Zustand ── + @GetMapping("/{sessionId}/debug") + public ResponseEntity> debug(@PathVariable UUID sessionId) { + VanillaGameEntity entity = sessionRepository.findById(sessionId).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + + Map 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("taskStartedAt", entity.getTaskStartedAt()); + session.put("hatAufgaben", entity.getAufgaben() != null); + session.put("hatActiveTask", entity.getActiveTaskJson() != null); + + List> mitspielerList = entity.getMitspieler().stream().map(m -> { + Map mp = new LinkedHashMap<>(); + mp.put("mitspielerId", m.getMitspielerId()); + mp.put("name", m.getName()); + mp.put("userId", m.getUserId()); + mp.put("werkzeuge", m.getWerkzeuge()); + mp.put("eigenesGeraet", m.isEigenesGeraet()); + return mp; + }).toList(); + + Map result = new LinkedHashMap<>(); + result.put("session", session); + result.put("mitspieler", mitspielerList); + return ResponseEntity.ok(result); + } + + private VanillaGame toSession(VanillaGameEntity entity) { + VanillaGame session = new VanillaGame(); + session.setSessionId(entity.getSessionId()); + session.setUserId(entity.getUserId()); + session.setAufgabenProLevel(entity.getAufgabenProLevel()); + session.setLevel(entity.getLevel()); + session.setAufgabenAufAktuellemLevel(entity.getAufgabenAufAktuellemLevel()); + session.setStartZeit(entity.getStartZeit()); + session.setLetzteAktivitaet(entity.getLetzteAktivitaet()); + return session; + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.java new file mode 100644 index 0000000..2926253 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaSetupDraftController.java @@ -0,0 +1,71 @@ +package de.oaa.xxx.games.vanilla.controller; + +import de.oaa.xxx.games.vanilla.entity.VanillaSetupDraftEntity; +import de.oaa.xxx.games.vanilla.repository.VanillaSetupDraftRepository; +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("/vanilla/setup") +@Transactional +public class VanillaSetupDraftController { + + private final VanillaSetupDraftRepository draftRepository; + private final UserService userService; + + public VanillaSetupDraftController(VanillaSetupDraftRepository draftRepository, UserService userService) { + this.draftRepository = draftRepository; + this.userService = userService; + } + + record DraftRequest(String setupId, String settingsJson, String setupJson, String gruppenJson) {} + + @GetMapping + public ResponseEntity> 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 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 saveDraft(@RequestBody DraftRequest req, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + VanillaSetupDraftEntity d = draftRepository.findByUserId(userId) + .orElseGet(() -> { VanillaSetupDraftEntity n = new VanillaSetupDraftEntity(); 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 deleteDraft(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + draftRepository.findByUserId(userId).ifPresent(draftRepository::delete); + return ResponseEntity.accepted().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaToyController.java b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaToyController.java new file mode 100644 index 0000000..c76afbe --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/controller/VanillaToyController.java @@ -0,0 +1,266 @@ +package de.oaa.xxx.games.vanilla.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.AufgabenGruppeRepository; +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.Map; +import java.util.Set; +import java.util.UUID; + +@RestController +@RequestMapping("/vanilla/toy") +@Transactional +public class VanillaToyController { + + private static final Logger LOGGER = LoggerFactory.getLogger(VanillaToyController.class); + private static final int DEFAULT_PAGE_SIZE = 12; + + private final ToyRepository toyRepository; + private final UserService userService; + private final GruppenAboRepository aboRepository; + private final AufgabenGruppeRepository gruppeRepository; + private final SubscriptionLimitService limitService; + + public VanillaToyController(ToyRepository toyRepository, + UserService userService, + GruppenAboRepository aboRepository, + AufgabenGruppeRepository gruppeRepository, + SubscriptionLimitService limitService) { + this.toyRepository = toyRepository; + this.userService = userService; + this.aboRepository = aboRepository; + this.gruppeRepository = gruppeRepository; + this.limitService = limitService; + } + + @GetMapping("/list/user") + public ResponseEntity listUser( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size, + Principal principal) { + UserEntity user = userService.requireUser(principal); + Page result = toyRepository.findByUserId( + user.getUserId(), PageRequest.of(page, size, Sort.by("name"))); + return ResponseEntity.ok(toToyPage(result)); + } + + @GetMapping("/list/system") + public ResponseEntity listSystem( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "" + DEFAULT_PAGE_SIZE) int size) { + Page 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 vanilla-safe groups' items. + */ + @GetMapping("/available") + public ResponseEntity> available(Principal principal) { + UserEntity user = userService.requireUser(principal); + + List own = toyRepository.findByUserId(user.getUserId(), PageRequest.of(0, 500, Sort.by("name"))).getContent(); + List system = toyRepository.findByUserIdIsNull(PageRequest.of(0, 500, Sort.by("name"))).getContent(); + + Set knownIds = new HashSet<>(); + own.forEach(t -> knownIds.add(t.getToyId())); + system.forEach(t -> knownIds.add(t.getToyId())); + + Set fromAbos = new HashSet<>(); + aboRepository.findByUserId(user.getUserId()).forEach(abo -> { + AufgabenGruppeEntity gruppe = abo.getAufgabenGruppe(); + // Only include toys from vanilla-safe groups (no Strafen, no Sperren) + if (!gruppe.getStrafen().isEmpty() || !gruppe.getSperren().isEmpty()) return; + gruppe.getAufgaben().forEach(a -> { + if (a.getBenoetigteToys() != null) + a.getBenoetigteToys().stream().filter(t -> !knownIds.contains(t.getToyId())).forEach(fromAbos::add); + }); + }); + + List 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); + } + + /** + * Returns all distinct toys required by aufgaben and finisher of the given gruppe IDs. + * Only vanilla-safe groups (no Strafen, no Sperren) are considered. + */ + @GetMapping("/required") + public ResponseEntity> required(@RequestParam List gruppenIds) { + Map toyMap = new java.util.LinkedHashMap<>(); + gruppeRepository.findAllById(gruppenIds).forEach(gruppe -> { + gruppe.getAufgaben().forEach(a -> { + if (a.getBenoetigteToys() != null) + a.getBenoetigteToys().forEach(t -> toyMap.putIfAbsent(t.getToyId(), t)); + }); + gruppe.getFinisher().forEach(f -> { + if (f.getBenoetigteToys() != null) + f.getBenoetigteToys().forEach(t -> toyMap.putIfAbsent(t.getToyId(), t)); + }); + }); + List result = toyMap.values().stream() + .sorted(Comparator.comparing(ToyEntity::getName, String.CASE_INSENSITIVE_ORDER)) + .map(ToyEntity::toToy) + .toList(); + return ResponseEntity.ok(result); + } + + @GetMapping("/{toyId}") + public ResponseEntity get(@PathVariable UUID toyId) { + return toyRepository.findById(toyId) + .map(entity -> ResponseEntity.ok(entity.toToy())) + .orElse(ResponseEntity.noContent().build()); + } + + @PostMapping + public ResponseEntity 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 Vanilla-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 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 für Vanilla (Kopie: {})", user.getUserId(), toyId, copy.getToyId()); + return ResponseEntity.status(201).build(); + } + + @PutMapping("/{toyId}") + public ResponseEntity 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 Vanilla-Toy {} aktualisiert", user.getUserId(), toyId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{toyId}") + public ResponseEntity 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 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; + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.java b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.java new file mode 100644 index 0000000..fce76c4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaEinladungEntity.java @@ -0,0 +1,22 @@ +package de.oaa.xxx.games.vanilla.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter @Setter @Entity @Table(name = "vanilla_einladung") +public class VanillaEinladungEntity { + 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; +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.java b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.java new file mode 100644 index 0000000..258e1d0 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaGameEntity.java @@ -0,0 +1,26 @@ +package de.oaa.xxx.games.vanilla.entity; + +import jakarta.persistence.*; +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 = "vanilla_game") +public class VanillaGameEntity { + @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 mitspieler = new ArrayList<>(); + @Column private Integer aufgabenProLevel; + @Column private Integer level; + @Column private Integer aufgabenAufAktuellemLevel; + @Column(columnDefinition = "TEXT") private String aufgaben; + @Column(columnDefinition = "TEXT") private String activeTaskJson; + @Column private LocalDateTime taskStartedAt; + @Column private UUID setupId; +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.java b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.java new file mode 100644 index 0000000..b2d7121 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaMitspielerEntity.java @@ -0,0 +1,54 @@ +package de.oaa.xxx.games.vanilla.entity; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +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 = "vanilla_mitspieler") +public class VanillaMitspielerEntity { + @Id + @Column + private UUID mitspielerId; + @Column + private UUID userId; + @Column + private boolean eigenesGeraet; + @Column + private String name; + @Enumerated(EnumType.STRING) + @ElementCollection(targetClass = Werkzeug.class, fetch = FetchType.EAGER) + @CollectionTable(name = "vanilla_mitspieler_werkzeuge", joinColumns = @JoinColumn(name = "mitspielerId")) + @Column(name = "werkzeug") + private List werkzeuge = new ArrayList<>(); + @ManyToOne + @JoinColumn(name = "sessionId", nullable = false) + private VanillaGameEntity session; + + public de.oaa.xxx.games.vanilla.VanillaMitspieler toMitspieler() { + de.oaa.xxx.games.vanilla.VanillaMitspieler m = new de.oaa.xxx.games.vanilla.VanillaMitspieler(); + m.setId(mitspielerId); + m.setUserId(userId); + m.setEigenesGeraet(eigenesGeraet); + m.setName(name); + m.setVerfuegbareWerkzeuge(new ArrayList<>(werkzeuge)); + return m; + } +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.java b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.java new file mode 100644 index 0000000..06896ea --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/entity/VanillaSetupDraftEntity.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.games.vanilla.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter @Setter @Entity @Table(name = "vanilla_setup_draft") +public class VanillaSetupDraftEntity { + @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; +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.java b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.java new file mode 100644 index 0000000..fc4f5ad --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaEinladungRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.games.vanilla.repository; +import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity; +import de.oaa.xxx.games.vanilla.entity.VanillaEinladungEntity.Status; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.UUID; +public interface VanillaEinladungRepository extends JpaRepository { + List findBySetupId(UUID setupId); + List findByInviteeIdAndStatus(UUID inviteeId, Status status); + List findByInviterIdAndStatus(UUID inviterId, Status status); +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.java b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.java new file mode 100644 index 0000000..f1f84ba --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaGameRepository.java @@ -0,0 +1,8 @@ +package de.oaa.xxx.games.vanilla.repository; +import de.oaa.xxx.games.vanilla.entity.VanillaGameEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; +public interface VanillaGameRepository extends JpaRepository { + Optional findByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.java b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.java new file mode 100644 index 0000000..8a11087 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaMitspielerRepository.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.games.vanilla.repository; +import de.oaa.xxx.games.vanilla.entity.VanillaMitspielerEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.UUID; +public interface VanillaMitspielerRepository extends JpaRepository {} diff --git a/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.java b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.java new file mode 100644 index 0000000..e1e9474 --- /dev/null +++ b/src/main/java/de/oaa/xxx/games/vanilla/repository/VanillaSetupDraftRepository.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.games.vanilla.repository; +import de.oaa.xxx.games.vanilla.entity.VanillaSetupDraftEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; +public interface VanillaSetupDraftRepository extends JpaRepository { + Optional findByUserId(UUID userId); + Optional findBySetupId(String setupId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/AnfrageStatus.java b/src/main/java/de/oaa/xxx/gruppe/AnfrageStatus.java new file mode 100644 index 0000000..597e1b7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/AnfrageStatus.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.gruppe; + +public enum AnfrageStatus { + AUSSTEHEND, GENEHMIGT, ABGELEHNT +} diff --git a/src/main/java/de/oaa/xxx/gruppe/BeitragTyp.java b/src/main/java/de/oaa/xxx/gruppe/BeitragTyp.java new file mode 100644 index 0000000..3fa881c --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/BeitragTyp.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.gruppe; + +public enum BeitragTyp { + TEXT, UMFRAGE +} diff --git a/src/main/java/de/oaa/xxx/gruppe/GruppeController.java b/src/main/java/de/oaa/xxx/gruppe/GruppeController.java new file mode 100644 index 0000000..a669b18 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/GruppeController.java @@ -0,0 +1,512 @@ +package de.oaa.xxx.gruppe; + +import de.oaa.xxx.gruppe.dto.*; +import de.oaa.xxx.gruppe.entity.*; +import de.oaa.xxx.gruppe.repository.*; +import de.oaa.xxx.social.entity.KommentarEntity; +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; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; + +@RestController +@RequestMapping("/gruppen") +public class GruppeController { + + private static final Logger LOGGER = LoggerFactory.getLogger(GruppeController.class); + + private final GruppeRepository gruppeRepository; + private final GruppenmitgliedRepository mitgliedRepository; + private final BeitrittsanfrageRepository anfrageRepository; + private final GruppenbeitragRepository beitragRepository; + private final UmfrageOptionRepository optionRepository; + private final UmfrageStimmeRepository stimmeRepository; + private final GruppenbeitragLikeRepository likeRepository; + private final BeitragMeldungRepository meldungRepository; + private final KommentarRepository kommentarRepository; + private final UserRepository userRepository; + private final UserService userService; + + public GruppeController(GruppeRepository gruppeRepository, + GruppenmitgliedRepository mitgliedRepository, + BeitrittsanfrageRepository anfrageRepository, + GruppenbeitragRepository beitragRepository, + UmfrageOptionRepository optionRepository, + UmfrageStimmeRepository stimmeRepository, + GruppenbeitragLikeRepository likeRepository, + BeitragMeldungRepository meldungRepository, + KommentarRepository kommentarRepository, + UserRepository userRepository, + UserService userService) { + this.gruppeRepository = gruppeRepository; + this.mitgliedRepository = mitgliedRepository; + this.anfrageRepository = anfrageRepository; + this.beitragRepository = beitragRepository; + this.optionRepository = optionRepository; + this.stimmeRepository = stimmeRepository; + this.likeRepository = likeRepository; + this.meldungRepository = meldungRepository; + this.kommentarRepository = kommentarRepository; + this.userRepository = userRepository; + this.userService = userService; + } + + record CreateGruppeRequest(String name, String beschreibung, String bild, boolean isPrivate) {} + record JoinRequest(String nachricht) {} + record UpdateGruppeRequest(String name, String beschreibung, String bild, Boolean isPrivate) {} + + // ── GET /gruppe/search?q= ── + + @GetMapping("/search") + public ResponseEntity> search(@RequestParam String q, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + List gruppen = gruppeRepository.findByNameContainingIgnoreCase(q) + .stream().limit(30).toList(); + List gruppeIds = gruppen.stream().map(GruppeEntity::getGruppeId).toList(); + Map latestActivity = buildLatestActivityMap(gruppeIds); + List result = gruppen.stream() + .map(g -> toDto(g, myId)) + .sorted((a, b) -> { + LocalDateTime la = latestActivity.getOrDefault(a.gruppeId(), a.createdAt()); + LocalDateTime lb = latestActivity.getOrDefault(b.gruppeId(), b.createdAt()); + return lb.compareTo(la); + }) + .toList(); + return ResponseEntity.ok(result); + } + + // ── GET /gruppe/mine ── + + @GetMapping("/mine") + public ResponseEntity> mine(Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + List gruppen = mitgliedRepository.findByUserId(myId) + .stream() + .map(m -> gruppeRepository.findById(m.getGruppeId()).orElse(null)) + .filter(Objects::nonNull) + .toList(); + List gruppeIds = gruppen.stream().map(GruppeEntity::getGruppeId).toList(); + Map latestActivity = buildLatestActivityMap(gruppeIds); + List result = gruppen.stream() + .map(g -> toDto(g, myId)) + .sorted((a, b) -> { + LocalDateTime la = latestActivity.getOrDefault(a.gruppeId(), a.createdAt()); + LocalDateTime lb = latestActivity.getOrDefault(b.gruppeId(), b.createdAt()); + return lb.compareTo(la); + }) + .toList(); + return ResponseEntity.ok(result); + } + + // ── GET /gruppe/{id} ── + + @GetMapping("/{id}") + public ResponseEntity getGruppe(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + return gruppeRepository.findById(id) + .map(g -> ResponseEntity.ok(toDto(g, myId))) + .orElse(ResponseEntity.notFound().build()); + } + + // ── POST /gruppe ── + + @PostMapping + public ResponseEntity createGruppe(@RequestBody CreateGruppeRequest req, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + + GruppeEntity gruppe = new GruppeEntity(); + gruppe.setGruppeId(UUID.randomUUID()); + gruppe.setName(req.name().trim()); + gruppe.setBeschreibung(req.beschreibung()); + gruppe.setBild(req.bild()); + gruppe.setPrivate(req.isPrivate()); + gruppe.setCreatedAt(LocalDateTime.now()); + gruppe.setCreatedByUserId(myId); + gruppeRepository.save(gruppe); + + GruppenmitgliedEntity admin = new GruppenmitgliedEntity(); + admin.setMitgliedId(UUID.randomUUID()); + admin.setGruppeId(gruppe.getGruppeId()); + admin.setUserId(myId); + admin.setRolle(GruppenRolle.ADMIN); + admin.setJoinedAt(LocalDateTime.now()); + mitgliedRepository.save(admin); + LOGGER.info("User {} hat Gruppe '{}' ({}) erstellt", myId, gruppe.getName(), gruppe.getGruppeId()); + + return ResponseEntity.status(201).body(toDto(gruppe, myId)); + } + + // ── PUT /gruppe/{id} ── + + @PutMapping("/{id}") + public ResponseEntity updateGruppe(@PathVariable UUID id, + @RequestBody UpdateGruppeRequest req, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + var gruppeOpt = gruppeRepository.findById(id); + if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build(); + GruppeEntity gruppe = gruppeOpt.get(); + + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + if (req.name() != null && !req.name().isBlank()) gruppe.setName(req.name().trim()); + if (req.beschreibung() != null) gruppe.setBeschreibung(req.beschreibung()); + if (req.bild() != null) gruppe.setBild(req.bild()); + if (req.isPrivate() != null) gruppe.setPrivate(req.isPrivate()); + gruppeRepository.save(gruppe); + LOGGER.debug("User {} hat Gruppe {} aktualisiert", myId, id); + + return ResponseEntity.ok(toDto(gruppe, myId)); + } + + // ── DELETE /gruppe/{id} ── + + @DeleteMapping("/{id}") + public ResponseEntity deleteGruppe(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + var gruppeOpt = gruppeRepository.findById(id); + if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + // Cascade delete + List beitraege = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(id); + for (GruppenbeitragEntity b : beitraege) { + UUID bid = b.getBeitragId(); + meldungRepository.deleteByBeitragId(bid); + stimmeRepository.deleteByBeitragId(bid); + optionRepository.deleteByBeitragId(bid); + likeRepository.deleteByBeitragId(bid); + // Kommentare on GROUP_POST + List kommentare = kommentarRepository + .findByTargetTypeAndTargetIdOrderByCreatedAtAsc("GROUP_POST", bid); + for (KommentarEntity k : kommentare) { + kommentarRepository.delete(k); + } + } + beitragRepository.deleteByGruppeId(id); + anfrageRepository.deleteByGruppeId(id); + mitgliedRepository.deleteByGruppeId(id); + gruppeRepository.deleteById(id); + LOGGER.info("User {} hat Gruppe {} gelöscht", myId, id); + + return ResponseEntity.noContent().build(); + } + + // ── POST /gruppe/{id}/join ── + + @PostMapping("/{id}/join") + public ResponseEntity join(@PathVariable UUID id, + @RequestBody(required = false) JoinRequest req, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + var gruppeOpt = gruppeRepository.findById(id); + if (gruppeOpt.isEmpty()) return ResponseEntity.notFound().build(); + GruppeEntity gruppe = gruppeOpt.get(); + + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isPresent()) + return ResponseEntity.status(409).build(); + + if (gruppe.isPrivate()) { + // Check no pending request already + var existingReq = anfrageRepository.findByGruppeIdAndUserId(id, myId); + if (existingReq.isPresent() && existingReq.get().getStatus() == AnfrageStatus.AUSSTEHEND) + return ResponseEntity.status(409).build(); + + BeitrittsanfrageEntity anfrage = new BeitrittsanfrageEntity(); + anfrage.setAnfrageId(UUID.randomUUID()); + anfrage.setGruppeId(id); + anfrage.setUserId(myId); + anfrage.setNachricht(req != null ? req.nachricht() : null); + anfrage.setAngefragtAt(LocalDateTime.now()); + anfrage.setStatus(AnfrageStatus.AUSSTEHEND); + anfrageRepository.save(anfrage); + LOGGER.info("User {} hat Beitrittsanfrage für private Gruppe {} gestellt", myId, id); + return ResponseEntity.status(201).build(); + } else { + GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity(); + mitglied.setMitgliedId(UUID.randomUUID()); + mitglied.setGruppeId(id); + mitglied.setUserId(myId); + mitglied.setRolle(GruppenRolle.MITGLIED); + mitglied.setJoinedAt(LocalDateTime.now()); + mitgliedRepository.save(mitglied); + LOGGER.info("User {} ist Gruppe {} beigetreten", myId, id); + return ResponseEntity.status(201).build(); + } + } + + // ── DELETE /gruppe/{id}/leave ── + + @DeleteMapping("/{id}/leave") + public ResponseEntity leave(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + mitgliedRepository.deleteByGruppeIdAndUserId(id, myId); + LOGGER.info("User {} hat Gruppe {} verlassen", myId, id); + return ResponseEntity.noContent().build(); + } + + // ── GET /gruppe/{id}/members ── + + @GetMapping("/{id}/members") + public ResponseEntity>> getMembers(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + + List> result = mitgliedRepository.findByGruppeId(id).stream() + .map(m -> { + Map map = new LinkedHashMap<>(); + map.put("mitgliedId", m.getMitgliedId()); + map.put("userId", m.getUserId()); + map.put("rolle", m.getRolle().name()); + map.put("joinedAt", m.getJoinedAt()); + userRepository.findById(m.getUserId()).ifPresent(u -> { + map.put("userName", u.getName()); + map.put("userPicture", u.getProfilePicture()); + }); + return map; + }) + .toList(); + return ResponseEntity.ok(result); + } + + // ── DELETE /gruppe/{id}/members/{userId} ── + + @DeleteMapping("/{id}/members/{userId}") + public ResponseEntity removeMember(@PathVariable UUID id, + @PathVariable UUID userId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + mitgliedRepository.deleteByGruppeIdAndUserId(id, userId); + LOGGER.warn("Admin {} hat User {} aus Gruppe {} entfernt", myId, userId, id); + return ResponseEntity.noContent().build(); + } + + // ── POST /gruppe/{id}/members/{userId}/promote ── + + @PostMapping("/{id}/members/{userId}/promote") + public ResponseEntity promote(@PathVariable UUID id, + @PathVariable UUID userId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + var m = mitgliedRepository.findFirstByGruppeIdAndUserId(id, userId); + if (m.isEmpty()) return ResponseEntity.notFound().build(); + m.get().setRolle(GruppenRolle.ADMIN); + mitgliedRepository.save(m.get()); + LOGGER.info("Admin {} hat User {} in Gruppe {} zum Admin befördert", myId, userId, id); + return ResponseEntity.ok().build(); + } + + // ── GET /gruppe/{id}/requests ── + + @GetMapping("/{id}/requests") + public ResponseEntity> getRequests(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + List dtos = anfrageRepository + .findByGruppeIdAndStatus(id, AnfrageStatus.AUSSTEHEND) + .stream() + .map(a -> { + UserEntity u = userRepository.findById(a.getUserId()).orElse(null); + return new BeitrittsanfrageDto(a.getAnfrageId(), a.getGruppeId(), a.getUserId(), + u != null ? u.getName() : "Unbekannt", + u != null ? u.getProfilePicture() : null, + a.getNachricht(), a.getAngefragtAt()); + }) + .toList(); + return ResponseEntity.ok(dtos); + } + + // ── POST /gruppe/{id}/requests/{reqId}/approve ── + + @PostMapping("/{id}/requests/{reqId}/approve") + public ResponseEntity approveRequest(@PathVariable UUID id, + @PathVariable UUID reqId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + var anfOpt = anfrageRepository.findById(reqId); + if (anfOpt.isEmpty()) return ResponseEntity.notFound().build(); + BeitrittsanfrageEntity anfrage = anfOpt.get(); + anfrage.setStatus(AnfrageStatus.GENEHMIGT); + anfrageRepository.save(anfrage); + LOGGER.info("Admin {} hat Beitrittsanfrage {} (User: {}) für Gruppe {} genehmigt", myId, reqId, anfrage.getUserId(), id); + + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, anfrage.getUserId()).isEmpty()) { + GruppenmitgliedEntity mitglied = new GruppenmitgliedEntity(); + mitglied.setMitgliedId(UUID.randomUUID()); + mitglied.setGruppeId(id); + mitglied.setUserId(anfrage.getUserId()); + mitglied.setRolle(GruppenRolle.MITGLIED); + mitglied.setJoinedAt(LocalDateTime.now()); + mitgliedRepository.save(mitglied); + } + + return ResponseEntity.ok().build(); + } + + // ── DELETE /gruppe/{id}/requests/{reqId} ── + + @DeleteMapping("/{id}/requests/{reqId}") + public ResponseEntity rejectRequest(@PathVariable UUID id, + @PathVariable UUID reqId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (!isAdmin(id, myId)) return ResponseEntity.status(403).build(); + + var anfOpt = anfrageRepository.findById(reqId); + if (anfOpt.isEmpty()) return ResponseEntity.notFound().build(); + BeitrittsanfrageEntity anfrage = anfOpt.get(); + anfrage.setStatus(AnfrageStatus.ABGELEHNT); + anfrageRepository.save(anfrage); + LOGGER.debug("Admin {} hat Beitrittsanfrage {} für Gruppe {} abgelehnt", myId, reqId, id); + return ResponseEntity.noContent().build(); + } + + // ── GET /gruppen/reports/pending/count ── + + @GetMapping("/reports/pending/count") + public ResponseEntity pendingReportsCount(Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + long count = mitgliedRepository.findByUserId(myId).stream() + .filter(m -> m.getRolle() == GruppenRolle.ADMIN) + .mapToLong(m -> { + List beitraege = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(m.getGruppeId()); + List ids = beitraege.stream().map(GruppenbeitragEntity::getBeitragId).toList(); + return ids.isEmpty() ? 0L : meldungRepository.findByBeitragIdIn(ids).size(); + }) + .sum(); + return ResponseEntity.ok(count); + } + + // ── GET /gruppen/requests/pending/count ── + + @GetMapping("/requests/pending/count") + public ResponseEntity pendingRequestCount(Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + long count = mitgliedRepository.findByUserId(myId).stream() + .filter(m -> m.getRolle() == GruppenRolle.ADMIN) + .mapToLong(m -> anfrageRepository.findByGruppeIdAndStatus(m.getGruppeId(), AnfrageStatus.AUSSTEHEND).size()) + .sum(); + return ResponseEntity.ok(count); + } + + // ── GET /gruppe/requests/mine ── + + @GetMapping("/requests/mine") + public ResponseEntity>> myRequests(Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + List> result = anfrageRepository.findByUserIdAndStatus(myId, AnfrageStatus.AUSSTEHEND) + .stream() + .map(a -> { + Map map = new LinkedHashMap<>(); + map.put("anfrageId", a.getAnfrageId()); + map.put("gruppeId", a.getGruppeId()); + map.put("nachricht", a.getNachricht()); + map.put("angefragtAt", a.getAngefragtAt()); + gruppeRepository.findById(a.getGruppeId()).ifPresent(g -> map.put("gruppeName", g.getName())); + return map; + }) + .toList(); + return ResponseEntity.ok(result); + } + + // ── DELETE /gruppe/{id}/requests/mine (withdraw own request) ── + + @DeleteMapping("/{id}/requests/mine") + public ResponseEntity withdrawRequest(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + anfrageRepository.findByGruppeIdAndUserId(id, myId).ifPresent(a -> { + if (a.getStatus() == AnfrageStatus.AUSSTEHEND) { + anfrageRepository.delete(a); + LOGGER.debug("User {} hat eigene Beitrittsanfrage für Gruppe {} zurückgezogen", myId, id); + } + }); + return ResponseEntity.noContent().build(); + } + + // ── Helpers ── + + private Map buildLatestActivityMap(List gruppeIds) { + if (gruppeIds.isEmpty()) return Map.of(); + Map map = new HashMap<>(); + beitragRepository.findLatestCreatedAtByGruppeIds(gruppeIds).forEach(row -> { + UUID gId = (UUID) row[0]; + LocalDateTime latest = (LocalDateTime) row[1]; + map.put(gId, latest); + }); + return map; + } + + private UUID resolveMyId(Principal principal) { + if (principal == null) return null; + return userService.requireUser(principal).getUserId(); + } + + private boolean isAdmin(UUID gruppeId, UUID userId) { + return mitgliedRepository.findFirstByGruppeIdAndUserId(gruppeId, userId) + .map(m -> m.getRolle() == GruppenRolle.ADMIN) + .orElse(false); + } + + GruppeDto toDto(GruppeEntity g, UUID myId) { + long memberCount = mitgliedRepository.countByGruppeId(g.getGruppeId()); + long postCount = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(g.getGruppeId()).size(); + String myRole = mitgliedRepository.findFirstByGruppeIdAndUserId(g.getGruppeId(), myId) + .map(m -> m.getRolle().name()) + .orElse(null); + String myRequestStatus = null; + if (myRole == null) { + myRequestStatus = anfrageRepository.findByGruppeIdAndUserId(g.getGruppeId(), myId) + .filter(a -> a.getStatus() == AnfrageStatus.AUSSTEHEND) + .map(a -> a.getStatus().name()) + .orElse(null); + } + return new GruppeDto(g.getGruppeId(), g.getName(), g.getBeschreibung(), g.getBild(), + g.isPrivate(), g.getCreatedAt(), memberCount, postCount, myRole, myRequestStatus); + } +} diff --git a/src/main/java/de/oaa/xxx/gruppe/GruppenRolle.java b/src/main/java/de/oaa/xxx/gruppe/GruppenRolle.java new file mode 100644 index 0000000..d454d6d --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/GruppenRolle.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.gruppe; + +public enum GruppenRolle { + ADMIN, MITGLIED +} diff --git a/src/main/java/de/oaa/xxx/gruppe/GruppenbeitragController.java b/src/main/java/de/oaa/xxx/gruppe/GruppenbeitragController.java new file mode 100644 index 0000000..fc82ef1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/GruppenbeitragController.java @@ -0,0 +1,353 @@ +package de.oaa.xxx.gruppe; + +import de.oaa.xxx.gruppe.dto.*; +import de.oaa.xxx.gruppe.entity.*; +import de.oaa.xxx.gruppe.repository.*; +import de.oaa.xxx.social.LikeService; +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; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; + +@RestController +public class GruppenbeitragController { + + private static final Logger LOGGER = LoggerFactory.getLogger(GruppenbeitragController.class); + + private final GruppeRepository gruppeRepository; + private final GruppenmitgliedRepository mitgliedRepository; + private final GruppenbeitragRepository beitragRepository; + private final UmfrageOptionRepository optionRepository; + private final UmfrageStimmeRepository stimmeRepository; + private final GruppenbeitragLikeRepository likeRepository; + private final BeitragMeldungRepository meldungRepository; + private final KommentarRepository kommentarRepository; + private final UserRepository userRepository; + private final UserService userService; + private final LikeService likeService; + + public GruppenbeitragController(GruppeRepository gruppeRepository, + GruppenmitgliedRepository mitgliedRepository, + GruppenbeitragRepository beitragRepository, + UmfrageOptionRepository optionRepository, + UmfrageStimmeRepository stimmeRepository, + GruppenbeitragLikeRepository likeRepository, + BeitragMeldungRepository meldungRepository, + KommentarRepository kommentarRepository, + UserRepository userRepository, + UserService userService, + LikeService likeService) { + this.gruppeRepository = gruppeRepository; + this.mitgliedRepository = mitgliedRepository; + this.beitragRepository = beitragRepository; + this.optionRepository = optionRepository; + this.stimmeRepository = stimmeRepository; + this.likeRepository = likeRepository; + this.meldungRepository = meldungRepository; + this.kommentarRepository = kommentarRepository; + this.userRepository = userRepository; + this.userService = userService; + this.likeService = likeService; + } + + record CreateBeitragRequest(String beitragTyp, String text, Boolean multiChoice, List optionen, List bilder) {} + record PostsPage(List posts, boolean hasMore) {} + record VoteRequest(UUID optionId) {} + record ReportRequest(String grund) {} + + // ── GET /gruppen/{id}/posts/{postId} ── + + @GetMapping("/gruppen/{id}/posts/{postId}") + public ResponseEntity getPost(@PathVariable UUID id, + @PathVariable UUID postId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + return beitragRepository.findById(postId) + .map(b -> ResponseEntity.ok(toDto(b, myId))) + .orElse(ResponseEntity.notFound().build()); + } + + // ── GET /gruppen/{id}/posts ── + + @GetMapping("/gruppen/{id}/posts") + public ResponseEntity getPosts(@PathVariable UUID id, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + + Slice slice = beitragRepository + .findByGruppeIdOrderByCreatedAtDesc(id, PageRequest.of(page, size)); + List posts = slice.getContent().stream() + .map(b -> toDto(b, myId)) + .toList(); + return ResponseEntity.ok(new PostsPage(posts, slice.hasNext())); + } + + // ── POST /gruppen/{id}/posts ── + + @PostMapping("/gruppen/{id}/posts") + public ResponseEntity createPost(@PathVariable UUID id, + @RequestBody CreateBeitragRequest req, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + if (gruppeRepository.findById(id).isEmpty()) return ResponseEntity.notFound().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(); + } + + GruppenbeitragEntity beitrag = new GruppenbeitragEntity(); + beitrag.setBeitragId(UUID.randomUUID()); + beitrag.setGruppeId(id); + beitrag.setAuthorId(myId); + beitrag.setBeitragTyp(typ); + beitrag.setText(req.text().trim()); + beitrag.setMultiChoice(typ == BeitragTyp.UMFRAGE ? req.multiChoice() : null); + beitrag.setBilder(req.bilder() != null ? req.bilder() : List.of()); + beitrag.setCreatedAt(LocalDateTime.now()); + beitragRepository.save(beitrag); + LOGGER.debug("User {} hat Beitrag {} (Typ: {}) in Gruppe {} erstellt", myId, beitrag.getBeitragId(), typ, id); + + 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; + UmfrageOptionEntity opt = new UmfrageOptionEntity(); + opt.setOptionId(UUID.randomUUID()); + opt.setBeitragId(beitrag.getBeitragId()); + opt.setText(optText.trim()); + opt.setReihenfolge(i); + optionRepository.save(opt); + } + } + + return ResponseEntity.status(201).body(toDto(beitrag, myId)); + } + + // ── DELETE /gruppen/{id}/posts/{postId} ── + + @DeleteMapping("/gruppen/{id}/posts/{postId}") + public ResponseEntity deletePost(@PathVariable UUID id, + @PathVariable UUID postId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + + var bOpt = beitragRepository.findById(postId); + if (bOpt.isEmpty()) return ResponseEntity.notFound().build(); + GruppenbeitragEntity beitrag = bOpt.get(); + + boolean isAuthor = beitrag.getAuthorId().equals(myId); + boolean isAdmin = mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId) + .map(m -> m.getRolle() == GruppenRolle.ADMIN) + .orElse(false); + if (!isAuthor && !isAdmin) return ResponseEntity.status(403).build(); + + deleteBeitragCascade(beitrag); + LOGGER.info("User {} hat Beitrag {} aus Gruppe {} gelöscht", myId, postId, id); + return ResponseEntity.noContent().build(); + } + + // ── POST /gruppen/{id}/posts/{postId}/like ── + + @PostMapping("/gruppen/{id}/posts/{postId}/like") + public ResponseEntity toggleLike(@PathVariable UUID id, + @PathVariable UUID postId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + + likeService.toggleGruppenbeitragLike(postId, myId); + return ResponseEntity.ok().build(); + } + + // ── POST /gruppen/{id}/posts/{postId}/vote ── + + @PostMapping("/gruppen/{id}/posts/{postId}/vote") + public ResponseEntity vote(@PathVariable UUID id, + @PathVariable UUID postId, + @RequestBody VoteRequest req, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + + var bOpt = beitragRepository.findById(postId); + if (bOpt.isEmpty()) return ResponseEntity.notFound().build(); + GruppenbeitragEntity beitrag = bOpt.get(); + + var optOpt = optionRepository.findById(req.optionId()); + if (optOpt.isEmpty() || !optOpt.get().getBeitragId().equals(postId)) + return ResponseEntity.badRequest().build(); + + boolean isMultiChoice = Boolean.TRUE.equals(beitrag.getMultiChoice()); + + var existingVote = stimmeRepository.findByOptionIdAndUserId(req.optionId(), myId); + if (existingVote.isPresent()) { + // Toggle: remove vote + stimmeRepository.delete(existingVote.get()); + return ResponseEntity.ok().build(); + } + + if (!isMultiChoice) { + // Single-choice: remove all existing votes on this poll + List existing = stimmeRepository.findByBeitragIdAndUserId(postId, myId); + stimmeRepository.deleteAll(existing); + } + + UmfrageStimmeEntity stimme = new UmfrageStimmeEntity(); + stimme.setStimmeId(UUID.randomUUID()); + stimme.setOptionId(req.optionId()); + stimme.setBeitragId(postId); + stimme.setUserId(myId); + stimmeRepository.save(stimme); + LOGGER.debug("User {} hat für Option {} in Beitrag {} gestimmt", myId, req.optionId(), postId); + return ResponseEntity.ok().build(); + } + + // ── POST /gruppen/{id}/posts/{postId}/report ── + + @PostMapping("/gruppen/{id}/posts/{postId}/report") + public ResponseEntity report(@PathVariable UUID id, + @PathVariable UUID postId, + @RequestBody ReportRequest req, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId).isEmpty()) + return ResponseEntity.status(403).build(); + + if (beitragRepository.findById(postId).isEmpty()) return ResponseEntity.notFound().build(); + + var existing = meldungRepository.findByBeitragIdAndMelderId(postId, myId); + if (existing.isPresent()) return ResponseEntity.status(409).build(); + + BeitragMeldungEntity meldung = new BeitragMeldungEntity(); + meldung.setMeldungId(UUID.randomUUID()); + meldung.setBeitragId(postId); + meldung.setMelderId(myId); + meldung.setGrund(req.grund()); + meldung.setGemeldetAt(LocalDateTime.now()); + meldungRepository.save(meldung); + LOGGER.warn("User {} hat Beitrag {} in Gruppe {} gemeldet (Grund: {})", myId, postId, id, req.grund()); + return ResponseEntity.status(201).build(); + } + + // ── GET /gruppen/{id}/reports ── + + @GetMapping("/gruppen/{id}/reports") + public ResponseEntity> getReports(@PathVariable UUID id, Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId) + .map(m -> m.getRolle() != GruppenRolle.ADMIN).orElse(true)) + return ResponseEntity.status(403).build(); + + List beitragIds = beitragRepository.findByGruppeIdOrderByCreatedAtDesc(id) + .stream().map(GruppenbeitragEntity::getBeitragId).toList(); + + List dtos = meldungRepository.findByBeitragIdIn(beitragIds) + .stream() + .map(m -> { + UserEntity melder = userRepository.findById(m.getMelderId()).orElse(null); + return new BeitragMeldungDto(m.getMeldungId(), m.getBeitragId(), m.getMelderId(), + melder != null ? melder.getName() : "Unbekannt", + m.getGrund(), m.getGemeldetAt()); + }) + .toList(); + return ResponseEntity.ok(dtos); + } + + // ── DELETE /gruppen/{id}/reports/{meldungId} ── + + @DeleteMapping("/gruppen/{id}/reports/{meldungId}") + public ResponseEntity dismissReport(@PathVariable UUID id, + @PathVariable UUID meldungId, + Principal principal) { + UUID myId = resolveMyId(principal); + if (myId == null) return ResponseEntity.status(401).build(); + if (mitgliedRepository.findFirstByGruppeIdAndUserId(id, myId) + .map(m -> m.getRolle() != GruppenRolle.ADMIN).orElse(true)) + return ResponseEntity.status(403).build(); + + meldungRepository.deleteById(meldungId); + LOGGER.debug("Admin {} hat Meldung {} in Gruppe {} abgewiesen", myId, meldungId, id); + return ResponseEntity.noContent().build(); + } + + // ── Helpers ── + + private UUID resolveMyId(Principal principal) { + if (principal == null) return null; + return userService.requireUser(principal).getUserId(); + } + + private void deleteBeitragCascade(GruppenbeitragEntity beitrag) { + UUID bid = beitrag.getBeitragId(); + meldungRepository.deleteByBeitragId(bid); + stimmeRepository.deleteByBeitragId(bid); + optionRepository.deleteByBeitragId(bid); + likeRepository.deleteByBeitragId(bid); + var kommentare = kommentarRepository + .findByTargetTypeAndTargetIdOrderByCreatedAtAsc("GROUP_POST", bid); + kommentarRepository.deleteAll(kommentare); + beitragRepository.delete(beitrag); + } + + private GruppenbeitragDto toDto(GruppenbeitragEntity b, UUID myId) { + UserEntity author = userRepository.findById(b.getAuthorId()).orElse(null); + long likeCount = likeRepository.countByBeitragId(b.getBeitragId()); + boolean likedByMe = likeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId).isPresent(); + long kommentarCount = kommentarRepository + .countByTargetTypeAndTargetId("GROUP_POST", b.getBeitragId()); + boolean reported = meldungRepository.findByBeitragIdAndMelderId(b.getBeitragId(), myId).isPresent(); + + List optionen = List.of(); + List myVoteOptionIds = List.of(); + if (b.getBeitragTyp() == BeitragTyp.UMFRAGE) { + optionen = optionRepository.findByBeitragIdOrderByReihenfolge(b.getBeitragId()) + .stream() + .map(o -> new UmfrageOptionDto(o.getOptionId(), o.getText(), o.getReihenfolge(), + stimmeRepository.countByOptionId(o.getOptionId()))) + .toList(); + myVoteOptionIds = stimmeRepository.findByBeitragIdAndUserId(b.getBeitragId(), myId) + .stream() + .map(UmfrageStimmeEntity::getOptionId) + .toList(); + } + + return new GruppenbeitragDto( + b.getBeitragId(), b.getGruppeId(), 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, reported); + } +} diff --git a/src/main/java/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.java b/src/main/java/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.java new file mode 100644 index 0000000..d35648a --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/dto/BeitragMeldungDto.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.gruppe.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record BeitragMeldungDto( + UUID meldungId, + UUID beitragId, + UUID melderId, + String melderName, + String grund, + LocalDateTime gemeldetAt +) {} diff --git a/src/main/java/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.java b/src/main/java/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.java new file mode 100644 index 0000000..8740609 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/dto/BeitrittsanfrageDto.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.gruppe.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record BeitrittsanfrageDto( + UUID anfrageId, + UUID gruppeId, + UUID userId, + String userName, + String userPicture, + String nachricht, + LocalDateTime angefragtAt +) {} diff --git a/src/main/java/de/oaa/xxx/gruppe/dto/GruppeDto.java b/src/main/java/de/oaa/xxx/gruppe/dto/GruppeDto.java new file mode 100644 index 0000000..9e456a9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/dto/GruppeDto.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.gruppe.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record GruppeDto( + UUID gruppeId, + String name, + String beschreibung, + String bild, + boolean isPrivate, + LocalDateTime createdAt, + long memberCount, + long postCount, + String myRole, + String myRequestStatus +) {} diff --git a/src/main/java/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.java b/src/main/java/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.java new file mode 100644 index 0000000..f6fb732 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/dto/GruppenbeitragDto.java @@ -0,0 +1,24 @@ +package de.oaa.xxx.gruppe.dto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public record GruppenbeitragDto( + UUID beitragId, + UUID gruppeId, + UUID authorId, + String authorName, + String authorPicture, + String beitragTyp, + String text, + Boolean multiChoice, + List bilder, + LocalDateTime createdAt, + long likeCount, + boolean likedByMe, + long kommentarCount, + List optionen, + List myVoteOptionIds, + boolean reported +) {} diff --git a/src/main/java/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.java b/src/main/java/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.java new file mode 100644 index 0000000..292a4ec --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/dto/UmfrageOptionDto.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.gruppe.dto; + +import java.util.UUID; + +public record UmfrageOptionDto( + UUID optionId, + String text, + int reihenfolge, + long stimmenCount +) {} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.java new file mode 100644 index 0000000..58d3a09 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/BeitragMeldungEntity.java @@ -0,0 +1,32 @@ +package de.oaa.xxx.gruppe.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "beitrag_meldung", + uniqueConstraints = @UniqueConstraint(columnNames = {"beitragId", "melderId"})) +public class BeitragMeldungEntity { + + @Id + @Column + private UUID meldungId; + + @Column(nullable = false) + private UUID beitragId; + + @Column(nullable = false) + private UUID melderId; + + @Column(columnDefinition = "TEXT") + private String grund; + + @Column(nullable = false) + private LocalDateTime gemeldetAt; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.java new file mode 100644 index 0000000..63ced11 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/BeitrittsanfrageEntity.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.gruppe.entity; + +import de.oaa.xxx.gruppe.AnfrageStatus; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "beitrittsanfrage") +public class BeitrittsanfrageEntity { + + @Id + @Column + private UUID anfrageId; + + @Column(nullable = false) + private UUID gruppeId; + + @Column(nullable = false) + private UUID userId; + + @Column(columnDefinition = "TEXT") + private String nachricht; + + @Column(nullable = false) + private LocalDateTime angefragtAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 15) + private AnfrageStatus status; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/GruppeEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/GruppeEntity.java new file mode 100644 index 0000000..fccff55 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/GruppeEntity.java @@ -0,0 +1,37 @@ +package de.oaa.xxx.gruppe.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "gruppe") +public class GruppeEntity { + + @Id + @Column + private UUID gruppeId; + + @Column(length = 100, nullable = false) + private String name; + + @Column(columnDefinition = "TEXT") + private String beschreibung; + + @Column(columnDefinition = "MEDIUMTEXT") + private String bild; + + @Column(nullable = false) + private boolean isPrivate = false; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private UUID createdByUserId; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.java new file mode 100644 index 0000000..2d7c11b --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragEntity.java @@ -0,0 +1,45 @@ +package de.oaa.xxx.gruppe.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 = "gruppe_beitrag") +public class GruppenbeitragEntity { + + @Id + @Column + private UUID beitragId; + + @Column(nullable = false) + private UUID gruppeId; + + @Column(nullable = false) + private UUID authorId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private BeitragTyp beitragTyp; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column + private Boolean multiChoice; + + @Convert(converter = StringListConverter.class) + @Column(name = "bild", columnDefinition = "MEDIUMTEXT") + private List bilder; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.java new file mode 100644 index 0000000..d7f0e71 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenbeitragLikeEntity.java @@ -0,0 +1,29 @@ +package de.oaa.xxx.gruppe.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "gruppe_beitrag_like", + uniqueConstraints = @UniqueConstraint(columnNames = {"beitragId", "userId"})) +public class GruppenbeitragLikeEntity { + + @Id + @Column + private UUID likeId; + + @Column(nullable = false) + private UUID beitragId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private LocalDateTime likedAt; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.java new file mode 100644 index 0000000..2f85e3c --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/GruppenmitgliedEntity.java @@ -0,0 +1,34 @@ +package de.oaa.xxx.gruppe.entity; + +import de.oaa.xxx.gruppe.GruppenRolle; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "gruppe_mitglied", + uniqueConstraints = @UniqueConstraint(columnNames = {"gruppeId", "userId"})) +public class GruppenmitgliedEntity { + + @Id + @Column + private UUID mitgliedId; + + @Column(nullable = false) + private UUID gruppeId; + + @Column(nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private GruppenRolle rolle; + + @Column(nullable = false) + private LocalDateTime joinedAt; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.java new file mode 100644 index 0000000..f378a5f --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageOptionEntity.java @@ -0,0 +1,27 @@ +package de.oaa.xxx.gruppe.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "umfrage_option") +public class UmfrageOptionEntity { + + @Id + @Column + private UUID optionId; + + @Column(nullable = false) + private UUID beitragId; + + @Column(length = 200, nullable = false) + private String text; + + @Column(nullable = false) + private int reihenfolge; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.java b/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.java new file mode 100644 index 0000000..ebe2593 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/entity/UmfrageStimmeEntity.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.gruppe.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "umfrage_stimme", + uniqueConstraints = @UniqueConstraint(columnNames = {"optionId", "userId"})) +public class UmfrageStimmeEntity { + + @Id + @Column + private UUID stimmeId; + + @Column(nullable = false) + private UUID optionId; + + @Column(nullable = false) + private UUID beitragId; + + @Column(nullable = false) + private UUID userId; +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.java new file mode 100644 index 0000000..4c3a94e --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/BeitragMeldungRepository.java @@ -0,0 +1,22 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.BeitragMeldungEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface BeitragMeldungRepository extends JpaRepository { + + List findByBeitragId(UUID beitragId); + + List findByBeitragIdIn(Collection beitragIds); + + Optional findByBeitragIdAndMelderId(UUID beitragId, UUID melderId); + + @Transactional + void deleteByBeitragId(UUID beitragId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.java new file mode 100644 index 0000000..71c4ee6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/BeitrittsanfrageRepository.java @@ -0,0 +1,22 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.AnfrageStatus; +import de.oaa.xxx.gruppe.entity.BeitrittsanfrageEntity; +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 BeitrittsanfrageRepository extends JpaRepository { + + List findByGruppeIdAndStatus(UUID gruppeId, AnfrageStatus status); + + List findByUserIdAndStatus(UUID userId, AnfrageStatus status); + + Optional findByGruppeIdAndUserId(UUID gruppeId, UUID userId); + + @Transactional + void deleteByGruppeId(UUID gruppeId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/GruppeRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/GruppeRepository.java new file mode 100644 index 0000000..de306bb --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/GruppeRepository.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.GruppeEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface GruppeRepository extends JpaRepository { + + List findByNameContainingIgnoreCase(String name); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.java new file mode 100644 index 0000000..bbeec96 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragLikeRepository.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.GruppenbeitragLikeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +public interface GruppenbeitragLikeRepository extends JpaRepository { + + Optional findByBeitragIdAndUserId(UUID beitragId, UUID userId); + + long countByBeitragId(UUID beitragId); + + @Transactional + void deleteByBeitragId(UUID beitragId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.java new file mode 100644 index 0000000..10b5b81 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenbeitragRepository.java @@ -0,0 +1,34 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.GruppenbeitragEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface GruppenbeitragRepository extends JpaRepository { + + Slice findByGruppeIdOrderByCreatedAtDesc(UUID gruppeId, Pageable pageable); + + List findByGruppeIdOrderByCreatedAtDesc(UUID gruppeId); + + Optional findFirstByGruppeIdOrderByCreatedAtDesc(UUID gruppeId); + + List findByGruppeIdInAndCreatedAtAfterOrderByCreatedAtDesc(List gruppeIds, LocalDateTime since); + + @Query("SELECT b.gruppeId, MAX(b.createdAt) FROM GruppenbeitragEntity b WHERE b.gruppeId IN :gruppeIds GROUP BY b.gruppeId") + List findLatestCreatedAtByGruppeIds(@Param("gruppeIds") List gruppeIds); + + @Transactional + void deleteByGruppeId(UUID gruppeId); + + @Transactional + void deleteByAuthorId(UUID authorId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.java new file mode 100644 index 0000000..f907b9b --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/GruppenmitgliedRepository.java @@ -0,0 +1,26 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.GruppenmitgliedEntity; +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 GruppenmitgliedRepository extends JpaRepository { + + Optional findFirstByGruppeIdAndUserId(UUID gruppeId, UUID userId); + + List findByGruppeId(UUID gruppeId); + + List findByUserId(UUID userId); + + @Transactional + void deleteByGruppeIdAndUserId(UUID gruppeId, UUID userId); + + long countByGruppeId(UUID gruppeId); + + @Transactional + void deleteByGruppeId(UUID gruppeId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.java new file mode 100644 index 0000000..da21ea0 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageOptionRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.UmfrageOptionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +public interface UmfrageOptionRepository extends JpaRepository { + + List findByBeitragIdOrderByReihenfolge(UUID beitragId); + + @Transactional + void deleteByBeitragId(UUID beitragId); +} diff --git a/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.java b/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.java new file mode 100644 index 0000000..2499814 --- /dev/null +++ b/src/main/java/de/oaa/xxx/gruppe/repository/UmfrageStimmeRepository.java @@ -0,0 +1,21 @@ +package de.oaa.xxx.gruppe.repository; + +import de.oaa.xxx.gruppe.entity.UmfrageStimmeEntity; +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 UmfrageStimmeRepository extends JpaRepository { + + List findByBeitragIdAndUserId(UUID beitragId, UUID userId); + + Optional findByOptionIdAndUserId(UUID optionId, UUID userId); + + long countByOptionId(UUID optionId); + + @Transactional + void deleteByBeitragId(UUID beitragId); +} diff --git a/src/main/java/de/oaa/xxx/mail/Email.java b/src/main/java/de/oaa/xxx/mail/Email.java new file mode 100644 index 0000000..7e1069d --- /dev/null +++ b/src/main/java/de/oaa/xxx/mail/Email.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.mail; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class Email { + + private String emailAdresse; + private String titel; + private String text; +} diff --git a/src/main/java/de/oaa/xxx/mail/MailService.java b/src/main/java/de/oaa/xxx/mail/MailService.java new file mode 100644 index 0000000..365b63e --- /dev/null +++ b/src/main/java/de/oaa/xxx/mail/MailService.java @@ -0,0 +1,39 @@ +package de.oaa.xxx.mail; + +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +public class MailService { + + private static final Logger LOGGER = LoggerFactory.getLogger(MailService.class); + + private final JavaMailSender mailSender; + + public MailService(JavaMailSender mailSender) { + this.mailSender = mailSender; + } + + public boolean send(Email email) { + try { + MimeMessage message = mailSender.createMimeMessage(); + message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(email.getEmailAdresse())); + message.setSubject(email.getTitel()); + message.setFrom(InternetAddress.parse("noreply@xxx-sphere.de")[0]); + message.setContent(email.getText(), "text/html; charset=utf-8"); + message.addHeader("X-Mailin-Tag", "no-tracking"); + message.addHeader("X-Sib-Attributes", "{\"X-SIB-TRACKING\":\"0\"}"); + mailSender.send(message); + return true; + } catch (MessagingException e) { + LOGGER.error(e.getLocalizedMessage(), e); + return false; + } + } +} diff --git a/src/main/java/de/oaa/xxx/mail/MailTemplateService.java b/src/main/java/de/oaa/xxx/mail/MailTemplateService.java new file mode 100644 index 0000000..f06fcee --- /dev/null +++ b/src/main/java/de/oaa/xxx/mail/MailTemplateService.java @@ -0,0 +1,270 @@ +package de.oaa.xxx.mail; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Generates HTML email bodies with inline styles derived from app.theme.* properties. + * Changing theme colors in application.properties automatically updates email appearance. + * (Email clients don't support external CSS or CSS variables, so styles must be inlined.) + */ +@Service +public class MailTemplateService { + + @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; + + public String buildEmailChangeMail(String name, String confirmLink, String newEmail) { + return """ + + + +
            + +

            XXX The Game

            + +

            Moin %s,

            +

            Du hast eine Änderung deiner E-Mail-Adresse angefordert.

            +

            Klick auf den Button, um deine neue Adresse %s zu bestätigen:

            + + + +
            + +

            + Falls du diese Änderung nicht angefordert hast, kannst du diese E-Mail einfach ignorieren. +

            +
            + + + """.formatted( + colorBg, colorText, + colorCard, colorSecondary, + colorPrimary, + colorText, name, + colorText, + colorText, colorPrimary, newEmail, + confirmLink, colorPrimary, + colorSecondary, + colorMuted + ); + } + + public String buildPasswordResetMail(String name, String resetLink) { + return """ + + + +
            + +

            XXX The Game

            + +

            Moin %s,

            +

            Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.

            +

            Klick auf den Button, um ein neues Passwort zu vergeben:

            + + + +
            + +

            + Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail einfach ignorieren. +

            +
            + + + """.formatted( + colorBg, colorText, + colorCard, colorSecondary, + colorPrimary, + colorText, name, + colorText, + colorText, + resetLink, colorPrimary, + colorSecondary, + colorMuted + ); + } + + public String buildKeyholderInvitationMail(String keyholderName, String lockeeName, String lockName, String confirmLink) { + return """ + + + +
            + +

            XXX The Game

            + +

            Moin %s,

            +

            + %s hat dich als Keyholder*In für das Chastity-Lock + %s eingetragen. +

            +

            + Wenn du die Keyholder*In-Rolle annehmen möchtest, klicke auf den Button. Das Lock startet erst nach deiner Bestätigung mit dir als Keyholder*In. +

            + + + +
            + +

            + Falls du die Rolle nicht annehmen möchtest, kannst du diese E-Mail einfach ignorieren. + Das Lock läuft dann als Self-Lock weiter. +

            +
            + + + """.formatted( + colorBg, colorText, + colorCard, colorSecondary, + colorPrimary, + colorText, keyholderName, + colorText, colorPrimary, lockeeName, + colorPrimary, lockName, + colorText, + confirmLink, colorPrimary, + colorSecondary, + colorMuted + ); + } + + public String buildNotificationMail(String name, String text, String targetUrl, String baseUrl) { + String actionButton = targetUrl != null + ? """ + + """.formatted(baseUrl, targetUrl, colorPrimary) + : "
            "; + + String settingsUrl = baseUrl + "/konto/einstellungen.html#sec-benachrichtigungen"; + + return """ + + + +
            + +

            XXX The Game

            + +

            Hallo %s,

            +

            %s

            + + %s + +
            + +

            + Du erhältst diese E-Mail, weil du E-Mail-Benachrichtigungen für diese Kategorie aktiviert hast. + Du kannst deine Einstellungen jederzeit unter + Einstellungen → Benachrichtigungen anpassen. +

            +
            + + + """.formatted( + colorBg, colorText, + colorCard, colorSecondary, + colorPrimary, + colorText, name, + colorText, text, + actionButton, + colorSecondary, + colorMuted, settingsUrl, colorPrimary + ); + } + + public String buildActivationMail(String name, String activationLink, String activatePageUrl, String uuid) { + return """ + + + +
            + +

            XXX The Game

            + +

            Moin %s,

            +

            Vielen Dank für deine Anmeldung!

            +

            Klick auf den Button, um deine E-Mail-Adresse zu bestätigen:

            + + + +
            + +

            + Falls der Button nicht funktioniert, öffne + %s + und gib folgenden Code ein: +

            +

            + %s +

            + +

            Viel Spaß beim Spiel!

            +
            + + + """.formatted( + colorBg, colorText, + colorCard, colorSecondary, + colorPrimary, + colorText, name, + colorText, + colorText, + activationLink, colorPrimary, + colorSecondary, + colorMuted, activatePageUrl, colorPrimary, activatePageUrl, + colorSecondary, colorText, + uuid, + colorMuted + ); + } +} diff --git a/src/main/java/de/oaa/xxx/meldung/MeldungController.java b/src/main/java/de/oaa/xxx/meldung/MeldungController.java new file mode 100644 index 0000000..614e62c --- /dev/null +++ b/src/main/java/de/oaa/xxx/meldung/MeldungController.java @@ -0,0 +1,35 @@ +package de.oaa.xxx.meldung; + +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.util.UUID; + +@RestController +@RequestMapping("/meldung") +public class MeldungController { + + private final MeldungRepository meldungRepository; + private final UserService userService; + + public MeldungController(MeldungRepository meldungRepository, UserService userService) { + this.meldungRepository = meldungRepository; + this.userService = userService; + } + + record MeldungRequest(MeldungZielTyp zielTyp, UUID zielId, String grund) {} + + @PostMapping + @Transactional + public ResponseEntity melden(@RequestBody MeldungRequest request, Principal principal) { + var user = userService.requireUser(principal); + if (meldungRepository.existsByMelderIdAndZielTypAndZielId(user.getUserId(), request.zielTyp(), request.zielId())) { + return ResponseEntity.status(409).build(); + } + meldungRepository.save(MeldungEntity.create(user.getUserId(), request.zielTyp(), request.zielId(), request.grund())); + return ResponseEntity.status(201).build(); + } +} diff --git a/src/main/java/de/oaa/xxx/meldung/MeldungEntity.java b/src/main/java/de/oaa/xxx/meldung/MeldungEntity.java new file mode 100644 index 0000000..56ba75c --- /dev/null +++ b/src/main/java/de/oaa/xxx/meldung/MeldungEntity.java @@ -0,0 +1,59 @@ +package de.oaa.xxx.meldung; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "meldung", uniqueConstraints = { + @UniqueConstraint(columnNames = {"melder_id", "ziel_typ", "ziel_id"}) +}) +public class MeldungEntity { + + @Id + @Column + private UUID meldungId; + + @Column(name = "melder_id", nullable = false) + private UUID melderId; + + @Enumerated(EnumType.STRING) + @Column(name = "ziel_typ", length = 10, nullable = false) + private MeldungZielTyp zielTyp; + + @Column(name = "ziel_id", nullable = false) + private UUID zielId; + + @Column(columnDefinition = "TEXT") + private String grund; + + @Column(nullable = false) + private LocalDateTime gemeldetAt; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false) + private MeldungStatus status; + + @Column(name = "bearbeitet_von") + private UUID bearbeitetVon; + + @Column(name = "bearbeitet_at") + private LocalDateTime bearbeitetAt; + + public static MeldungEntity create(UUID melderId, MeldungZielTyp zielTyp, UUID zielId, String grund) { + MeldungEntity entity = new MeldungEntity(); + entity.setMeldungId(UUID.randomUUID()); + entity.setMelderId(melderId); + entity.setZielTyp(zielTyp); + entity.setZielId(zielId); + entity.setGrund(grund); + entity.setGemeldetAt(LocalDateTime.now()); + entity.setStatus(MeldungStatus.OFFEN); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/meldung/MeldungRepository.java b/src/main/java/de/oaa/xxx/meldung/MeldungRepository.java new file mode 100644 index 0000000..77c383d --- /dev/null +++ b/src/main/java/de/oaa/xxx/meldung/MeldungRepository.java @@ -0,0 +1,15 @@ +package de.oaa.xxx.meldung; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface MeldungRepository extends JpaRepository { + + List findAllByOrderByGemeldetAtDesc(); + + List findByStatusOrderByGemeldetAtDesc(MeldungStatus status); + + boolean existsByMelderIdAndZielTypAndZielId(UUID melderId, MeldungZielTyp zielTyp, UUID zielId); +} diff --git a/src/main/java/de/oaa/xxx/meldung/MeldungStatus.java b/src/main/java/de/oaa/xxx/meldung/MeldungStatus.java new file mode 100644 index 0000000..2bd6bfd --- /dev/null +++ b/src/main/java/de/oaa/xxx/meldung/MeldungStatus.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.meldung; + +public enum MeldungStatus { + OFFEN, BEARBEITET, ABGELEHNT +} diff --git a/src/main/java/de/oaa/xxx/meldung/MeldungZielTyp.java b/src/main/java/de/oaa/xxx/meldung/MeldungZielTyp.java new file mode 100644 index 0000000..2af7eb4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/meldung/MeldungZielTyp.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.meldung; + +public enum MeldungZielTyp { + POST, PROFIL +} diff --git a/src/main/java/de/oaa/xxx/passwordreset/PasswordResetConfirm.java b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetConfirm.java new file mode 100644 index 0000000..7eae9bf --- /dev/null +++ b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetConfirm.java @@ -0,0 +1,3 @@ +package de.oaa.xxx.passwordreset; + +public record PasswordResetConfirm(String token, String password) {} diff --git a/src/main/java/de/oaa/xxx/passwordreset/PasswordResetController.java b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetController.java new file mode 100644 index 0000000..43866d9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetController.java @@ -0,0 +1,81 @@ +package de.oaa.xxx.passwordreset; + +import de.oaa.xxx.mail.Email; +import de.oaa.xxx.mail.MailService; +import de.oaa.xxx.mail.MailTemplateService; +import de.oaa.xxx.user.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/password-reset") +public class PasswordResetController { + + private static final Logger LOGGER = LoggerFactory.getLogger(PasswordResetController.class); + + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + + private final PasswordResetRepository passwordResetRepository; + private final UserRepository userRepository; + private final MailService mailService; + private final MailTemplateService mailTemplateService; + private final PasswordEncoder passwordEncoder; + + public PasswordResetController(PasswordResetRepository passwordResetRepository, + UserRepository userRepository, + MailService mailService, + MailTemplateService mailTemplateService, + PasswordEncoder passwordEncoder) { + this.passwordResetRepository = passwordResetRepository; + this.userRepository = userRepository; + this.mailService = mailService; + this.mailTemplateService = mailTemplateService; + this.passwordEncoder = passwordEncoder; + } + + @PostMapping("/request") + public ResponseEntity request(@RequestBody PasswordResetRequest request) { + userRepository.findByEmail(request.email()).ifPresent(user -> { + passwordResetRepository.findByEmail(request.email()) + .ifPresent(passwordResetRepository::delete); + PasswordResetEntity entity = PasswordResetEntity.create(request.email()); + passwordResetRepository.save(entity); + String resetLink = baseUrl + "/reset-password.html?token=" + entity.getTokenId(); + Email email = new Email(); + email.setTitel("Passwort zurücksetzen"); + email.setEmailAdresse(request.email()); + email.setText(mailTemplateService.buildPasswordResetMail(user.getName(), resetLink)); + mailService.send(email); + LOGGER.info("Passwort-Reset angefordert für: {}", request.email()); + }); + return ResponseEntity.status(202).build(); + } + + @PostMapping("/confirm") + public ResponseEntity confirm(@RequestBody PasswordResetConfirm confirm) { + UUID tokenId; + try { + tokenId = UUID.fromString(confirm.token()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + var entity = passwordResetRepository.findById(tokenId); + if (entity.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + userRepository.findByEmail(entity.get().getEmail()).ifPresent(user -> { + user.setPassword(passwordEncoder.encode(confirm.password())); + userRepository.save(user); + LOGGER.info("Passwort zurückgesetzt für: {}", entity.get().getEmail()); + }); + passwordResetRepository.delete(entity.get()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/passwordreset/PasswordResetEntity.java b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetEntity.java new file mode 100644 index 0000000..8c32780 --- /dev/null +++ b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetEntity.java @@ -0,0 +1,41 @@ +package de.oaa.xxx.passwordreset; + +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 = "password_reset") +public class PasswordResetEntity { + + @Id + @Column + private UUID tokenId; + + @Column(unique = true) + private String email; + + @Column + private LocalDateTime createdAt; + + @Override + public String toString() { + return "PasswordResetEntity[tokenId=" + tokenId + ", email=" + email + ", createdAt=" + createdAt + "]"; + } + + public static PasswordResetEntity create(String email) { + PasswordResetEntity entity = new PasswordResetEntity(); + entity.setTokenId(UUID.randomUUID()); + entity.setEmail(email); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRepository.java b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRepository.java new file mode 100644 index 0000000..dcf0fe1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.passwordreset; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface PasswordResetRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRequest.java b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRequest.java new file mode 100644 index 0000000..54c7edc --- /dev/null +++ b/src/main/java/de/oaa/xxx/passwordreset/PasswordResetRequest.java @@ -0,0 +1,3 @@ +package de.oaa.xxx.passwordreset; + +public record PasswordResetRequest(String email) {} diff --git a/src/main/java/de/oaa/xxx/registration/ActivationController.java b/src/main/java/de/oaa/xxx/registration/ActivationController.java new file mode 100644 index 0000000..1a3210c --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/ActivationController.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.registration; + +import java.net.URI; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/activation") +public class ActivationController { + + private final RegistrationService registrationService; + + public ActivationController(RegistrationService registrationService) { + this.registrationService = registrationService; + } + + @GetMapping("/{uuid}") + public ResponseEntity activate(@PathVariable("uuid") String uuid) { + try { + String email = registrationService.activate(uuid); + String redirect = "/login.html?email=" + java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8); + return ResponseEntity.status(302).location(URI.create(redirect)).build(); + } catch (IllegalStateException e) { + // Bereits aktiviert → trotzdem zum Login weiterleiten (idempotent) + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.noContent().build(); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/registration/Registration.java b/src/main/java/de/oaa/xxx/registration/Registration.java new file mode 100644 index 0000000..39d1301 --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/Registration.java @@ -0,0 +1,23 @@ +package de.oaa.xxx.registration; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +public class Registration { + + private UUID id; + private String name; + private String email; + private String password; + private LocalDate geburtsdatum; + + @Override + public String toString() { + return "Registration [id=" + id + ", name=" + name + ", email=" + email + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/registration/RegistrationController.java b/src/main/java/de/oaa/xxx/registration/RegistrationController.java new file mode 100644 index 0000000..d0b3a18 --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/RegistrationController.java @@ -0,0 +1,102 @@ +package de.oaa.xxx.registration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +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 de.oaa.xxx.mail.Email; +import de.oaa.xxx.mail.MailService; +import de.oaa.xxx.mail.MailTemplateService; +import de.oaa.xxx.user.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; +import java.time.Period; + +@RestController +@RequestMapping("/registration") +public class RegistrationController { + + private static final Logger LOGGER = LoggerFactory.getLogger(RegistrationController.class); + + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + + private final RegistrationRepository registrationRepository; + private final UserRepository userRepository; + private final MailService mailService; + private final MailTemplateService mailTemplateService; + private final PasswordEncoder passwordEncoder; + + public RegistrationController(RegistrationRepository registrationRepository, UserRepository userRepository, + MailService mailService, MailTemplateService mailTemplateService, + PasswordEncoder passwordEncoder) { + this.registrationRepository = registrationRepository; + this.userRepository = userRepository; + this.mailService = mailService; + this.mailTemplateService = mailTemplateService; + this.passwordEncoder = passwordEncoder; + } + + @PostMapping + public ResponseEntity create(@RequestBody Registration registration) { + LOGGER.info("POST {}: {}", getClass().getName(), registration); + if (registration.getEmail() == null + || !registration.getEmail().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) { + LOGGER.warn("Registrierung abgelehnt – ungültige E-Mail-Adresse"); + return ResponseEntity.status(422).body("EMAIL_FORMAT"); + } + if (registration.getPassword() == null || registration.getPassword().length() < 8) { + LOGGER.warn("Registrierung abgelehnt – Passwort zu kurz (min. 8 Zeichen)"); + return ResponseEntity.status(422).body("PASSWORT_ZU_KURZ"); + } + if (registration.getGeburtsdatum() == null + || Period.between(registration.getGeburtsdatum(), LocalDate.now()).getYears() < 18) { + LOGGER.warn("Registrierung abgelehnt – Mindestalter nicht erreicht"); + return ResponseEntity.status(422).body("ALTER"); + } + // Bereits aktivierte User blockieren + if (userRepository.findByEmail(registration.getEmail()).isPresent()) { + LOGGER.warn("User mit E-Mail {} bereits vorhanden", registration.getEmail()); + return ResponseEntity.badRequest().build(); + } + if (userRepository.findByName(registration.getName()).isPresent()) { + LOGGER.warn("User mit Name {} bereits vorhanden", registration.getName()); + return ResponseEntity.status(409).build(); + } + // Noch nicht aktivierte Registrierungen mit gleicher E-Mail oder Name überschreiben + registrationRepository.findByEmail(registration.getEmail()).ifPresent(old -> { + LOGGER.info("Überschreibe nicht aktivierte Registrierung mit E-Mail {}", registration.getEmail()); + registrationRepository.delete(old); + }); + registrationRepository.findByName(registration.getName()).ifPresent(old -> { + LOGGER.info("Überschreibe nicht aktivierte Registrierung mit Name {}", registration.getName()); + registrationRepository.delete(old); + }); + // Passwort serverseitig mit BCrypt hashen + registration.setPassword(passwordEncoder.encode(registration.getPassword())); + RegistrationEntity entity = RegistrationEntity.create(registration); + registrationRepository.save(entity); + + Email email = new Email(); + email.setTitel("Bitte bestätige deine E-Mail Adresse"); + email.setEmailAdresse(registration.getEmail()); + String uuid = entity.getRegistrationId().toString(); + String activationLink = baseUrl + "/activation/" + uuid; + String activatePageUrl = baseUrl + "/activate.html"; + email.setText(mailTemplateService.buildActivationMail(registration.getName(), activationLink, activatePageUrl, entity.getActivationCode())); + + if (!mailService.send(email)) { + registrationRepository.delete(entity); + return ResponseEntity.internalServerError().build(); + } + return ResponseEntity.status(202).build(); + } + + +} diff --git a/src/main/java/de/oaa/xxx/registration/RegistrationEntity.java b/src/main/java/de/oaa/xxx/registration/RegistrationEntity.java new file mode 100644 index 0000000..ceed773 --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/RegistrationEntity.java @@ -0,0 +1,62 @@ +package de.oaa.xxx.registration; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.security.SecureRandom; +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "registration") +public class RegistrationEntity { + + @Id + @Column + private UUID registrationId; + @Column + private String name; + @Column(unique = true) + private String email; + @Column + private String password; + @Column + private Boolean activated; + @Column(length = 6) + private String activationCode; + @Column + private LocalDate geburtsdatum; + + @Override + public String toString() { + return "RegistrationEntity [registrationId=" + registrationId + ", name=" + name + ", email=" + email + "]"; + } + + public Registration toRegistration() { + Registration registration = new Registration(); + registration.setId(registrationId); + registration.setEmail(email); + registration.setName(name); + registration.setPassword(password); + registration.setGeburtsdatum(geburtsdatum); + return registration; + } + + public static RegistrationEntity create(Registration registration) { + RegistrationEntity entity = new RegistrationEntity(); + entity.setRegistrationId(UUID.randomUUID()); + entity.setEmail(registration.getEmail()); + entity.setActivated(Boolean.FALSE); + entity.setActivationCode(String.format("%06d", new SecureRandom().nextInt(1_000_000))); + entity.setName(registration.getName()); + entity.setPassword(registration.getPassword()); + entity.setGeburtsdatum(registration.getGeburtsdatum()); + return entity; + } +} diff --git a/src/main/java/de/oaa/xxx/registration/RegistrationRepository.java b/src/main/java/de/oaa/xxx/registration/RegistrationRepository.java new file mode 100644 index 0000000..90e24ac --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/RegistrationRepository.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.registration; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface RegistrationRepository extends JpaRepository { + + Optional findByEmail(String email); + Optional findByName(String name); + Optional findByActivationCode(String activationCode); +} diff --git a/src/main/java/de/oaa/xxx/registration/RegistrationService.java b/src/main/java/de/oaa/xxx/registration/RegistrationService.java new file mode 100644 index 0000000..6136799 --- /dev/null +++ b/src/main/java/de/oaa/xxx/registration/RegistrationService.java @@ -0,0 +1,59 @@ +package de.oaa.xxx.registration; + +import de.oaa.xxx.user.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Koordiniert den Aktivierungsflow: liest die RegistrationEntity aus der DB + * und delegiert die User-Anlage an den UserService. + * Ersetzt den direkten Controller→Controller-Aufruf im ActivationController. + */ +@Service +public class RegistrationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(RegistrationService.class); + + private final RegistrationRepository registrationRepository; + private final UserService userService; + + public RegistrationService(RegistrationRepository registrationRepository, UserService userService) { + this.registrationRepository = registrationRepository; + this.userService = userService; + } + + /** + * Aktiviert eine Registrierung: legt den User an und markiert die Registration als aktiviert. + * + * @return E-Mail des aktivierten Users (für Redirect im Controller) + * @throws IllegalArgumentException wenn UUID ungültig oder Registration nicht gefunden + * @throws IllegalStateException wenn Registration bereits aktiviert + */ + public String activate(String token) { + RegistrationEntity registration; + try { + UUID registrationId = UUID.fromString(token); + registration = registrationRepository.findById(registrationId) + .orElseThrow(() -> new IllegalArgumentException("Registration nicht gefunden: " + token)); + } catch (IllegalArgumentException e) { + // Kein UUID-Format → nach kurzem Aktivierungscode suchen + registration = registrationRepository.findByActivationCode(token) + .orElseThrow(() -> new IllegalArgumentException("Aktivierungscode ungültig: " + token)); + } + + if (Boolean.TRUE.equals(registration.getActivated())) { + throw new IllegalStateException("Registration bereits aktiviert"); + } + + userService.createUser(registration.toRegistration()); + + registration.setActivated(Boolean.TRUE); + registrationRepository.save(registration); + + LOGGER.info("Registration {} aktiviert, User {} angelegt", token, registration.getEmail()); + return registration.getEmail(); + } +} diff --git a/src/main/java/de/oaa/xxx/social/EventController.java b/src/main/java/de/oaa/xxx/social/EventController.java new file mode 100644 index 0000000..834189d --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/EventController.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.user.UserService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.security.Principal; + +@RestController +@RequestMapping("/events") +public class EventController { + + private final SseService sseService; + private final UserService userService; + + public EventController(SseService sseService, UserService userService) { + this.sseService = sseService; + this.userService = userService; + } + + @GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(Principal principal) { + return sseService.subscribe(userService.requireUser(principal).getUserId()); + } +} diff --git a/src/main/java/de/oaa/xxx/social/KommentarController.java b/src/main/java/de/oaa/xxx/social/KommentarController.java new file mode 100644 index 0000000..1e1162f --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/KommentarController.java @@ -0,0 +1,126 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.social.dto.KommentarDto; +import de.oaa.xxx.social.entity.KommentarEntity; +import de.oaa.xxx.social.repository.KommentarLikeRepository; +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; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/social/kommentare") +public class KommentarController { + + private static final Logger LOGGER = LoggerFactory.getLogger(KommentarController.class); + + private final KommentarRepository kommentarRepository; + private final KommentarLikeRepository likeRepository; + private final UserRepository userRepository; + private final UserService userService; + private final LikeService likeService; + + public KommentarController(KommentarRepository kommentarRepository, + KommentarLikeRepository likeRepository, + UserRepository userRepository, + UserService userService, + LikeService likeService) { + this.kommentarRepository = kommentarRepository; + this.likeRepository = likeRepository; + this.userRepository = userRepository; + this.userService = userService; + this.likeService = likeService; + } + + record CreateKommentarRequest(String targetType, UUID targetId, String text) {} + + @GetMapping + public ResponseEntity> getKommentare( + @RequestParam String targetType, + @RequestParam UUID targetId, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List dtos = kommentarRepository + .findByTargetTypeAndTargetIdOrderByCreatedAtAsc(targetType, targetId) + .stream() + .map(k -> toDto(k, myId)) + .toList(); + return ResponseEntity.ok(dtos); + } + + @PostMapping + public ResponseEntity createKommentar(@RequestBody CreateKommentarRequest request, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build(); + if (request.text().length() > 500) return ResponseEntity.badRequest().build(); + if (!List.of("PINNWAND", "IMAGE", "KOMMENTAR", "GROUP_POST", "FEED_POST").contains(request.targetType())) { + return ResponseEntity.badRequest().build(); + } + + KommentarEntity entity = new KommentarEntity(); + entity.setKommentarId(UUID.randomUUID()); + entity.setAuthorId(myId); + entity.setTargetType(request.targetType()); + entity.setTargetId(request.targetId()); + entity.setText(request.text().trim()); + entity.setCreatedAt(LocalDateTime.now()); + kommentarRepository.save(entity); + LOGGER.debug("User {} hat Kommentar {} auf {} {} erstellt", myId, entity.getKommentarId(), request.targetType(), request.targetId()); + + return ResponseEntity.status(201).body(toDto(entity, myId)); + } + + @DeleteMapping("/{kommentarId}") + public ResponseEntity deleteKommentar(@PathVariable UUID kommentarId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var kOpt = kommentarRepository.findById(kommentarId); + if (kOpt.isEmpty()) return ResponseEntity.notFound().build(); + if (!kOpt.get().getAuthorId().equals(myId)) return ResponseEntity.status(403).build(); + + // Delete nested replies and their likes + List replies = kommentarRepository + .findByTargetTypeAndTargetIdOrderByCreatedAtAsc("KOMMENTAR", kommentarId); + for (KommentarEntity reply : replies) { + likeRepository.deleteByKommentarId(reply.getKommentarId()); + kommentarRepository.delete(reply); + } + likeRepository.deleteByKommentarId(kommentarId); + kommentarRepository.delete(kOpt.get()); + LOGGER.debug("User {} hat Kommentar {} gelöscht", myId, kommentarId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{kommentarId}/like") + public ResponseEntity toggleLike(@PathVariable UUID kommentarId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (kommentarRepository.findById(kommentarId).isEmpty()) return ResponseEntity.notFound().build(); + + likeService.toggleKommentarLike(kommentarId, myId); + return ResponseEntity.ok().build(); + } + + private KommentarDto toDto(KommentarEntity k, UUID myId) { + UserEntity author = userRepository.findById(k.getAuthorId()).orElse(null); + String authorName = author != null ? author.getName() : "Unbekannt"; + String authorPic = author != null ? author.getProfilePicture() : null; + long likeCount = likeRepository.countByKommentarId(k.getKommentarId()); + boolean likedByMe = likeRepository.findByKommentarIdAndUserId(k.getKommentarId(), myId).isPresent(); + long replyCount = kommentarRepository.countByTargetTypeAndTargetId("KOMMENTAR", k.getKommentarId()); + return new KommentarDto(k.getKommentarId(), k.getAuthorId(), authorName, authorPic, + k.getTargetType(), k.getTargetId(), k.getText(), k.getCreatedAt(), + likeCount, likedByMe, replyCount); + } +} diff --git a/src/main/java/de/oaa/xxx/social/LikeService.java b/src/main/java/de/oaa/xxx/social/LikeService.java new file mode 100644 index 0000000..7f9dfa3 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/LikeService.java @@ -0,0 +1,122 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.feed.entity.FeedPostLikeEntity; +import de.oaa.xxx.feed.repository.FeedPostLikeRepository; +import de.oaa.xxx.gruppe.entity.GruppenbeitragLikeEntity; +import de.oaa.xxx.gruppe.repository.GruppenbeitragLikeRepository; +import de.oaa.xxx.social.entity.KommentarLikeEntity; +import de.oaa.xxx.social.entity.PinnwandLikeEntity; +import de.oaa.xxx.social.entity.ProfileImageLikeEntity; +import de.oaa.xxx.social.repository.KommentarLikeRepository; +import de.oaa.xxx.social.repository.PinnwandLikeRepository; +import de.oaa.xxx.social.repository.ProfileImageLikeRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class LikeService { + + private static final Logger LOGGER = LoggerFactory.getLogger(LikeService.class); + + private final PinnwandLikeRepository pinnwandLikeRepository; + private final KommentarLikeRepository kommentarLikeRepository; + private final ProfileImageLikeRepository profileImageLikeRepository; + private final FeedPostLikeRepository feedPostLikeRepository; + private final GruppenbeitragLikeRepository gruppenbeitragLikeRepository; + + public LikeService(PinnwandLikeRepository pinnwandLikeRepository, + KommentarLikeRepository kommentarLikeRepository, + ProfileImageLikeRepository profileImageLikeRepository, + FeedPostLikeRepository feedPostLikeRepository, + GruppenbeitragLikeRepository gruppenbeitragLikeRepository) { + this.pinnwandLikeRepository = pinnwandLikeRepository; + this.kommentarLikeRepository = kommentarLikeRepository; + this.profileImageLikeRepository = profileImageLikeRepository; + this.feedPostLikeRepository = feedPostLikeRepository; + this.gruppenbeitragLikeRepository = gruppenbeitragLikeRepository; + } + + public void togglePinnwandLike(UUID eintragId, UUID userId) { + var existing = pinnwandLikeRepository.findByEintragIdAndUserId(eintragId, userId); + if (existing.isPresent()) { + pinnwandLikeRepository.delete(existing.get()); + LOGGER.debug("User {} hat Like auf Pinnwand-Eintrag {} entfernt", userId, eintragId); + } else { + PinnwandLikeEntity like = new PinnwandLikeEntity(); + like.setLikeId(UUID.randomUUID()); + like.setEintragId(eintragId); + like.setUserId(userId); + like.setLikedAt(LocalDateTime.now()); + pinnwandLikeRepository.save(like); + LOGGER.debug("User {} hat Pinnwand-Eintrag {} geliked", userId, eintragId); + } + } + + public void toggleKommentarLike(UUID kommentarId, UUID userId) { + var existing = kommentarLikeRepository.findByKommentarIdAndUserId(kommentarId, userId); + if (existing.isPresent()) { + kommentarLikeRepository.delete(existing.get()); + LOGGER.debug("User {} hat Like auf Kommentar {} entfernt", userId, kommentarId); + } else { + KommentarLikeEntity like = new KommentarLikeEntity(); + like.setLikeId(UUID.randomUUID()); + like.setKommentarId(kommentarId); + like.setUserId(userId); + like.setLikedAt(LocalDateTime.now()); + kommentarLikeRepository.save(like); + LOGGER.debug("User {} hat Kommentar {} geliked", userId, kommentarId); + } + } + + public void toggleProfileImageLike(UUID imageId, UUID userId) { + var existing = profileImageLikeRepository.findByImageIdAndUserId(imageId, userId); + if (existing.isPresent()) { + profileImageLikeRepository.delete(existing.get()); + LOGGER.debug("User {} hat Like auf Profilbild {} entfernt", userId, imageId); + } else { + ProfileImageLikeEntity like = new ProfileImageLikeEntity(); + like.setLikeId(UUID.randomUUID()); + like.setImageId(imageId); + like.setUserId(userId); + like.setLikedAt(LocalDateTime.now()); + profileImageLikeRepository.save(like); + LOGGER.debug("User {} hat Profilbild {} geliked", userId, imageId); + } + } + + public void toggleFeedPostLike(UUID postId, UUID userId) { + var existing = feedPostLikeRepository.findByPostIdAndUserId(postId, userId); + if (existing.isPresent()) { + feedPostLikeRepository.delete(existing.get()); + LOGGER.debug("User {} hat Like auf Feed-Post {} entfernt", userId, postId); + } else { + FeedPostLikeEntity like = new FeedPostLikeEntity(); + like.setLikeId(UUID.randomUUID()); + like.setPostId(postId); + like.setUserId(userId); + like.setLikedAt(LocalDateTime.now()); + feedPostLikeRepository.save(like); + LOGGER.debug("User {} hat Feed-Post {} geliked", userId, postId); + } + } + + public void toggleGruppenbeitragLike(UUID beitragId, UUID userId) { + var existing = gruppenbeitragLikeRepository.findByBeitragIdAndUserId(beitragId, userId); + if (existing.isPresent()) { + gruppenbeitragLikeRepository.delete(existing.get()); + LOGGER.debug("User {} hat Like auf Beitrag {} entfernt", userId, beitragId); + } else { + GruppenbeitragLikeEntity like = new GruppenbeitragLikeEntity(); + like.setLikeId(UUID.randomUUID()); + like.setBeitragId(beitragId); + like.setUserId(userId); + like.setLikedAt(LocalDateTime.now()); + gruppenbeitragLikeRepository.save(like); + LOGGER.debug("User {} hat Beitrag {} geliked", userId, beitragId); + } + } +} diff --git a/src/main/java/de/oaa/xxx/social/NotificationController.java b/src/main/java/de/oaa/xxx/social/NotificationController.java new file mode 100644 index 0000000..91d5f0b --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/NotificationController.java @@ -0,0 +1,77 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.social.repository.MessageRepository; +import de.oaa.xxx.user.UserRepository; +import de.oaa.xxx.user.UserService; +import org.springframework.data.domain.PageRequest; +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.*; + +@RestController +@RequestMapping("/notifications") +public class NotificationController { + + private final MessageRepository messageRepository; + private final UserRepository userRepository; + private final UserService userService; + + public NotificationController(MessageRepository messageRepository, + UserRepository userRepository, + UserService userService) { + this.messageRepository = messageRepository; + this.userRepository = userRepository; + this.userService = userService; + } + + @GetMapping + public ResponseEntity>> getNotifications(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List> result = messageRepository + .findNotificationsForUser(myId, PageRequest.of(0, 10)) + .stream() + .map(m -> { + Map n = new LinkedHashMap<>(); + n.put("id", m.getMessageId().toString()); + n.put("text", m.getText()); + n.put("sentAt", m.getSentAt().toString()); + n.put("read", m.getReadAt() != null); + n.put("targetUrl", m.getTargetUrl() != null ? m.getTargetUrl() : ""); + userRepository.findById(m.getSenderId()).ifPresent(sender -> { + n.put("senderName", sender.getName()); + n.put("senderAvatar", sender.getProfilePicture() != null ? sender.getProfilePicture() : ""); + }); + return n; + }) + .toList(); + return ResponseEntity.ok(result); + } + + @GetMapping("/unread/count") + public ResponseEntity getUnreadCount(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + return ResponseEntity.ok( + messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(myId, true)); + } + + @Transactional + @PostMapping("/{id}/read") + public ResponseEntity markOneRead(@PathVariable UUID id, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + messageRepository.markNotificationAsRead(id, myId, LocalDateTime.now()); + return ResponseEntity.noContent().build(); + } + + @Transactional + @PostMapping("/read-all") + public ResponseEntity markAllRead(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + messageRepository.markAllNotificationsAsRead(myId, LocalDateTime.now()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/de/oaa/xxx/social/PinnwandController.java b/src/main/java/de/oaa/xxx/social/PinnwandController.java new file mode 100644 index 0000000..38732e7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/PinnwandController.java @@ -0,0 +1,123 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.social.dto.PinnwandEintragDto; +import de.oaa.xxx.social.entity.PinnwandEintragEntity; +import de.oaa.xxx.social.repository.KommentarRepository; +import de.oaa.xxx.social.repository.PinnwandEintragRepository; +import de.oaa.xxx.social.repository.PinnwandLikeRepository; +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; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/social/pinnwand") +public class PinnwandController { + + private static final Logger LOGGER = LoggerFactory.getLogger(PinnwandController.class); + + private final PinnwandEintragRepository eintragRepository; + private final PinnwandLikeRepository likeRepository; + private final KommentarRepository kommentarRepository; + private final UserRepository userRepository; + private final UserService userService; + private final LikeService likeService; + + public PinnwandController(PinnwandEintragRepository eintragRepository, + PinnwandLikeRepository likeRepository, + KommentarRepository kommentarRepository, + UserRepository userRepository, + UserService userService, + LikeService likeService) { + this.eintragRepository = eintragRepository; + this.likeRepository = likeRepository; + this.kommentarRepository = kommentarRepository; + this.userRepository = userRepository; + this.userService = userService; + this.likeService = likeService; + } + + record CreateEintragRequest(UUID profilUserId, String text) {} + + @GetMapping + public ResponseEntity> getEintraege(@RequestParam UUID userId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List dtos = eintragRepository + .findByProfilUserIdOrderByCreatedAtDesc(userId) + .stream() + .map(e -> toDto(e, myId)) + .toList(); + return ResponseEntity.ok(dtos); + } + + @PostMapping + public ResponseEntity createEintrag(@RequestBody CreateEintragRequest request, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (request.text() == null || request.text().isBlank()) return ResponseEntity.badRequest().build(); + if (request.text().length() > 1000) return ResponseEntity.badRequest().build(); + + PinnwandEintragEntity entity = new PinnwandEintragEntity(); + entity.setEintragId(UUID.randomUUID()); + entity.setProfilUserId(request.profilUserId()); + entity.setAuthorId(myId); + entity.setText(request.text().trim()); + entity.setCreatedAt(LocalDateTime.now()); + eintragRepository.save(entity); + LOGGER.debug("User {} hat Pinnwand-Eintrag {} auf Profil {} erstellt", myId, entity.getEintragId(), request.profilUserId()); + + return ResponseEntity.status(201).body(toDto(entity, myId)); + } + + @DeleteMapping("/{eintragId}") + public ResponseEntity deleteEintrag(@PathVariable UUID eintragId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var eintragOpt = eintragRepository.findById(eintragId); + if (eintragOpt.isEmpty()) return ResponseEntity.notFound().build(); + var eintrag = eintragOpt.get(); + + // Author or profile owner may delete + if (!eintrag.getAuthorId().equals(myId) && !eintrag.getProfilUserId().equals(myId)) { + return ResponseEntity.status(403).build(); + } + + likeRepository.deleteByEintragId(eintragId); + // Delete comments on this entry (via KommentarRepository) + kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("PINNWAND", eintragId) + .forEach(k -> kommentarRepository.deleteById(k.getKommentarId())); + eintragRepository.delete(eintrag); + LOGGER.debug("User {} hat Pinnwand-Eintrag {} gelöscht", myId, eintragId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{eintragId}/like") + public ResponseEntity toggleLike(@PathVariable UUID eintragId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (eintragRepository.findById(eintragId).isEmpty()) return ResponseEntity.notFound().build(); + + likeService.togglePinnwandLike(eintragId, myId); + return ResponseEntity.ok().build(); + } + + private PinnwandEintragDto toDto(PinnwandEintragEntity e, UUID myId) { + UserEntity author = userRepository.findById(e.getAuthorId()).orElse(null); + String authorName = author != null ? author.getName() : "Unbekannt"; + String authorPic = author != null ? author.getProfilePicture() : null; + long likeCount = likeRepository.countByEintragId(e.getEintragId()); + boolean likedByMe = likeRepository.findByEintragIdAndUserId(e.getEintragId(), myId).isPresent(); + long kommentarCount = kommentarRepository.countByTargetTypeAndTargetId("PINNWAND", e.getEintragId()); + return new PinnwandEintragDto(e.getEintragId(), e.getProfilUserId(), e.getAuthorId(), + authorName, authorPic, e.getText(), e.getCreatedAt(), likeCount, likedByMe, kommentarCount); + } +} diff --git a/src/main/java/de/oaa/xxx/social/ProfileImageController.java b/src/main/java/de/oaa/xxx/social/ProfileImageController.java new file mode 100644 index 0000000..833da97 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/ProfileImageController.java @@ -0,0 +1,103 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.social.dto.ProfileImageDto; +import de.oaa.xxx.social.entity.ProfileImageEntity; +import de.oaa.xxx.social.repository.ProfileImageLikeRepository; +import de.oaa.xxx.social.repository.ProfileImageRepository; +import de.oaa.xxx.user.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/social/profile-images") +public class ProfileImageController { + + private static final Logger LOGGER = LoggerFactory.getLogger(ProfileImageController.class); + private static final int MAX_IMAGES_PER_USER = 20; + + private final ProfileImageRepository profileImageRepository; + private final ProfileImageLikeRepository profileImageLikeRepository; + private final UserService userService; + private final LikeService likeService; + + public ProfileImageController(ProfileImageRepository profileImageRepository, + ProfileImageLikeRepository profileImageLikeRepository, + UserService userService, + LikeService likeService) { + this.profileImageRepository = profileImageRepository; + this.profileImageLikeRepository = profileImageLikeRepository; + this.userService = userService; + this.likeService = likeService; + } + + record UploadRequest(String imageData) {} + + @PostMapping + public ResponseEntity uploadImage(@RequestBody UploadRequest request, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (request.imageData() == null || request.imageData().isBlank()) { + return ResponseEntity.badRequest().build(); + } + if (profileImageRepository.countByUserId(myId) >= MAX_IMAGES_PER_USER) { + return ResponseEntity.status(422).build(); + } + + ProfileImageEntity entity = new ProfileImageEntity(); + entity.setImageId(UUID.randomUUID()); + entity.setUserId(myId); + entity.setImageData(request.imageData()); + entity.setUploadedAt(LocalDateTime.now()); + profileImageRepository.save(entity); + LOGGER.debug("User {} hat Profilbild {} hochgeladen", myId, entity.getImageId()); + + return ResponseEntity.status(201).body(toDto(entity, myId)); + } + + @GetMapping + public ResponseEntity> getImages(@RequestParam UUID userId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List images = profileImageRepository.findByUserIdOrderByUploadedAtDesc(userId); + List dtos = images.stream().map(img -> toDto(img, myId)).toList(); + return ResponseEntity.ok(dtos); + } + + @DeleteMapping("/{imageId}") + public ResponseEntity deleteImage(@PathVariable UUID imageId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var imgOpt = profileImageRepository.findById(imageId); + if (imgOpt.isEmpty()) return ResponseEntity.notFound().build(); + if (!imgOpt.get().getUserId().equals(myId)) return ResponseEntity.status(403).build(); + + profileImageLikeRepository.deleteByImageId(imageId); + profileImageRepository.delete(imgOpt.get()); + LOGGER.info("User {} hat Profilbild {} gelöscht", myId, imageId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{imageId}/like") + public ResponseEntity toggleLike(@PathVariable UUID imageId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (profileImageRepository.findById(imageId).isEmpty()) return ResponseEntity.notFound().build(); + + likeService.toggleProfileImageLike(imageId, myId); + return ResponseEntity.ok().build(); + } + + private ProfileImageDto toDto(ProfileImageEntity entity, UUID myId) { + long likeCount = profileImageLikeRepository.countByImageId(entity.getImageId()); + boolean likedByMe = profileImageLikeRepository.findByImageIdAndUserId(entity.getImageId(), myId).isPresent(); + return new ProfileImageDto(entity.getImageId(), entity.getUserId(), entity.getImageData(), + entity.getUploadedAt(), likeCount, likedByMe); + } +} diff --git a/src/main/java/de/oaa/xxx/social/SocialController.java b/src/main/java/de/oaa/xxx/social/SocialController.java new file mode 100644 index 0000000..0dc97b1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/SocialController.java @@ -0,0 +1,362 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.social.dto.ConversationSummary; +import de.oaa.xxx.social.dto.FriendshipDto; +import de.oaa.xxx.social.dto.MessageDto; +import de.oaa.xxx.social.dto.UserProfile; +import de.oaa.xxx.social.entity.FriendshipEntity; +import de.oaa.xxx.social.entity.FriendshipEntity.Status; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.MessageEntity; +import de.oaa.xxx.social.repository.FriendshipRepository; +import de.oaa.xxx.social.repository.MessageRepository; +import de.oaa.xxx.support.SupportUserService; +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; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; + +@RestController +@RequestMapping("/social") +public class SocialController { + + private static final Logger LOGGER = LoggerFactory.getLogger(SocialController.class); + + private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + private final MessageRepository messageRepository; + private final SseService sseService; + private final SystemMessageService systemMessageService; + private final UserService userService; + + public SocialController(UserRepository userRepository, + FriendshipRepository friendshipRepository, + MessageRepository messageRepository, + SseService sseService, + SystemMessageService systemMessageService, + UserService userService) { + this.userRepository = userRepository; + this.friendshipRepository = friendshipRepository; + this.messageRepository = messageRepository; + this.sseService = sseService; + this.systemMessageService = systemMessageService; + this.userService = userService; + } + + record FriendRequestBody(UUID receiverId) {} + record FriendshipActionBody(UUID friendshipId) {} + record SendMessageBody(UUID receiverId, String text) {} + + // ── User Profile ── + + @GetMapping("/users/{userId}") + public ResponseEntity getUserProfile(@PathVariable UUID userId, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + return userRepository.findById(userId) + .map(u -> ResponseEntity.ok(toUserProfileWithStatus(u, myId))) + .orElse(ResponseEntity.notFound().build()); + } + + // ── User Search ── + + @GetMapping("/users/search") + public ResponseEntity> searchUsers(@RequestParam String q, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List results = userRepository.findByNameContainingIgnoreCase(q); + List profiles = results.stream() + .filter(u -> !u.getUserId().equals(myId)) + .limit(20) + .map(u -> toUserProfileWithStatus(u, myId)) + .toList(); + return ResponseEntity.ok(profiles); + } + + // ── Friendship ── + + @PostMapping("/friends/request") + public ResponseEntity sendFriendRequest(@RequestBody FriendRequestBody body, Principal principal) { + var me = userService.requireUser(principal); + UUID myId = me.getUserId(); + + if (myId.equals(body.receiverId())) { + return ResponseEntity.badRequest().build(); + } + if (friendshipRepository.findExisting(myId, body.receiverId()).isPresent()) { + return ResponseEntity.status(409).build(); + } + FriendshipEntity f = new FriendshipEntity(); + f.setFriendshipId(UUID.randomUUID()); + f.setSenderId(myId); + f.setReceiverId(body.receiverId()); + f.setStatus(Status.PENDING); + f.setCreatedAt(LocalDateTime.now()); + friendshipRepository.save(f); + LOGGER.info("User {} hat Freundschaftsanfrage an User {} gesendet", myId, body.receiverId()); + + String senderName = me.getName(); + systemMessageService.send(myId, body.receiverId(), + senderName + " hat dir eine Freundschaftsanfrage gesendet.", + "/community/benutzer.html?userId=" + myId, + MessageCause.FRIENDREQUEST); + + return ResponseEntity.status(201).build(); + } + + @PostMapping("/friends/accept") + public ResponseEntity acceptFriendRequest(@RequestBody FriendshipActionBody body, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var fOpt = friendshipRepository.findById(body.friendshipId()); + if (fOpt.isEmpty()) return ResponseEntity.notFound().build(); + FriendshipEntity f = fOpt.get(); + if (!f.getReceiverId().equals(myId)) return ResponseEntity.status(403).build(); + + f.setStatus(Status.ACCEPTED); + friendshipRepository.save(f); + LOGGER.info("User {} hat Freundschaftsanfrage {} angenommen", myId, body.friendshipId()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/friends/reject") + public ResponseEntity rejectOrRemoveFriend(@RequestBody FriendshipActionBody body, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + var fOpt = friendshipRepository.findById(body.friendshipId()); + if (fOpt.isEmpty()) return ResponseEntity.notFound().build(); + FriendshipEntity f = fOpt.get(); + if (!f.getSenderId().equals(myId) && !f.getReceiverId().equals(myId)) { + return ResponseEntity.status(403).build(); + } + friendshipRepository.delete(f); + LOGGER.info("User {} hat Freundschaft/Anfrage {} gelöscht", myId, body.friendshipId()); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/friends/user/{userId}") + public ResponseEntity> getFriendsOfUser(@PathVariable UUID userId, Principal principal) { + userService.requireUser(principal); + List profiles = friendshipRepository.findFriends(userId, Status.ACCEPTED).stream() + .map(f -> { + UUID friendId = f.getSenderId().equals(userId) ? f.getReceiverId() : f.getSenderId(); + return userRepository.findById(friendId) + .map(u -> new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), null)) + .orElse(null); + }) + .filter(Objects::nonNull) + .toList(); + return ResponseEntity.ok(profiles); + } + + @GetMapping("/friends") + public ResponseEntity> getFriends(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List dtos = friendshipRepository.findFriends(myId, Status.ACCEPTED).stream() + .map(f -> { + UUID friendId = f.getSenderId().equals(myId) ? f.getReceiverId() : f.getSenderId(); + return userRepository.findById(friendId) + .map(u -> new FriendshipDto( + f.getFriendshipId(), + new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), "FRIEND"), + f.getStatus().name(), + f.getCreatedAt())) + .orElse(null); + }) + .filter(Objects::nonNull) + .toList(); + return ResponseEntity.ok(dtos); + } + + @GetMapping("/friends/pending") + public ResponseEntity> getPendingRequests(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List dtos = friendshipRepository.findByReceiverIdAndStatus(myId, Status.PENDING).stream() + .map(f -> userRepository.findById(f.getSenderId()) + .map(u -> new FriendshipDto( + f.getFriendshipId(), + new UserProfile(u.getUserId(), u.getName(), u.getProfilePicture(), u.getProfilePictureHq(), "PENDING_RECEIVED"), + f.getStatus().name(), + f.getCreatedAt())) + .orElse(null)) + .filter(Objects::nonNull) + .toList(); + return ResponseEntity.ok(dtos); + } + + @GetMapping("/friends/pending/count") + public ResponseEntity getPendingCount(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + return ResponseEntity.ok(friendshipRepository.countByReceiverIdAndStatus(myId, Status.PENDING)); + } + + // ── Messages ── + + @PostMapping("/messages") + public ResponseEntity sendMessage(@RequestBody SendMessageBody body, Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (body.text() == null || body.text().isBlank()) return ResponseEntity.badRequest().build(); + + // Nachrichten an den Support-Account sind nicht erlaubt + if (SupportUserService.SUPPORT_USER_ID.equals(body.receiverId())) { + return ResponseEntity.status(403).build(); + } + + MessageEntity msg = new MessageEntity(); + msg.setMessageId(UUID.randomUUID()); + msg.setSenderId(myId); + msg.setReceiverId(body.receiverId()); + msg.setText(body.text().trim()); + msg.setSentAt(LocalDateTime.now()); + messageRepository.save(msg); + LOGGER.debug("User {} hat Nachricht an User {} gesendet", myId, body.receiverId()); + long unread = messageRepository.countUnread(body.receiverId()); + sseService.push(body.receiverId(), "DM", Map.of("unreadCount", unread, "senderId", myId.toString())); + return ResponseEntity.status(201).build(); + } + + @GetMapping("/messages") + public ResponseEntity> getConversations(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + List allMessages = messageRepository.findAllByUser(myId); + + // Group by partner, keep most recent message per partner + Map latestByPartner = new LinkedHashMap<>(); + for (MessageEntity m : allMessages) { + UUID partnerId = m.getSenderId().equals(myId) ? m.getReceiverId() : m.getSenderId(); + latestByPartner.putIfAbsent(partnerId, m); + } + + List summaries = new ArrayList<>(); + for (Map.Entry entry : latestByPartner.entrySet()) { + UUID partnerId = entry.getKey(); + MessageEntity lastMsg = entry.getValue(); + var partnerOpt = userRepository.findById(partnerId); + if (partnerOpt.isEmpty()) continue; + + UserProfile partnerProfile = toUserProfileWithStatus(partnerOpt.get(), myId); + MessageDto lastMsgDto = toMessageDto(lastMsg); + long unreadCount = allMessages.stream() + .filter(m -> m.getSenderId().equals(partnerId) + && m.getReceiverId().equals(myId) + && m.getReadAt() == null) + .count(); + summaries.add(new ConversationSummary(partnerProfile, lastMsgDto, unreadCount)); + } + return ResponseEntity.ok(summaries); + } + + @GetMapping("/messages/unread/count") + public ResponseEntity getUnreadCount(Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + return ResponseEntity.ok(messageRepository.countUnread(myId)); + } + + private static final int MSG_PAGE_SIZE = 20; + + @GetMapping("/messages/{partnerId}") + public ResponseEntity getConversation( + @PathVariable UUID partnerId, + @RequestParam(required = false) String before, + @RequestParam(required = false) String after, + Principal principal) { + UUID myId = userService.requireUser(principal).getUserId(); + + if (after != null) { + LocalDateTime afterDt = LocalDateTime.parse(after); + List newMsgs = messageRepository.findConversationAfter(myId, partnerId, afterDt); + messageRepository.markAsRead(myId, partnerId, LocalDateTime.now()); + return ResponseEntity.ok(Map.of("messages", newMsgs.stream().map(this::toMessageDto).toList(), "hasMore", false)); + } + + List messages; + if (before != null) { + LocalDateTime beforeDt = LocalDateTime.parse(before); + messages = new ArrayList<>(messageRepository.findConversationBefore(myId, partnerId, beforeDt, PageRequest.of(0, MSG_PAGE_SIZE + 1))); + } else { + messages = new ArrayList<>(messageRepository.findConversation(myId, partnerId, PageRequest.of(0, MSG_PAGE_SIZE + 1))); + messageRepository.markAsRead(myId, partnerId, LocalDateTime.now()); + } + + boolean hasMore = messages.size() > MSG_PAGE_SIZE; + if (hasMore) messages = messages.subList(0, MSG_PAGE_SIZE); + + // DESC order from DB → reverse to oldest-first for client + Collections.reverse(messages); + return ResponseEntity.ok(Map.of("messages", messages.stream().map(this::toMessageDto).toList(), "hasMore", hasMore)); + } + + // ── Helpers ── + + private UserProfile toUserProfileWithStatus(UserEntity user, UUID myId) { + boolean isOwn = user.getUserId().equals(myId); + String status = "NONE"; + if (!isOwn) { + var existing = friendshipRepository.findExisting(myId, user.getUserId()); + if (existing.isPresent()) { + FriendshipEntity f = existing.get(); + if (f.getStatus() == Status.ACCEPTED) { + status = "FRIEND"; + } else if (f.getSenderId().equals(myId)) { + status = "PENDING_SENT"; + } else { + status = "PENDING_RECEIVED"; + } + } + } + boolean isFriend = isOwn || "FRIEND".equals(status); + + // Grunddaten nur zurückgeben wenn berechtigt + de.oaa.xxx.user.Sichtbarkeit svGd = user.getSichtbarkeitGrunddaten(); + boolean showGrunddaten = isOwn || svGd == de.oaa.xxx.user.Sichtbarkeit.ALLE + || (svGd == de.oaa.xxx.user.Sichtbarkeit.NUR_FREUNDE && isFriend); + + // XP nur zurückgeben wenn berechtigt + de.oaa.xxx.user.Sichtbarkeit svXp = user.getSichtbarkeitXp(); + boolean showXp = isOwn || svXp == de.oaa.xxx.user.Sichtbarkeit.ALLE + || (svXp == de.oaa.xxx.user.Sichtbarkeit.NUR_FREUNDE && isFriend); + + return new UserProfile( + user.getUserId(), user.getName(), user.getProfilePicture(), user.getProfilePictureHq(), + status, + showGrunddaten ? user.getAlter() : null, + showGrunddaten ? user.getGroesse() : null, + showGrunddaten ? user.getGewicht() : null, + showGrunddaten ? user.getGeschlecht() : null, + showGrunddaten ? user.getNeigung() : null, + showGrunddaten ? user.getBeziehungsstatus() : null, + showGrunddaten ? user.getBeschreibung() : null, + showXp ? user.getLockeeXp() : 0, + showXp ? user.getKeyholderXp() : 0, + showXp ? user.getBdsmXp() : 0, + user.getSichtbarkeitGrunddaten(), + user.getSichtbarkeitGalerie(), + user.getSichtbarkeitFreunde(), + user.getSichtbarkeitFeed(), + user.getSichtbarkeitPinnwand(), + user.getSichtbarkeitXp(), + user.getSichtbarkeitLockhistorie(), + user.getSichtbarkeitVorlieben(), + user.isProfilBeiVeroeffentlichungenSichtbar()); + } + + private MessageDto toMessageDto(MessageEntity m) { + String senderName = userRepository.findById(m.getSenderId()) + .map(UserEntity::getName) + .orElse("Unbekannt"); + return new MessageDto( + m.getMessageId(), m.getSenderId(), senderName, + m.getReceiverId(), m.getText(), m.getSentAt(), m.getReadAt() != null); + } +} diff --git a/src/main/java/de/oaa/xxx/social/SseService.java b/src/main/java/de/oaa/xxx/social/SseService.java new file mode 100644 index 0000000..a2d68f4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/SseService.java @@ -0,0 +1,54 @@ +package de.oaa.xxx.social; + +import jakarta.annotation.PreDestroy; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +@Service +public class SseService { + + private final Map> emitters = new ConcurrentHashMap<>(); + + @PreDestroy + public void shutdown() { + emitters.values().forEach(list -> list.forEach(SseEmitter::complete)); + emitters.clear(); + } + + public SseEmitter subscribe(UUID userId) { + SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min – Client reconnects automatically + emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter); + Runnable cleanup = () -> removeEmitter(userId, emitter); + emitter.onCompletion(cleanup); + emitter.onTimeout(() -> { emitter.complete(); cleanup.run(); }); + emitter.onError(e -> cleanup.run()); + return emitter; + } + + /** Pushes a named SSE event to all open connections of the given user. */ + public void push(UUID userId, String eventName, Object data) { + List list = emitters.get(userId); + if (list == null || list.isEmpty()) return; + list.removeIf(emitter -> { + try { + emitter.send(SseEmitter.event().name(eventName).data(data, MediaType.APPLICATION_JSON)); + return false; + } catch (IOException e) { + return true; + } + }); + } + + private void removeEmitter(UUID userId, SseEmitter emitter) { + List list = emitters.get(userId); + if (list != null) list.remove(emitter); + } +} diff --git a/src/main/java/de/oaa/xxx/social/SystemMessageService.java b/src/main/java/de/oaa/xxx/social/SystemMessageService.java new file mode 100644 index 0000000..32dfcf8 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/SystemMessageService.java @@ -0,0 +1,113 @@ +package de.oaa.xxx.social; + +import de.oaa.xxx.mail.Email; +import de.oaa.xxx.mail.MailService; +import de.oaa.xxx.mail.MailTemplateService; +import org.springframework.beans.factory.annotation.Value; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.MessageEntity; +import de.oaa.xxx.social.entity.NotificationPreferenceEntity; +import de.oaa.xxx.social.repository.MessageRepository; +import de.oaa.xxx.social.repository.NotificationPreferenceRepository; +import de.oaa.xxx.user.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Service +public class SystemMessageService { + + private static final Logger LOGGER = LoggerFactory.getLogger(SystemMessageService.class); + + private final MessageRepository messageRepository; + private final NotificationPreferenceRepository preferenceRepository; + private final UserRepository userRepository; + private final SseService sseService; + private final MailService mailService; + private final MailTemplateService mailTemplateService; + + @Value("${app.base-url:http://localhost:8080}") + private String baseUrl; + + public SystemMessageService(MessageRepository messageRepository, + NotificationPreferenceRepository preferenceRepository, + UserRepository userRepository, + SseService sseService, + MailService mailService, + MailTemplateService mailTemplateService) { + this.messageRepository = messageRepository; + this.preferenceRepository = preferenceRepository; + this.userRepository = userRepository; + this.sseService = sseService; + this.mailService = mailService; + this.mailTemplateService = mailTemplateService; + } + + /** + * Sendet eine Systemnachricht unter Berücksichtigung der Benachrichtigungseinstellungen des Empfängers. + */ + public void send(UUID senderId, UUID receiverId, String text, String targetUrl, MessageCause cause) { + if (senderId == null || receiverId == null) return; + + NotificationPreferenceEntity pref = preferenceRepository + .findByUserIdAndCause(receiverId, cause) + .orElseGet(() -> NotificationPreferenceEntity.defaultFor(receiverId, cause)); + + // FRIENDREQUEST und INVITATION sind immer nur in-app, kein E-Mail + boolean sendInApp = cause == MessageCause.FRIENDREQUEST || cause == MessageCause.INVITATION || pref.isInApp(); + + if (sendInApp) { + MessageEntity msg = new MessageEntity(); + msg.setMessageId(UUID.randomUUID()); + msg.setSenderId(senderId); + msg.setReceiverId(receiverId); + msg.setText(text); + msg.setSentAt(LocalDateTime.now()); + msg.setSystemMessage(true); + msg.setMessageCause(cause); + if (targetUrl != null) msg.setTargetUrl(targetUrl); + messageRepository.save(msg); + + long unread = messageRepository.countByReceiverIdAndSystemMessageAndReadAtIsNull(receiverId, true); + sseService.push(receiverId, "NOTIFICATION", Map.of("unreadCount", unread, "text", text)); + } + + if (pref.isEmail() && cause != MessageCause.INVITATION) { + userRepository.findById(receiverId).ifPresent(user -> { + try { + Email email = new Email(); + email.setEmailAdresse(user.getEmail()); + email.setTitel(causeTitel(cause)); + email.setText(mailTemplateService.buildNotificationMail(user.getName(), text, targetUrl, baseUrl)); + mailService.send(email); + } catch (Exception e) { + LOGGER.error("E-Mail-Benachrichtigung fehlgeschlagen für userId={}: {}", receiverId, e.getMessage()); + } + }); + } + } + + /** + * Benachrichtigt den Empfänger per SSE, dass sich seine Einladungsliste geändert hat, + * ohne eine In-App-Nachricht oder E-Mail zu erstellen. + */ + public void pushInvitationUpdate(UUID receiverId) { + if (receiverId == null) return; + sseService.push(receiverId, "INVITATION", java.util.Map.of()); + } + + private String causeTitel(MessageCause cause) { + return switch (cause) { + case INVITATION -> "XXX The Game – Neue Einladung"; + case GAME_STATE -> "XXX The Game – Spielstatus-Änderung"; + case EMERGENCY -> "XXX The Game – ⚠️ Notfall"; + case FRIENDREQUEST -> "XXX The Game – Neue Freundschaftsanfrage"; + case SUPPORT -> "xXx Sphere – Nachricht vom Support"; + }; + } + +} diff --git a/src/main/java/de/oaa/xxx/social/dto/ConversationSummary.java b/src/main/java/de/oaa/xxx/social/dto/ConversationSummary.java new file mode 100644 index 0000000..c15f880 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/ConversationSummary.java @@ -0,0 +1,3 @@ +package de.oaa.xxx.social.dto; + +public record ConversationSummary(UserProfile partner, MessageDto lastMessage, long unreadCount) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/FriendshipDto.java b/src/main/java/de/oaa/xxx/social/dto/FriendshipDto.java new file mode 100644 index 0000000..3534c48 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/FriendshipDto.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.social.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record FriendshipDto(UUID friendshipId, UserProfile user, String status, LocalDateTime createdAt) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/KommentarDto.java b/src/main/java/de/oaa/xxx/social/dto/KommentarDto.java new file mode 100644 index 0000000..ed9e9ea --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/KommentarDto.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.social.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record KommentarDto( + UUID kommentarId, + UUID authorId, + String authorName, + String authorPicture, + String targetType, + UUID targetId, + String text, + LocalDateTime createdAt, + long likeCount, + boolean likedByMe, + long replyCount +) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/MessageDto.java b/src/main/java/de/oaa/xxx/social/dto/MessageDto.java new file mode 100644 index 0000000..74a6ae6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/MessageDto.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.social.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record MessageDto(UUID messageId, UUID senderId, String senderName, UUID receiverId, String text, LocalDateTime sentAt, boolean read) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/PinnwandEintragDto.java b/src/main/java/de/oaa/xxx/social/dto/PinnwandEintragDto.java new file mode 100644 index 0000000..77bfbcf --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/PinnwandEintragDto.java @@ -0,0 +1,17 @@ +package de.oaa.xxx.social.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record PinnwandEintragDto( + UUID eintragId, + UUID profilUserId, + UUID authorId, + String authorName, + String authorPicture, + String text, + LocalDateTime createdAt, + long likeCount, + boolean likedByMe, + long kommentarCount +) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/ProfileImageDto.java b/src/main/java/de/oaa/xxx/social/dto/ProfileImageDto.java new file mode 100644 index 0000000..776661d --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/ProfileImageDto.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.social.dto; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record ProfileImageDto(UUID imageId, UUID userId, String imageData, + LocalDateTime uploadedAt, long likeCount, boolean likedByMe) {} diff --git a/src/main/java/de/oaa/xxx/social/dto/UserProfile.java b/src/main/java/de/oaa/xxx/social/dto/UserProfile.java new file mode 100644 index 0000000..a022972 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/dto/UserProfile.java @@ -0,0 +1,43 @@ +package de.oaa.xxx.social.dto; + +import de.oaa.xxx.user.Beziehungsstatus; +import de.oaa.xxx.user.Geschlecht; +import de.oaa.xxx.user.Neigung; +import de.oaa.xxx.user.Sichtbarkeit; + +import java.util.UUID; + +public record UserProfile( + UUID userId, + String name, + String profilePicture, + String profilePictureHq, + String friendStatus, + Integer alter, + Integer groesse, + Integer gewicht, + Geschlecht geschlecht, + Neigung neigung, + Beziehungsstatus beziehungsstatus, + String beschreibung, + int lockeeXp, + int keyholderXp, + int bdsmXp, + // Datenschutz-Einstellungen + Sichtbarkeit sichtbarkeitGrunddaten, + Sichtbarkeit sichtbarkeitGalerie, + Sichtbarkeit sichtbarkeitFreunde, + Sichtbarkeit sichtbarkeitFeed, + Sichtbarkeit sichtbarkeitPinnwand, + Sichtbarkeit sichtbarkeitXp, + Sichtbarkeit sichtbarkeitLockhistorie, + Sichtbarkeit sichtbarkeitVorlieben, + boolean profilBeiVeroeffentlichungenSichtbar +) { + /** Compact constructor for contexts where profile details are not needed (friend list etc.) */ + public UserProfile(UUID userId, String name, String profilePicture, String profilePictureHq, String friendStatus) { + this(userId, name, profilePicture, profilePictureHq, friendStatus, + null, null, null, null, null, null, null, 0, 0, 0, + null, null, null, null, null, null, null, null, false); + } +} diff --git a/src/main/java/de/oaa/xxx/social/entity/FriendshipEntity.java b/src/main/java/de/oaa/xxx/social/entity/FriendshipEntity.java new file mode 100644 index 0000000..b555ba2 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/FriendshipEntity.java @@ -0,0 +1,34 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "friendship") +public class FriendshipEntity { + + public enum Status { PENDING, ACCEPTED } + + @Id + @Column + private UUID friendshipId; + + @Column(nullable = false) + private UUID senderId; + + @Column(nullable = false) + private UUID receiverId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private Status status; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/KommentarEntity.java b/src/main/java/de/oaa/xxx/social/entity/KommentarEntity.java new file mode 100644 index 0000000..a530f78 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/KommentarEntity.java @@ -0,0 +1,35 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "kommentar") +public class KommentarEntity { + + /** targetType values: PINNWAND, IMAGE, KOMMENTAR */ + @Id + @Column + private UUID kommentarId; + + @Column(nullable = false) + private UUID authorId; + + @Column(length = 20, nullable = false) + private String targetType; + + @Column(nullable = false) + private UUID targetId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/KommentarLikeEntity.java b/src/main/java/de/oaa/xxx/social/entity/KommentarLikeEntity.java new file mode 100644 index 0000000..25ad9a1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/KommentarLikeEntity.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "kommentar_like") +public class KommentarLikeEntity { + + @Id + @Column + private UUID likeId; + + @Column(nullable = false) + private UUID kommentarId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private LocalDateTime likedAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/MessageCause.java b/src/main/java/de/oaa/xxx/social/entity/MessageCause.java new file mode 100644 index 0000000..c48a816 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/MessageCause.java @@ -0,0 +1,9 @@ +package de.oaa.xxx.social.entity; + +public enum MessageCause { + INVITATION, + GAME_STATE, + EMERGENCY, + FRIENDREQUEST, + SUPPORT +} diff --git a/src/main/java/de/oaa/xxx/social/entity/MessageEntity.java b/src/main/java/de/oaa/xxx/social/entity/MessageEntity.java new file mode 100644 index 0000000..0027ed1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/MessageEntity.java @@ -0,0 +1,44 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "message") +public class MessageEntity { + + @Id + @Column + private UUID messageId; + + @Column(nullable = false) + private UUID senderId; + + @Column(nullable = false) + private UUID receiverId; + + @Column(columnDefinition = "MEDIUMTEXT", nullable = false) + private String text; + + @Column(nullable = false) + private LocalDateTime sentAt; + + @Column + private LocalDateTime readAt; + + @Column(nullable = false) + private boolean systemMessage = false; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private MessageCause messageCause; + + @Column(length = 500) + private String targetUrl; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/NotificationPreferenceEntity.java b/src/main/java/de/oaa/xxx/social/entity/NotificationPreferenceEntity.java new file mode 100644 index 0000000..2fdc08e --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/NotificationPreferenceEntity.java @@ -0,0 +1,40 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "notification_preference", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "cause"})) +public class NotificationPreferenceEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private MessageCause cause; + + @Column(nullable = false) + private boolean inApp = true; + + @Column(nullable = false) + private boolean email = false; + + /** Erzeugt eine nicht persistierte Standardpräferenz für unbekannte/neue Causes. */ + public static NotificationPreferenceEntity defaultFor(UUID userId, MessageCause cause) { + NotificationPreferenceEntity p = new NotificationPreferenceEntity(); + p.setUserId(userId); + p.setCause(cause); + // inApp=true und email=false sind bereits die Java-Felddefaults + return p; + } +} diff --git a/src/main/java/de/oaa/xxx/social/entity/PinnwandEintragEntity.java b/src/main/java/de/oaa/xxx/social/entity/PinnwandEintragEntity.java new file mode 100644 index 0000000..59ecbca --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/PinnwandEintragEntity.java @@ -0,0 +1,31 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "pinnwand_eintrag") +public class PinnwandEintragEntity { + + @Id + @Column + private UUID eintragId; + + @Column(nullable = false) + private UUID profilUserId; + + @Column(nullable = false) + private UUID authorId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/PinnwandLikeEntity.java b/src/main/java/de/oaa/xxx/social/entity/PinnwandLikeEntity.java new file mode 100644 index 0000000..abd7995 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/PinnwandLikeEntity.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "pinnwand_like") +public class PinnwandLikeEntity { + + @Id + @Column + private UUID likeId; + + @Column(nullable = false) + private UUID eintragId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private LocalDateTime likedAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/ProfileImageEntity.java b/src/main/java/de/oaa/xxx/social/entity/ProfileImageEntity.java new file mode 100644 index 0000000..edacab1 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/ProfileImageEntity.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "profile_image") +public class ProfileImageEntity { + + @Id + @Column + private UUID imageId; + + @Column(nullable = false) + private UUID userId; + + @Column(columnDefinition = "MEDIUMTEXT", nullable = false) + private String imageData; + + @Column(nullable = false) + private LocalDateTime uploadedAt; +} diff --git a/src/main/java/de/oaa/xxx/social/entity/ProfileImageLikeEntity.java b/src/main/java/de/oaa/xxx/social/entity/ProfileImageLikeEntity.java new file mode 100644 index 0000000..b321613 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/entity/ProfileImageLikeEntity.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "profile_image_like") +public class ProfileImageLikeEntity { + + @Id + @Column + private UUID likeId; + + @Column(nullable = false) + private UUID imageId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private LocalDateTime likedAt; +} diff --git a/src/main/java/de/oaa/xxx/social/repository/FriendshipRepository.java b/src/main/java/de/oaa/xxx/social/repository/FriendshipRepository.java new file mode 100644 index 0000000..1fc4375 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/FriendshipRepository.java @@ -0,0 +1,24 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.FriendshipEntity; +import de.oaa.xxx.social.entity.FriendshipEntity.Status; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface FriendshipRepository extends JpaRepository { + + List findByReceiverIdAndStatus(UUID receiverId, Status status); + + long countByReceiverIdAndStatus(UUID receiverId, Status status); + + @Query("SELECT f FROM FriendshipEntity f WHERE (f.senderId = :userId OR f.receiverId = :userId) AND f.status = :status") + List findFriends(@Param("userId") UUID userId, @Param("status") Status status); + + @Query("SELECT f FROM FriendshipEntity f WHERE (f.senderId = :userA AND f.receiverId = :userB) OR (f.senderId = :userB AND f.receiverId = :userA)") + Optional findExisting(@Param("userA") UUID userA, @Param("userB") UUID userB); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/KommentarLikeRepository.java b/src/main/java/de/oaa/xxx/social/repository/KommentarLikeRepository.java new file mode 100644 index 0000000..0892a31 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/KommentarLikeRepository.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.KommentarLikeEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface KommentarLikeRepository extends JpaRepository { + + long countByKommentarId(UUID kommentarId); + + Optional findByKommentarIdAndUserId(UUID kommentarId, UUID userId); + + void deleteByKommentarId(UUID kommentarId); + + void deleteByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/KommentarRepository.java b/src/main/java/de/oaa/xxx/social/repository/KommentarRepository.java new file mode 100644 index 0000000..d13189b --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/KommentarRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.KommentarEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface KommentarRepository extends JpaRepository { + + List findByTargetTypeAndTargetIdOrderByCreatedAtAsc(String targetType, UUID targetId); + + long countByTargetTypeAndTargetId(String targetType, UUID targetId); + + void deleteByAuthorId(UUID authorId); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/MessageRepository.java b/src/main/java/de/oaa/xxx/social/repository/MessageRepository.java new file mode 100644 index 0000000..ce749be --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/MessageRepository.java @@ -0,0 +1,57 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.MessageEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +public interface MessageRepository extends JpaRepository { + + // ── DM queries (systemMessage = false) ──────────────────────────────────── + + @Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false ORDER BY m.sentAt DESC") + List findConversation(@Param("userA") UUID userA, @Param("userB") UUID userB, Pageable pageable); + + @Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false AND m.sentAt < :before ORDER BY m.sentAt DESC") + List findConversationBefore(@Param("userA") UUID userA, @Param("userB") UUID userB, @Param("before") LocalDateTime before, Pageable pageable); + + @Query("SELECT m FROM MessageEntity m WHERE ((m.senderId = :userA AND m.receiverId = :userB) OR (m.senderId = :userB AND m.receiverId = :userA)) AND m.systemMessage = false AND m.sentAt > :after ORDER BY m.sentAt ASC") + List findConversationAfter(@Param("userA") UUID userA, @Param("userB") UUID userB, @Param("after") LocalDateTime after); + + @Query("SELECT m FROM MessageEntity m WHERE (m.senderId = :userId OR m.receiverId = :userId) AND m.systemMessage = false ORDER BY m.sentAt DESC") + List findAllByUser(@Param("userId") UUID userId); + + @Query("SELECT COUNT(m) FROM MessageEntity m WHERE m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false") + long countUnread(@Param("userId") UUID userId); + + @Modifying + @Transactional + @Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.senderId = :partnerId AND m.receiverId = :userId AND m.readAt IS NULL AND m.systemMessage = false") + void markAsRead(@Param("userId") UUID userId, @Param("partnerId") UUID partnerId, @Param("now") LocalDateTime now); + + // ── Notification queries (systemMessage = true) ─────────────────────────── + + /** Ungelesene zuerst, dann nach sentAt absteigend, max. 10 Einträge. */ + @Query("SELECT m FROM MessageEntity m WHERE m.receiverId = :receiverId AND m.systemMessage = true " + + "ORDER BY CASE WHEN m.readAt IS NULL THEN 0 ELSE 1 END, m.sentAt DESC") + List findNotificationsForUser(@Param("receiverId") UUID receiverId, Pageable pageable); + + long countByReceiverIdAndSystemMessageAndReadAtIsNull(UUID receiverId, boolean systemMessage); + + @Modifying + @Transactional + @Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.receiverId = :userId AND m.systemMessage = true AND m.readAt IS NULL") + void markAllNotificationsAsRead(@Param("userId") UUID userId, @Param("now") LocalDateTime now); + + @Modifying + @Transactional + @Query("UPDATE MessageEntity m SET m.readAt = :now WHERE m.messageId = :id AND m.receiverId = :userId AND m.readAt IS NULL") + void markNotificationAsRead(@Param("id") UUID id, @Param("userId") UUID userId, @Param("now") LocalDateTime now); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/NotificationPreferenceRepository.java b/src/main/java/de/oaa/xxx/social/repository/NotificationPreferenceRepository.java new file mode 100644 index 0000000..2d2d1f9 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/NotificationPreferenceRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.NotificationPreferenceEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface NotificationPreferenceRepository extends JpaRepository { + + List findByUserId(UUID userId); + + Optional findByUserIdAndCause(UUID userId, MessageCause cause); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/PinnwandEintragRepository.java b/src/main/java/de/oaa/xxx/social/repository/PinnwandEintragRepository.java new file mode 100644 index 0000000..13370b4 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/PinnwandEintragRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.PinnwandEintragEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface PinnwandEintragRepository extends JpaRepository { + + List findByProfilUserIdOrderByCreatedAtDesc(UUID profilUserId); + + void deleteByProfilUserId(UUID profilUserId); + + void deleteByAuthorId(UUID authorId); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/PinnwandLikeRepository.java b/src/main/java/de/oaa/xxx/social/repository/PinnwandLikeRepository.java new file mode 100644 index 0000000..9861ede --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/PinnwandLikeRepository.java @@ -0,0 +1,18 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.PinnwandLikeEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface PinnwandLikeRepository extends JpaRepository { + + long countByEintragId(UUID eintragId); + + Optional findByEintragIdAndUserId(UUID eintragId, UUID userId); + + void deleteByEintragId(UUID eintragId); + + void deleteByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/ProfileImageLikeRepository.java b/src/main/java/de/oaa/xxx/social/repository/ProfileImageLikeRepository.java new file mode 100644 index 0000000..2e719e0 --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/ProfileImageLikeRepository.java @@ -0,0 +1,28 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.ProfileImageLikeEntity; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import java.util.UUID; + +public interface ProfileImageLikeRepository extends JpaRepository { + + Optional findByImageIdAndUserId(UUID imageId, UUID userId); + + long countByImageId(UUID imageId); + + @Modifying + @Transactional + @Query("DELETE FROM ProfileImageLikeEntity l WHERE l.imageId = :imageId") + void deleteByImageId(@Param("imageId") UUID imageId); + + @Modifying + @Transactional + @Query("DELETE FROM ProfileImageLikeEntity l WHERE l.userId = :userId") + void deleteByUserId(@Param("userId") UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/social/repository/ProfileImageRepository.java b/src/main/java/de/oaa/xxx/social/repository/ProfileImageRepository.java new file mode 100644 index 0000000..fe4a94a --- /dev/null +++ b/src/main/java/de/oaa/xxx/social/repository/ProfileImageRepository.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.social.repository; + +import de.oaa.xxx.social.entity.ProfileImageEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface ProfileImageRepository extends JpaRepository { + + List findByUserIdOrderByUploadedAtDesc(UUID userId); + + long countByUserId(UUID userId); +} diff --git a/src/main/java/de/oaa/xxx/subscription/SubscriptionController.java b/src/main/java/de/oaa/xxx/subscription/SubscriptionController.java new file mode 100644 index 0000000..ed6a8de --- /dev/null +++ b/src/main/java/de/oaa/xxx/subscription/SubscriptionController.java @@ -0,0 +1,53 @@ +package de.oaa.xxx.subscription; + +import de.oaa.xxx.user.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.security.Principal; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/subscription") +public class SubscriptionController { + + private final UserService userService; + private final SubscriptionLimitService limitService; + + public SubscriptionController(UserService userService, + SubscriptionLimitService limitService) { + this.userService = userService; + this.limitService = limitService; + } + + @GetMapping("/me") + public ResponseEntity> getMySubscription(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + Map result = new LinkedHashMap<>(); + limitService.getActiveSubscription(userId).ifPresentOrElse(sub -> { + result.put("subscriptionType", sub.getSubscriptionType()); + result.put("subscribedAt", sub.getSubscribedAt()); + result.put("validUntil", sub.getValidUntil()); + result.put("cancellableFrom", sub.getCancellableFrom()); + }, () -> { + result.put("subscriptionType", SubscriptionType.STANDARD); + result.put("subscribedAt", null); + result.put("validUntil", null); + result.put("cancellableFrom", null); + }); + + result.put("limits", Map.of( + "maxLockTemplates", limitService.maxLockTemplates(userId), + "maxTaskGroups", limitService.maxTaskGroups(userId), + "maxTasksPerGroup", limitService.maxTasksPerGroup(userId), + "maxToys", limitService.maxToys(userId) + )); + + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/de/oaa/xxx/subscription/SubscriptionLimitService.java b/src/main/java/de/oaa/xxx/subscription/SubscriptionLimitService.java new file mode 100644 index 0000000..dbe75fc --- /dev/null +++ b/src/main/java/de/oaa/xxx/subscription/SubscriptionLimitService.java @@ -0,0 +1,71 @@ +package de.oaa.xxx.subscription; + +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +/** + * Central service for subscription-based feature limits. + * All limit constants are defined here – add new resource types as needed. + */ +@Service +public class SubscriptionLimitService { + + // ── Limits for STANDARD (no active subscription) ── + public static final int STANDARD_MAX_LOCK_TEMPLATES = 6; + public static final int STANDARD_MAX_TASK_GROUPS = 6; + public static final int STANDARD_MAX_TASKS_PER_GROUP = 50; + public static final int STANDARD_MAX_TOYS = 10; + public static final int STANDARD_MAX_KEYHOLDER_OFFERS = 5; + + private final UserSubscriptionRepository subscriptionRepository; + + public SubscriptionLimitService(UserSubscriptionRepository subscriptionRepository) { + this.subscriptionRepository = subscriptionRepository; + } + + /** Returns the active subscription for the user, or empty if none. */ + public Optional getActiveSubscription(UUID userId) { + return subscriptionRepository + .findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc(userId, LocalDate.now()); + } + + /** True if the user has an active (non-STANDARD) subscription. */ + public boolean hasActivePaidSubscription(UUID userId) { + return getActiveSubscription(userId) + .filter(s -> s.getSubscriptionType() != SubscriptionType.STANDARD) + .isPresent(); + } + + /** Max total lock templates (cardlock + timelock combined) allowed for this user. */ + public int maxLockTemplates(UUID userId) { + if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE; + return STANDARD_MAX_LOCK_TEMPLATES; + } + + /** Max task groups the user may own. */ + public int maxTaskGroups(UUID userId) { + if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE; + return STANDARD_MAX_TASK_GROUPS; + } + + /** Max tasks per task group. */ + public int maxTasksPerGroup(UUID userId) { + if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE; + return STANDARD_MAX_TASKS_PER_GROUP; + } + + /** Max individual toys the user may create. */ + public int maxToys(UUID userId) { + if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE; + return STANDARD_MAX_TOYS; + } + + /** Max keyholder offers the user may create. */ + public int maxKeyholderOffers(UUID userId) { + if (hasActivePaidSubscription(userId)) return Integer.MAX_VALUE; + return STANDARD_MAX_KEYHOLDER_OFFERS; + } +} diff --git a/src/main/java/de/oaa/xxx/subscription/SubscriptionType.java b/src/main/java/de/oaa/xxx/subscription/SubscriptionType.java new file mode 100644 index 0000000..b475f13 --- /dev/null +++ b/src/main/java/de/oaa/xxx/subscription/SubscriptionType.java @@ -0,0 +1,6 @@ +package de.oaa.xxx.subscription; + +public enum SubscriptionType { + STANDARD, + PREMIUM +} diff --git a/src/main/java/de/oaa/xxx/subscription/UserSubscriptionEntity.java b/src/main/java/de/oaa/xxx/subscription/UserSubscriptionEntity.java new file mode 100644 index 0000000..a7a6ddb --- /dev/null +++ b/src/main/java/de/oaa/xxx/subscription/UserSubscriptionEntity.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.subscription; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "user_subscription") +public class UserSubscriptionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "subscription_id") + private UUID subscriptionId; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "subscribed_at", nullable = false) + private LocalDate subscribedAt; + + @Column(name = "valid_until", nullable = false) + private LocalDate validUntil; + + @Enumerated(EnumType.STRING) + @Column(name = "subscription_type", nullable = false, length = 30) + private SubscriptionType subscriptionType; + + @Column(name = "cancellable_from") + private LocalDate cancellableFrom; +} diff --git a/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java b/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java new file mode 100644 index 0000000..19dafab --- /dev/null +++ b/src/main/java/de/oaa/xxx/subscription/UserSubscriptionRepository.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.subscription; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserSubscriptionRepository extends JpaRepository { + + Optional findTopByUserIdAndValidUntilGreaterThanEqualOrderByValidUntilDesc( + UUID userId, LocalDate today); + + List findByValidUntilGreaterThanEqualOrderByValidUntilDesc(LocalDate today); +} diff --git a/src/main/java/de/oaa/xxx/support/SupportUserService.java b/src/main/java/de/oaa/xxx/support/SupportUserService.java new file mode 100644 index 0000000..754d789 --- /dev/null +++ b/src/main/java/de/oaa/xxx/support/SupportUserService.java @@ -0,0 +1,112 @@ +package de.oaa.xxx.support; + +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.MessageEntity; +import de.oaa.xxx.social.repository.MessageRepository; +import de.oaa.xxx.user.UserEntity; +import de.oaa.xxx.user.UserRepository; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.UUID; + +@Service +public class SupportUserService { + + private static final Logger LOGGER = LoggerFactory.getLogger(SupportUserService.class); + + /** Deterministischer UUID – ändert sich nie. */ + public static final UUID SUPPORT_USER_ID = + UUID.nameUUIDFromBytes("xxxsphere-support".getBytes(StandardCharsets.UTF_8)); + + private static final String SUPPORT_NAME = "xXx Support"; + private static final String SUPPORT_EMAIL = "support@system.local"; + + private final UserRepository userRepository; + private final MessageRepository messageRepository; + + public SupportUserService(UserRepository userRepository, MessageRepository messageRepository) { + this.userRepository = userRepository; + this.messageRepository = messageRepository; + } + + /** Stellt sicher, dass der Support-Fake-User in der DB existiert. */ + @PostConstruct + public void ensureExists() { + try { + String icon = loadIconBase64(); + userRepository.findById(SUPPORT_USER_ID).ifPresentOrElse(u -> { + if (u.getProfilePicture() == null && icon != null) { + try { + u.setProfilePicture(icon); + userRepository.save(u); + } catch (Exception e) { + LOGGER.warn("Support-User-Avatar konnte nicht gespeichert werden: {}", e.getMessage()); + } + } + }, () -> { + UserEntity u = new UserEntity(); + u.setUserId(SUPPORT_USER_ID); + u.setName(SUPPORT_NAME); + u.setEmail(SUPPORT_EMAIL); + u.setPassword("__SYSTEM__"); // kein gültiges Login + try { + u.setProfilePicture(icon); + userRepository.save(u); + } catch (Exception e) { + LOGGER.warn("Support-User konnte nicht mit Avatar gespeichert werden, versuche ohne: {}", e.getMessage()); + u.setProfilePicture(null); + userRepository.save(u); + } + }); + } catch (Exception e) { + LOGGER.error("Support-User konnte nicht initialisiert werden: {}", e.getMessage()); + } + } + + private String loadIconBase64() { + try { + byte[] bytes = new ClassPathResource("static/img/icon.png").getInputStream().readAllBytes(); + BufferedImage original = ImageIO.read(new ByteArrayInputStream(bytes)); + BufferedImage thumb = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = thumb.createGraphics(); + g.drawImage(original.getScaledInstance(64, 64, Image.SCALE_SMOOTH), 0, 0, null); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(thumb, "png", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (Exception e) { + LOGGER.warn("Support-Avatar konnte nicht geladen werden: {}", e.getMessage()); + return null; + } + } + + /** + * Sendet eine Direktnachricht vom Support-Account an den Nutzer. + * Die Nachricht erscheint als normale DM (systemMessage = false), + * damit sie in nachrichten.html sichtbar ist. + */ + public void sendDm(UUID receiverId, String text) { + if (receiverId == null) return; + MessageEntity msg = new MessageEntity(); + msg.setMessageId(UUID.randomUUID()); + msg.setSenderId(SUPPORT_USER_ID); + msg.setReceiverId(receiverId); + msg.setText(text); + msg.setSentAt(LocalDateTime.now()); + msg.setSystemMessage(false); + msg.setMessageCause(MessageCause.SUPPORT); + messageRepository.save(msg); + } +} diff --git a/src/main/java/de/oaa/xxx/user/Beziehungsstatus.java b/src/main/java/de/oaa/xxx/user/Beziehungsstatus.java new file mode 100644 index 0000000..053fb9b --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/Beziehungsstatus.java @@ -0,0 +1,15 @@ +package de.oaa.xxx.user; + +public enum Beziehungsstatus { + SINGLE("single"), + IN_EINER_BEZIEHUNG("in einer Beziehung"), + VERHEIRATET("verheiratet"), + IN_EINER_OFFENEN_BEZIEHUNG("in einer offenen Beziehung"), + IN_EINER_OFFENEN_EHE("in einer offenen Ehe"); + + private final String label; + + Beziehungsstatus(String label) { this.label = label; } + + public String getLabel() { return label; } +} diff --git a/src/main/java/de/oaa/xxx/user/Geschlecht.java b/src/main/java/de/oaa/xxx/user/Geschlecht.java new file mode 100644 index 0000000..aa97eeb --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/Geschlecht.java @@ -0,0 +1,13 @@ +package de.oaa.xxx.user; + +public enum Geschlecht { + WEIBLICH("weiblich"), + DIVERS("divers"), + MAENNLICH("männlich"); + + private final String label; + + Geschlecht(String label) { this.label = label; } + + public String getLabel() { return label; } +} diff --git a/src/main/java/de/oaa/xxx/user/LoginController.java b/src/main/java/de/oaa/xxx/user/LoginController.java new file mode 100644 index 0000000..eaf2242 --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/LoginController.java @@ -0,0 +1,96 @@ +package de.oaa.xxx.user; + +import de.oaa.xxx.admin.AdminRepository; +import de.oaa.xxx.config.JwtService; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +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 java.security.Principal; +import java.time.Duration; +import java.util.UUID; + +@RestController +@RequestMapping("/login") +public class LoginController { + + private static final Logger LOGGER = LoggerFactory.getLogger(LoginController.class); + + record LoginRequest(String email, String password) {} + + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final AdminRepository adminRepository; + private final UserService userService; + + public LoginController(UserRepository userRepository, JwtService jwtService, PasswordEncoder passwordEncoder, AdminRepository adminRepository, UserService userService) { + this.userRepository = userRepository; + this.jwtService = jwtService; + this.passwordEncoder = passwordEncoder; + this.adminRepository = adminRepository; + this.userService = userService; + } + + @PostMapping + public ResponseEntity login(@RequestBody LoginRequest request, HttpServletResponse response) { + var userOpt = userRepository.findByEmail(request.email()); + if (userOpt.isPresent() && passwordEncoder.matches(request.password(), userOpt.get().getPassword())) { + UserEntity user = userOpt.get(); + LOGGER.info("User erfolgreich angemeldet: {}", request.email()); + String token = jwtService.generateToken(user.getEmail(), user.getName()); + ResponseCookie cookie = ResponseCookie.from("jwt", token) + .httpOnly(true) + .sameSite("Strict") + .path("/") + .maxAge(Duration.ofHours(24)) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + User u = user.toUser(); + u.setAdmin(adminRepository.existsByUserId(user.getUserId())); + return ResponseEntity.ok(u); + } else { + return ResponseEntity.noContent().build(); + } + } + + @GetMapping("/me") + public ResponseEntity me(Principal principal) { + if (principal == null) { + return ResponseEntity.status(401).build(); + } + UserEntity entity = userService.requireUser(principal); + User u = entity.toUser(); + u.setAdmin(adminRepository.existsByUserId(entity.getUserId())); + return ResponseEntity.ok(u); + } + + @GetMapping("/logout") + public void logout(HttpServletResponse response) throws java.io.IOException { + ResponseCookie cookie = ResponseCookie.from("jwt", "") + .httpOnly(true) + .sameSite("Strict") + .path("/") + .maxAge(0) + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + response.sendRedirect("/"); + } + + @GetMapping("/{userId}") + public ResponseEntity get(@PathVariable UUID userId) { + return userRepository.findById(userId) + .map(entity -> ResponseEntity.ok(entity.toUser())) + .orElse(ResponseEntity.noContent().build()); + } +} diff --git a/src/main/java/de/oaa/xxx/user/Neigung.java b/src/main/java/de/oaa/xxx/user/Neigung.java new file mode 100644 index 0000000..fadd2f3 --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/Neigung.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.user; + +public enum Neigung { + DEVOT("devot"), + EHER_DEVOT("eher devot"), + SWITCHER("Switcher"), + EHER_DOMINANT("eher dominant"), + DOMINANT("dominant"), + KEINES("keines"); + + private final String label; + + Neigung(String label) { this.label = label; } + + public String getLabel() { return label; } +} diff --git a/src/main/java/de/oaa/xxx/user/Sichtbarkeit.java b/src/main/java/de/oaa/xxx/user/Sichtbarkeit.java new file mode 100644 index 0000000..7bfb559 --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/Sichtbarkeit.java @@ -0,0 +1,7 @@ +package de.oaa.xxx.user; + +public enum Sichtbarkeit { + ALLE, + NUR_FREUNDE, + NUR_ICH +} diff --git a/src/main/java/de/oaa/xxx/user/User.java b/src/main/java/de/oaa/xxx/user/User.java new file mode 100644 index 0000000..ce2eb8e --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/User.java @@ -0,0 +1,36 @@ +package de.oaa.xxx.user; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.Period; +import java.util.UUID; + +@Getter +@Setter +public class User { + + private UUID userId; + private String name; + private String email; + private String password; + private boolean admin; + private String profilePicture; + private LocalDate geburtsdatum; + private Integer groesse; + private Integer gewicht; + private Geschlecht geschlecht; + private Neigung neigung; + private Beziehungsstatus beziehungsstatus; + private String beschreibung; + + public Integer getAlter() { + return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null; + } + + @Override + public String toString() { + return "User[userId=" + userId + ", name=" + name + ", email=" + email + "]"; + } +} diff --git a/src/main/java/de/oaa/xxx/user/UserController.java b/src/main/java/de/oaa/xxx/user/UserController.java new file mode 100644 index 0000000..a8f510f --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/UserController.java @@ -0,0 +1,442 @@ +package de.oaa.xxx.user; + +import java.security.Principal; +import java.time.LocalDate; +import java.time.Period; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.oaa.xxx.games.bdsm.entity.BdsmDefaultsEntity; +import de.oaa.xxx.games.bdsm.repository.BdsmDefaultsRepository; +import de.oaa.xxx.games.chastity.common.BaseLockRepository; +import de.oaa.xxx.games.chastity.common.BaseLockTemplateRepository; +import de.oaa.xxx.games.chastity.common.CodeCreator; +import de.oaa.xxx.games.chastity.ttlock.TTAuthService; +import de.oaa.xxx.games.chastity.ttlock.TTLockConfigRepository; +import de.oaa.xxx.games.chastity.ttlock.TTLockService; +import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigEntity; +import de.oaa.xxx.games.chastity.ttlock.TTLockUserConfigRepository; +import de.oaa.xxx.registration.Registration; +import de.oaa.xxx.registration.RegistrationRepository; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.NotificationPreferenceEntity; +import de.oaa.xxx.social.repository.NotificationPreferenceRepository; +import org.springframework.util.DigestUtils; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/user") +public class UserController { + + private static final Logger LOGGER = LoggerFactory.getLogger(UserController.class); + + private final UserRepository userRepository; + private final RegistrationRepository registrationRepository; + private final NotificationPreferenceRepository notificationPreferenceRepository; + private final BdsmDefaultsRepository bdsmDefaultsRepository; + private final UserService userService; + private final TTLockUserConfigRepository ttLockUserConfigRepository; + private final TTLockConfigRepository ttLockConfigRepository; + private final TTAuthService ttAuthService; + private final TTLockService ttLockService; + private final BaseLockRepository baseLockRepository; + private final BaseLockTemplateRepository baseLockTemplateRepository; + + public UserController(UserRepository userRepository, + RegistrationRepository registrationRepository, + NotificationPreferenceRepository notificationPreferenceRepository, + BdsmDefaultsRepository bdsmDefaultsRepository, + UserService userService, + TTLockUserConfigRepository ttLockUserConfigRepository, + TTLockConfigRepository ttLockConfigRepository, + TTAuthService ttAuthService, + TTLockService ttLockService, + BaseLockRepository baseLockRepository, + BaseLockTemplateRepository baseLockTemplateRepository) { + this.userRepository = userRepository; + this.registrationRepository = registrationRepository; + this.notificationPreferenceRepository = notificationPreferenceRepository; + this.bdsmDefaultsRepository = bdsmDefaultsRepository; + this.userService = userService; + this.ttLockUserConfigRepository = ttLockUserConfigRepository; + this.ttLockConfigRepository = ttLockConfigRepository; + this.ttAuthService = ttAuthService; + this.ttLockService = ttLockService; + this.baseLockRepository = baseLockRepository; + this.baseLockTemplateRepository = baseLockTemplateRepository; + } + + record ProfilePictureRequest(String picture, String pictureHq) {} + record NameChangeRequest(String name) {} + record GeburtsdatumChangeRequest(LocalDate geburtsdatum) {} + record TtlockUserConfigDto(String username, boolean passwordSet, Integer lockId, boolean testSuccessful) {} + record TtlockUserConfigRequest(String username, String password, Integer lockId) {} + record ProfileRequest(Integer groesse, Integer gewicht, + Geschlecht geschlecht, Neigung neigung, Beziehungsstatus beziehungsstatus, String beschreibung) {} + record PrivacyRequest( + Sichtbarkeit sichtbarkeitGrunddaten, + Sichtbarkeit sichtbarkeitGalerie, + Sichtbarkeit sichtbarkeitFreunde, + Sichtbarkeit sichtbarkeitFeed, + Sichtbarkeit sichtbarkeitPinnwand, + Sichtbarkeit sichtbarkeitXp, + Sichtbarkeit sichtbarkeitLockhistorie, + Sichtbarkeit sichtbarkeitVorlieben, + Boolean profilBeiVeroeffentlichungenSichtbar) {} + + @PutMapping("/me/picture") + public ResponseEntity updateProfilePicture(@RequestBody ProfilePictureRequest request, Principal principal) { + var user = userService.requireUser(principal); + user.setProfilePicture(request.picture()); + user.setProfilePictureHq(request.pictureHq()); + userRepository.save(user); + LOGGER.debug("User {} hat Profilbild aktualisiert", user.getUserId()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/profile") + public ResponseEntity updateProfile(@RequestBody ProfileRequest request, Principal principal) { + var user = userService.requireUser(principal); + if (request.beschreibung() != null && request.beschreibung().length() > 600) { + return ResponseEntity.badRequest().build(); + } + user.setGroesse(request.groesse()); + user.setGewicht(request.gewicht()); + user.setGeschlecht(request.geschlecht()); + user.setNeigung(request.neigung()); + user.setBeziehungsstatus(request.beziehungsstatus()); + user.setBeschreibung(request.beschreibung()); + userRepository.save(user); + LOGGER.info("User {} hat Profil aktualisiert", user.getUserId()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/privacy") + public ResponseEntity updatePrivacy(@RequestBody PrivacyRequest request, Principal principal) { + var user = userService.requireUser(principal); + if (request.sichtbarkeitGrunddaten() != null) user.setSichtbarkeitGrunddaten(request.sichtbarkeitGrunddaten()); + if (request.sichtbarkeitGalerie() != null) user.setSichtbarkeitGalerie(request.sichtbarkeitGalerie()); + if (request.sichtbarkeitFreunde() != null) user.setSichtbarkeitFreunde(request.sichtbarkeitFreunde()); + if (request.sichtbarkeitFeed() != null) user.setSichtbarkeitFeed(request.sichtbarkeitFeed()); + if (request.sichtbarkeitPinnwand() != null) user.setSichtbarkeitPinnwand(request.sichtbarkeitPinnwand()); + if (request.sichtbarkeitXp() != null) user.setSichtbarkeitXp(request.sichtbarkeitXp()); + if (request.sichtbarkeitLockhistorie()!= null) user.setSichtbarkeitLockhistorie(request.sichtbarkeitLockhistorie()); + if (request.sichtbarkeitVorlieben() != null) user.setSichtbarkeitVorlieben(request.sichtbarkeitVorlieben()); + if (request.profilBeiVeroeffentlichungenSichtbar() != null) { + boolean showAuthor = request.profilBeiVeroeffentlichungenSichtbar(); + user.setProfilBeiVeroeffentlichungenSichtbar(showAuthor); + // Alle veröffentlichten Templates synchronisieren + var templates = baseLockTemplateRepository.findByOwnerAndPublishedTrue(user.getUserId()); + for (var t : templates) { + t.setShowAuthor(showAuthor); + } + baseLockTemplateRepository.saveAll(templates); + } + userRepository.save(user); + LOGGER.info("User {} hat Datenschutz-Einstellungen aktualisiert", user.getUserId()); + return ResponseEntity.ok().build(); + } + + record NotificationPreferenceRequest(boolean inApp, boolean email) {} + + @GetMapping("/me/notifications") + public ResponseEntity> getNotifications(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + Map byKey = notificationPreferenceRepository.findByUserId(userId) + .stream().collect(Collectors.toMap(p -> p.getCause().name(), p -> p)); + + Map result = new LinkedHashMap<>(); + for (MessageCause cause : MessageCause.values()) { + NotificationPreferenceEntity pref = byKey.getOrDefault( + cause.name(), NotificationPreferenceEntity.defaultFor(userId, cause)); + Map entry = new LinkedHashMap<>(); + entry.put("inApp", pref.isInApp()); + entry.put("email", pref.isEmail()); + result.put(cause.name(), entry); + } + return ResponseEntity.ok(result); + } + + @PutMapping("/me/notifications") + public ResponseEntity updateNotifications(@RequestBody Map request, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + for (var entry : request.entrySet()) { + MessageCause cause; + try { + cause = MessageCause.valueOf(entry.getKey()); + } catch (IllegalArgumentException e) { + continue; + } + NotificationPreferenceEntity pref = notificationPreferenceRepository + .findByUserIdAndCause(userId, cause) + .orElseGet(() -> { + NotificationPreferenceEntity n = new NotificationPreferenceEntity(); + n.setUserId(userId); + n.setCause(cause); + return n; + }); + pref.setInApp(entry.getValue().inApp()); + pref.setEmail(entry.getValue().email()); + notificationPreferenceRepository.save(pref); + } + return ResponseEntity.ok().build(); + } + + record BdsmDefaultsRequest(List spieltMit, List rollen, List werkzeuge) {} + + @GetMapping("/me/bdsm-defaults") + public ResponseEntity> getBdsmDefaults(Principal principal) { + var currentUser = userService.requireUser(principal); + UUID userId = currentUser.getUserId(); + + BdsmDefaultsEntity d = bdsmDefaultsRepository.findByUserId(userId) + .orElse(new BdsmDefaultsEntity()); + Map result = new java.util.LinkedHashMap<>(); + result.put("geschlecht", currentUser.getGeschlecht() != null ? currentUser.getGeschlecht().name() : null); + result.put("spieltMit", splitOrEmpty(d.getSpieltMit())); + result.put("rollen", splitOrEmpty(d.getRollen())); + result.put("werkzeuge", splitOrEmpty(d.getWerkzeuge())); + return ResponseEntity.ok(result); + } + + @GetMapping("/{userId}/bdsm-defaults") + public ResponseEntity> getBdsmDefaultsForUser(@PathVariable UUID userId) { + var userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) return ResponseEntity.notFound().build(); + UserEntity user = userOpt.get(); + BdsmDefaultsEntity d = bdsmDefaultsRepository.findByUserId(userId) + .orElse(new BdsmDefaultsEntity()); + Map result = new java.util.LinkedHashMap<>(); + result.put("geschlecht", user.getGeschlecht() != null ? user.getGeschlecht().name() : null); + result.put("spieltMit", splitOrEmpty(d.getSpieltMit())); + result.put("rollen", splitOrEmpty(d.getRollen())); + result.put("werkzeuge", splitOrEmpty(d.getWerkzeuge())); + return ResponseEntity.ok(result); + } + + @PutMapping("/me/bdsm-defaults") + public ResponseEntity updateBdsmDefaults(@RequestBody BdsmDefaultsRequest request, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + BdsmDefaultsEntity d = bdsmDefaultsRepository.findByUserId(userId) + .orElseGet(() -> { BdsmDefaultsEntity n = new BdsmDefaultsEntity(); n.setUserId(userId); return n; }); + d.setSpieltMit(request.spieltMit() == null ? "" : String.join(",", request.spieltMit())); + d.setRollen(request.rollen() == null ? "" : String.join(",", request.rollen())); + d.setWerkzeuge(request.werkzeuge() == null ? "" : String.join(",", request.werkzeuge())); + bdsmDefaultsRepository.save(d); + return ResponseEntity.ok().build(); + } + + private static List splitOrEmpty(String s) { + if (s == null || s.isBlank()) return List.of(); + return List.of(s.split(",")); + } + + @PutMapping("/me/geburtsdatum") + public ResponseEntity updateGeburtsdatum(@RequestBody GeburtsdatumChangeRequest request, Principal principal) { + if (request.geburtsdatum() == null + || Period.between(request.geburtsdatum(), LocalDate.now()).getYears() < 18) { + return ResponseEntity.status(422).build(); + } + var user = userService.requireUser(principal); + user.setGeburtsdatum(request.geburtsdatum()); + userRepository.save(user); + LOGGER.info("User {} hat Geburtsdatum aktualisiert", user.getUserId()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/me/name") + public ResponseEntity updateName(@RequestBody NameChangeRequest request, Principal principal) { + String newName = request.name(); + if (userRepository.findByName(newName).isPresent() + || registrationRepository.findByName(newName).isPresent()) { + return ResponseEntity.status(409).build(); + } + var user = userService.requireUser(principal); + user.setName(newName); + userRepository.save(user); + LOGGER.info("User {} hat Namen zu '{}' geändert", user.getUserId(), newName); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/me") + public ResponseEntity deleteAccount(Principal principal) { + var currentUser = userService.requireUser(principal); + UUID userId = currentUser.getUserId(); + String email = currentUser.getEmail(); + + userService.deleteAccount(userId, email); + + ResponseCookie cookie = ResponseCookie.from("jwt", "") + .httpOnly(true) + .sameSite("Strict") + .path("/") + .maxAge(0) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .build(); + } + + // ── TTLock-Account ──────────────────────────────────────────────────────── + + @GetMapping("/me/ttlock") + public ResponseEntity getTtlockUserConfig(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + TTLockUserConfigEntity cfg = ttLockUserConfigRepository.findById(userId) + .orElse(new TTLockUserConfigEntity()); + return ResponseEntity.ok(new TtlockUserConfigDto( + cfg.getUsername(), + cfg.getPasswordMd5() != null && !cfg.getPasswordMd5().isBlank(), + cfg.getLockId(), + cfg.isTestSuccessful() + )); + } + + @PutMapping("/me/ttlock") + public ResponseEntity saveTtlockUserConfig(@RequestBody TtlockUserConfigRequest body, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + TTLockUserConfigEntity cfg = ttLockUserConfigRepository.findById(userId) + .orElseGet(() -> { TTLockUserConfigEntity n = new TTLockUserConfigEntity(); n.setUserId(userId); return n; }); + boolean credentialsChanged = !java.util.Objects.equals(cfg.getUsername(), body.username()) + || !java.util.Objects.equals(cfg.getLockId(), body.lockId()) + || (body.password() != null && !body.password().isBlank()); + if (credentialsChanged) { + cfg.setTestSuccessful(false); + } + cfg.setUsername(body.username()); + if (body.password() != null && !body.password().isBlank()) { + cfg.setPasswordMd5(DigestUtils.md5DigestAsHex(body.password().getBytes(StandardCharsets.UTF_8))); + } + cfg.setLockId(body.lockId()); + ttLockUserConfigRepository.save(cfg); + return ResponseEntity.ok().build(); + } + + @GetMapping("/me/ttlock/test") + public ResponseEntity> testTtlockConnection(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + var userCfg = ttLockUserConfigRepository.findById(userId).orElse(null); + if (userCfg == null || userCfg.getUsername() == null || userCfg.getPasswordMd5() == null || userCfg.getLockId() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "ttlock_not_configured")); + } + var adminCfg = ttLockConfigRepository.findById(1L).orElse(null); + if (adminCfg == null || adminCfg.getClientId() == null || adminCfg.getClientSecret() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "admin_config_missing")); + } + + String token = ttAuthService.getAccessToken( + adminCfg.getClientId(), adminCfg.getClientSecret(), + userCfg.getUsername(), userCfg.getPasswordMd5()); + if (token == null) { + return ResponseEntity.status(502).body(Map.of("error", "auth_failed")); + } + + TTLockService.TTLockDetailResponse detail = ttLockService.getLockDetail( + adminCfg.getClientId(), token, userCfg.getLockId()); + if (detail == null || detail.getErrcode() != 0) { + String msg = detail != null ? detail.getErrmsg() : "Keine Antwort"; + return ResponseEntity.status(502).body(Map.of("error", "lock_detail_failed", "message", msg)); + } + + userCfg.setTestSuccessful(true); + ttLockUserConfigRepository.save(userCfg); + + Map result = new LinkedHashMap<>(); + result.put("lockId", detail.getLockId()); + result.put("lockName", detail.getLockName()); + result.put("lockAlias", detail.getLockAlias()); + result.put("modelNum", detail.getModelNum()); + result.put("electricQuantity", detail.getElectricQuantity()); + result.put("state", detail.getState()); + return ResponseEntity.ok(result); + } + + @PostMapping("/me/ttlock/open") + public ResponseEntity> ttlockOpen(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + var userCfg = ttLockUserConfigRepository.findById(userId).orElse(null); + if (userCfg == null || userCfg.getUsername() == null || userCfg.getPasswordMd5() == null || userCfg.getLockId() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "ttlock_not_configured")); + } + var adminCfg = ttLockConfigRepository.findById(1L).orElse(null); + if (adminCfg == null || adminCfg.getClientId() == null) { + return ResponseEntity.badRequest().body(Map.of("error", "admin_config_missing")); + } + + var activeLock = baseLockRepository.findByLockee(userId); + if (activeLock.isPresent() && activeLock.get().getUnlockTime() == null) { + return ResponseEntity.status(409).body(Map.of("error", "active_lock_exists")); + } + + String token = ttAuthService.getAccessToken( + adminCfg.getClientId(), adminCfg.getClientSecret(), + userCfg.getUsername(), userCfg.getPasswordMd5()); + if (token == null) { + return ResponseEntity.status(502).body(Map.of("error", "auth_failed")); + } + + String pin = CodeCreator.createNumeric(6); + Integer pwdId = ttLockService.addCustomPasscode(adminCfg.getClientId(), token, userCfg.getLockId(), pin); + if (pwdId == null) { + return ResponseEntity.status(502).body(Map.of("error", "passcode_failed")); + } + + return ResponseEntity.ok(Map.of("pin", pin, "keyboardPwdId", pwdId)); + } + + @DeleteMapping("/me/ttlock/open/{keyboardPwdId}") + public ResponseEntity ttlockCloseOpen(@PathVariable int keyboardPwdId, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + + var userCfg = ttLockUserConfigRepository.findById(userId).orElse(null); + if (userCfg == null || userCfg.getLockId() == null) return ResponseEntity.badRequest().build(); + var adminCfg = ttLockConfigRepository.findById(1L).orElse(null); + if (adminCfg == null) return ResponseEntity.badRequest().build(); + + String token = ttAuthService.getAccessToken( + adminCfg.getClientId(), adminCfg.getClientSecret(), + userCfg.getUsername(), userCfg.getPasswordMd5()); + if (token == null) return ResponseEntity.status(502).build(); + + ttLockService.deleteCustomPasscode(adminCfg.getClientId(), token, userCfg.getLockId(), keyboardPwdId); + return ResponseEntity.ok().build(); + } + + @PostMapping + public ResponseEntity userAnlegen(@RequestBody Registration registration) { + try { + userService.createUser(registration); + return ResponseEntity.status(201).build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(409).build(); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/de/oaa/xxx/user/UserEntity.java b/src/main/java/de/oaa/xxx/user/UserEntity.java new file mode 100644 index 0000000..bb24437 --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/UserEntity.java @@ -0,0 +1,126 @@ +package de.oaa.xxx.user; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.Period; +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "user") +public class UserEntity { + + @Id + @Column + private UUID userId; + @Column + private String name; + @Column(unique = true) + private String email; + @Column + private String password; + + @Column(columnDefinition = "TEXT") + private String profilePicture; + + @Column(columnDefinition = "MEDIUMTEXT") + private String profilePictureHq; + + @Column + private LocalDate geburtsdatum; + + @Column + private Integer groesse; + + @Column + private Integer gewicht; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private Geschlecht geschlecht; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private Neigung neigung; + + @Enumerated(EnumType.STRING) + @Column(length = 30) + private Beziehungsstatus beziehungsstatus; + + @Column(columnDefinition = "TEXT") + private String beschreibung; + + @Column(nullable = false, columnDefinition = "INT DEFAULT 0") + private int lockeeXp; + + @Column(nullable = false, columnDefinition = "INT DEFAULT 0") + private int keyholderXp; + + @Column(nullable = false, columnDefinition = "INT DEFAULT 0") + private int bdsmXp; + + // ── Datenschutz / Sichtbarkeit ── + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitGrunddaten = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitGalerie = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitFreunde = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitFeed = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitPinnwand = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitXp = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitLockhistorie = Sichtbarkeit.ALLE; + + @Enumerated(EnumType.STRING) + @Column(length = 20, nullable = false, columnDefinition = "VARCHAR(20) DEFAULT 'ALLE'") + private Sichtbarkeit sichtbarkeitVorlieben = Sichtbarkeit.ALLE; + + @Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private boolean profilBeiVeroeffentlichungenSichtbar = false; + + public Integer getAlter() { + return geburtsdatum != null ? Period.between(geburtsdatum, LocalDate.now()).getYears() : null; + } + + @Override + public String toString() { + return "UserEntity[userId=" + userId + ", name=" + name + ", email=" + email + "]"; + } + + public User toUser() { + User user = new User(); + user.setEmail(email); + user.setName(name); + user.setUserId(userId); + user.setProfilePicture(profilePicture); + user.setGeburtsdatum(geburtsdatum); + user.setGroesse(groesse); + user.setGewicht(gewicht); + user.setGeschlecht(geschlecht); + user.setNeigung(neigung); + user.setBeziehungsstatus(beziehungsstatus); + user.setBeschreibung(beschreibung); + return user; + } +} diff --git a/src/main/java/de/oaa/xxx/user/UserRepository.java b/src/main/java/de/oaa/xxx/user/UserRepository.java new file mode 100644 index 0000000..cd27e2d --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/UserRepository.java @@ -0,0 +1,14 @@ +package de.oaa.xxx.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + Optional findByName(String name); + List findByNameContainingIgnoreCase(String name); +} diff --git a/src/main/java/de/oaa/xxx/user/UserService.java b/src/main/java/de/oaa/xxx/user/UserService.java new file mode 100644 index 0000000..a8a3af6 --- /dev/null +++ b/src/main/java/de/oaa/xxx/user/UserService.java @@ -0,0 +1,222 @@ +package de.oaa.xxx.user; + +import de.oaa.xxx.emailchange.EmailChangeRepository; +import de.oaa.xxx.games.bdsm.entity.AktiveSperreEntity; +import de.oaa.xxx.games.bdsm.entity.MitspielerEntity; +import de.oaa.xxx.games.bdsm.repository.AktiveSperreRepository; +import de.oaa.xxx.games.common.repository.AufgabeRepository; +import de.oaa.xxx.games.common.repository.AufgabenGruppeRepository; +import de.oaa.xxx.games.bdsm.repository.BdsmGameRepository; +import de.oaa.xxx.games.common.repository.FavoritRepository; +import de.oaa.xxx.games.common.repository.GruppenAboRepository; +import de.oaa.xxx.games.bdsm.repository.MitspielerRepository; +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.passwordreset.PasswordResetRepository; +import de.oaa.xxx.registration.Registration; +import de.oaa.xxx.social.entity.MessageCause; +import de.oaa.xxx.social.entity.NotificationPreferenceEntity; +import de.oaa.xxx.social.repository.KommentarLikeRepository; +import de.oaa.xxx.social.repository.KommentarRepository; +import de.oaa.xxx.social.repository.NotificationPreferenceRepository; +import de.oaa.xxx.social.repository.PinnwandEintragRepository; +import de.oaa.xxx.social.repository.PinnwandLikeRepository; +import de.oaa.xxx.social.repository.ProfileImageLikeRepository; +import de.oaa.xxx.social.repository.ProfileImageRepository; +import jakarta.transaction.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.security.Principal; +import java.util.List; +import java.util.UUID; + +@Service +public class UserService { + + private static final Logger LOGGER = LoggerFactory.getLogger(UserService.class); + + private final UserRepository userRepository; + private final AufgabenGruppeRepository aufgabenGruppeRepository; + private final AufgabeRepository aufgabeRepository; + private final StrafeRepository strafeRepository; + private final SperreRepository sperreRepository; + private final ToyRepository toyRepository; + private final FavoritRepository favoritRepository; + private final GruppenAboRepository gruppenAboRepository; + private final BdsmGameRepository sessionRepository; + private final AktiveSperreRepository aktiveSperreRepository; + private final MitspielerRepository mitspielerRepository; + private final EmailChangeRepository emailChangeRepository; + private final PasswordResetRepository passwordResetRepository; + private final ProfileImageRepository profileImageRepository; + private final ProfileImageLikeRepository profileImageLikeRepository; + private final PinnwandEintragRepository pinnwandEintragRepository; + private final PinnwandLikeRepository pinnwandLikeRepository; + private final KommentarRepository kommentarRepository; + private final KommentarLikeRepository kommentarLikeRepository; + private final NotificationPreferenceRepository notificationPreferenceRepository; + + public UserService(UserRepository userRepository, + AufgabenGruppeRepository aufgabenGruppeRepository, + AufgabeRepository aufgabeRepository, + StrafeRepository strafeRepository, + SperreRepository sperreRepository, + ToyRepository toyRepository, + FavoritRepository favoritRepository, + GruppenAboRepository gruppenAboRepository, + BdsmGameRepository sessionRepository, + AktiveSperreRepository aktiveSperreRepository, + MitspielerRepository mitspielerRepository, + EmailChangeRepository emailChangeRepository, + PasswordResetRepository passwordResetRepository, + ProfileImageRepository profileImageRepository, + ProfileImageLikeRepository profileImageLikeRepository, + PinnwandEintragRepository pinnwandEintragRepository, + PinnwandLikeRepository pinnwandLikeRepository, + KommentarRepository kommentarRepository, + KommentarLikeRepository kommentarLikeRepository, + NotificationPreferenceRepository notificationPreferenceRepository) { + this.userRepository = userRepository; + this.aufgabenGruppeRepository = aufgabenGruppeRepository; + this.aufgabeRepository = aufgabeRepository; + this.strafeRepository = strafeRepository; + this.sperreRepository = sperreRepository; + this.toyRepository = toyRepository; + this.favoritRepository = favoritRepository; + this.gruppenAboRepository = gruppenAboRepository; + this.sessionRepository = sessionRepository; + this.aktiveSperreRepository = aktiveSperreRepository; + this.mitspielerRepository = mitspielerRepository; + this.emailChangeRepository = emailChangeRepository; + this.passwordResetRepository = passwordResetRepository; + this.profileImageRepository = profileImageRepository; + this.profileImageLikeRepository = profileImageLikeRepository; + this.pinnwandEintragRepository = pinnwandEintragRepository; + this.pinnwandLikeRepository = pinnwandLikeRepository; + this.kommentarRepository = kommentarRepository; + this.kommentarLikeRepository = kommentarLikeRepository; + this.notificationPreferenceRepository = notificationPreferenceRepository; + } + + /** + * Liefert den UserEntity zum eingeloggten Principal. + * Wirft 401 wenn der User nicht gefunden wird. + */ + public UserEntity requireUser(Principal principal) { + return userRepository.findByEmail(principal.getName()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + } + + /** + * Löscht einen User-Account vollständig inklusive aller abhängigen Daten. + * Gibt die gelöschte E-Mail zurück (wird für Cookie-Clearing im Controller benötigt). + */ + @Transactional + public void deleteAccount(UUID userId, String email) { + var user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User nicht gefunden: " + userId)); + + LOGGER.info("Lösche Konto für User {}", email); + + // 1. AufgabenGruppen und deren Inhalte löschen + var gruppen = aufgabenGruppeRepository.findByUserId(userId); + if (!gruppen.isEmpty()) { + aufgabeRepository.deleteAll(aufgabeRepository.findByAufgabenGruppeIn(gruppen)); + strafeRepository.deleteAll(strafeRepository.findByAufgabenGruppeIn(gruppen)); + sperreRepository.deleteAll(sperreRepository.findByAufgabenGruppeIn(gruppen)); + for (var gruppe : gruppen) { + gruppenAboRepository.deleteByAufgabenGruppe(gruppe); + favoritRepository.deleteByAufgabenGruppeId(gruppe.getGruppenId()); + } + aufgabenGruppeRepository.deleteAll(gruppen); + } + + // 2. Toys löschen + toyRepository.deleteAll(toyRepository.findByUserId(userId)); + + // 3. Eigene Favoriten und Gruppenabos löschen + favoritRepository.deleteAll(favoritRepository.findByUserId(userId)); + gruppenAboRepository.deleteAll(gruppenAboRepository.findByUserId(userId)); + + // 4. BDSM-Session mit Mitspieler und AktiveSperre löschen + var sessionOpt = sessionRepository.findByUserId(userId); + if (sessionOpt.isPresent()) { + var session = sessionOpt.get(); + List sperren = session.getAktiveSperren(); + List mitspieler = session.getMitspieler(); + aktiveSperreRepository.deleteAll(sperren); + mitspielerRepository.deleteAll(mitspieler); + sessionRepository.delete(session); + } + + // 5. Pending Tokens löschen + emailChangeRepository.findByUserEmail(email).ifPresent(emailChangeRepository::delete); + passwordResetRepository.findByEmail(email).ifPresent(passwordResetRepository::delete); + + // 5b. Profilbilder und Likes löschen + var profileImages = profileImageRepository.findByUserIdOrderByUploadedAtDesc(userId); + for (var img : profileImages) { + profileImageLikeRepository.deleteByImageId(img.getImageId()); + kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("IMAGE", img.getImageId()) + .forEach(k -> { + kommentarLikeRepository.deleteByKommentarId(k.getKommentarId()); + kommentarRepository.delete(k); + }); + } + profileImageRepository.deleteAll(profileImages); + profileImageLikeRepository.deleteByUserId(userId); + + // 5c. Pinnwand-Einträge und Likes/Kommentare löschen + var ownWallEntries = pinnwandEintragRepository.findByProfilUserIdOrderByCreatedAtDesc(userId); + for (var e : ownWallEntries) { + pinnwandLikeRepository.deleteByEintragId(e.getEintragId()); + kommentarRepository.findByTargetTypeAndTargetIdOrderByCreatedAtAsc("PINNWAND", e.getEintragId()) + .forEach(k -> { + kommentarLikeRepository.deleteByKommentarId(k.getKommentarId()); + kommentarRepository.delete(k); + }); + } + pinnwandEintragRepository.deleteAll(ownWallEntries); + pinnwandEintragRepository.deleteByAuthorId(userId); + pinnwandLikeRepository.deleteByUserId(userId); + kommentarRepository.deleteByAuthorId(userId); + kommentarLikeRepository.deleteByUserId(userId); + + // 6. User löschen + userRepository.delete(user); + } + + /** + * Legt einen neuen User aus einer bestätigten Registration an + * und erstellt die Standard-Benachrichtigungseinstellungen. + */ + public void createUser(Registration registration) { + if (registration.getEmail() == null || registration.getPassword() == null || registration.getName() == null) { + throw new IllegalArgumentException("E-Mail, Passwort und Name sind Pflichtfelder"); + } + if (userRepository.findByEmail(registration.getEmail()).isPresent()) { + LOGGER.warn("User mit E-Mail {} bereits vorhanden", registration.getEmail()); + throw new IllegalStateException("E-Mail bereits vorhanden"); + } + + UserEntity entity = new UserEntity(); + entity.setUserId(UUID.randomUUID()); + entity.setEmail(registration.getEmail()); + entity.setName(registration.getName()); + entity.setPassword(registration.getPassword()); + entity.setGeburtsdatum(registration.getGeburtsdatum()); + userRepository.save(entity); + + for (MessageCause cause : MessageCause.values()) { + notificationPreferenceRepository.save( + NotificationPreferenceEntity.defaultFor(entity.getUserId(), cause)); + } + + LOGGER.info("User {} angelegt", entity.getUserId()); + } +} diff --git a/src/main/java/de/oaa/xxx/util/ValidationResult.java b/src/main/java/de/oaa/xxx/util/ValidationResult.java new file mode 100644 index 0000000..5a65bb3 --- /dev/null +++ b/src/main/java/de/oaa/xxx/util/ValidationResult.java @@ -0,0 +1,5 @@ +package de.oaa.xxx.util; + +public enum ValidationResult { + OK, INFO, WARNING, ERROR; +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeEntity.java b/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeEntity.java new file mode 100644 index 0000000..1c57e16 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeEntity.java @@ -0,0 +1,29 @@ +package de.oaa.xxx.vorlieben; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "user_vorliebe", + uniqueConstraints = @UniqueConstraint(columnNames = {"userId", "itemId"})) +public class UserVorliebeEntity { + + @Id + @Column + private UUID id; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private UUID itemId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private VorliebeBewertung bewertung; +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeRepository.java b/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeRepository.java new file mode 100644 index 0000000..a6bd80d --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/UserVorliebeRepository.java @@ -0,0 +1,12 @@ +package de.oaa.xxx.vorlieben; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserVorliebeRepository extends JpaRepository { + List findByUserId(UUID userId); + Optional findByUserIdAndItemId(UUID userId, UUID itemId); +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebeBewertung.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebeBewertung.java new file mode 100644 index 0000000..24a29de --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebeBewertung.java @@ -0,0 +1,16 @@ +package de.oaa.xxx.vorlieben; + +public enum VorliebeBewertung { + GEHT_GAR_NICHT("Geht gar nicht"), + EHER_NICHT("Eher nicht"), + NEUTRAL("Neutral"), + MAG_ICH("Mag ich"), + UNBEDINGT("Unbedingt"), + WILL_AUSPROBIEREN("Will ich ausprobieren"); + + private final String label; + + VorliebeBewertung(String label) { this.label = label; } + + public String getLabel() { return label; } +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemEntity.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemEntity.java new file mode 100644 index 0000000..73cc849 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemEntity.java @@ -0,0 +1,27 @@ +package de.oaa.xxx.vorlieben; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "vorliebe_item") +public class VorliebeItemEntity { + + @Id + @Column + private UUID itemId; + + @Column(nullable = false) + private UUID kategorieId; + + @Column(nullable = false, length = 200) + private String name; + + @Column(nullable = false, columnDefinition = "INT DEFAULT 0") + private int sortOrder; +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemRepository.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemRepository.java new file mode 100644 index 0000000..06886d7 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebeItemRepository.java @@ -0,0 +1,11 @@ +package de.oaa.xxx.vorlieben; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface VorliebeItemRepository extends JpaRepository { + List findAllByOrderBySortOrderAscNameAsc(); + boolean existsByKategorieId(UUID kategorieId); +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.java new file mode 100644 index 0000000..fecb547 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieEntity.java @@ -0,0 +1,24 @@ +package de.oaa.xxx.vorlieben; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@Entity +@Table(name = "vorliebe_kategorie") +public class VorliebeKategorieEntity { + + @Id + @Column + private UUID kategorieId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false, columnDefinition = "INT DEFAULT 0") + private int sortOrder; +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.java new file mode 100644 index 0000000..21de099 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebeKategorieRepository.java @@ -0,0 +1,10 @@ +package de.oaa.xxx.vorlieben; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface VorliebeKategorieRepository extends JpaRepository { + List findAllByOrderBySortOrderAscNameAsc(); +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebenAdminController.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebenAdminController.java new file mode 100644 index 0000000..324c489 --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebenAdminController.java @@ -0,0 +1,239 @@ +package de.oaa.xxx.vorlieben; + +import de.oaa.xxx.admin.AdminEntity; +import de.oaa.xxx.admin.AdminRepository; +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.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/admin/vorlieben") +@Transactional +public class VorliebenAdminController { + + private final VorliebeKategorieRepository kategorieRepository; + private final VorliebeItemRepository itemRepository; + private final UserVorliebeRepository userVorliebeRepository; + private final AdminRepository adminRepository; + private final UserService userService; + + public VorliebenAdminController(VorliebeKategorieRepository kategorieRepository, + VorliebeItemRepository itemRepository, + UserVorliebeRepository userVorliebeRepository, + AdminRepository adminRepository, + UserService userService) { + this.kategorieRepository = kategorieRepository; + this.itemRepository = itemRepository; + this.userVorliebeRepository = userVorliebeRepository; + this.adminRepository = adminRepository; + this.userService = userService; + } + + 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")); + } + + record KategorieRequest(String name, int sortOrder) {} + record ItemRequest(UUID kategorieId, String name, int sortOrder) {} + record KategorieDto(UUID kategorieId, String name, int sortOrder) {} + record ItemDto(UUID itemId, UUID kategorieId, String name, int sortOrder) {} + + // ── Kategorien ──────────────────────────────────────────────────────────── + + @GetMapping("/kategorien") + public ResponseEntity> getKategorien(Principal principal) { + requireAdmin(principal); + List result = kategorieRepository.findAllByOrderBySortOrderAscNameAsc().stream() + .map(k -> new KategorieDto(k.getKategorieId(), k.getName(), k.getSortOrder())) + .toList(); + return ResponseEntity.ok(result); + } + + @PostMapping("/kategorien") + public ResponseEntity createKategorie(@RequestBody KategorieRequest req, Principal principal) { + requireAdmin(principal); + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + VorliebeKategorieEntity entity = new VorliebeKategorieEntity(); + entity.setKategorieId(UUID.randomUUID()); + entity.setName(req.name().trim()); + entity.setSortOrder(req.sortOrder()); + kategorieRepository.save(entity); + return ResponseEntity.status(201).body( + new KategorieDto(entity.getKategorieId(), entity.getName(), entity.getSortOrder())); + } + + @PutMapping("/kategorien/{id}") + public ResponseEntity updateKategorie(@PathVariable UUID id, + @RequestBody KategorieRequest req, + Principal principal) { + requireAdmin(principal); + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + VorliebeKategorieEntity entity = kategorieRepository.findById(id).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + entity.setName(req.name().trim()); + entity.setSortOrder(req.sortOrder()); + kategorieRepository.save(entity); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/kategorien/{id}") + public ResponseEntity deleteKategorie(@PathVariable UUID id, Principal principal) { + requireAdmin(principal); + if (!kategorieRepository.existsById(id)) return ResponseEntity.notFound().build(); + if (itemRepository.existsByKategorieId(id)) { + return ResponseEntity.status(409) + .header("X-Error", "has-items") + .build(); + } + kategorieRepository.deleteById(id); + return ResponseEntity.ok().build(); + } + + // ── Items ───────────────────────────────────────────────────────────────── + + @GetMapping("/items") + public ResponseEntity> getItems(Principal principal) { + requireAdmin(principal); + List result = itemRepository.findAllByOrderBySortOrderAscNameAsc().stream() + .map(i -> new ItemDto(i.getItemId(), i.getKategorieId(), i.getName(), i.getSortOrder())) + .toList(); + return ResponseEntity.ok(result); + } + + @PostMapping("/items") + public ResponseEntity createItem(@RequestBody ItemRequest req, Principal principal) { + requireAdmin(principal); + if (req.name() == null || req.name().isBlank() || req.kategorieId() == null) + return ResponseEntity.badRequest().build(); + if (!kategorieRepository.existsById(req.kategorieId())) + return ResponseEntity.status(422).header("X-Error", "kategorie-not-found").build(); + VorliebeItemEntity entity = new VorliebeItemEntity(); + entity.setItemId(UUID.randomUUID()); + entity.setKategorieId(req.kategorieId()); + entity.setName(req.name().trim()); + entity.setSortOrder(req.sortOrder()); + itemRepository.save(entity); + return ResponseEntity.status(201).body( + new ItemDto(entity.getItemId(), entity.getKategorieId(), entity.getName(), entity.getSortOrder())); + } + + @PutMapping("/items/{id}") + public ResponseEntity updateItem(@PathVariable UUID id, + @RequestBody ItemRequest req, + Principal principal) { + requireAdmin(principal); + if (req.name() == null || req.name().isBlank()) return ResponseEntity.badRequest().build(); + VorliebeItemEntity entity = itemRepository.findById(id).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + if (req.kategorieId() != null && !kategorieRepository.existsById(req.kategorieId())) + return ResponseEntity.status(422).header("X-Error", "kategorie-not-found").build(); + if (req.kategorieId() != null) entity.setKategorieId(req.kategorieId()); + entity.setName(req.name().trim()); + entity.setSortOrder(req.sortOrder()); + itemRepository.save(entity); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/items/{id}") + public ResponseEntity deleteItem(@PathVariable UUID id, Principal principal) { + requireAdmin(principal); + VorliebeItemEntity entity = itemRepository.findById(id).orElse(null); + if (entity == null) return ResponseEntity.notFound().build(); + // Remove all user ratings for this item first + List ratings = userVorliebeRepository.findAll().stream() + .filter(uv -> uv.getItemId().equals(id)).toList(); + userVorliebeRepository.deleteAll(ratings); + itemRepository.delete(entity); + return ResponseEntity.ok().build(); + } + + // ── Export ──────────────────────────────────────────────────────────────── + + record ExportKategorie(UUID kategorieId, String name, int sortOrder, List items) {} + record ExportItem(UUID itemId, String name, int sortOrder) {} + + @GetMapping("/export") + public ResponseEntity> export(Principal principal) { + requireAdmin(principal); + List kategorien = kategorieRepository.findAllByOrderBySortOrderAscNameAsc(); + List allItems = itemRepository.findAllByOrderBySortOrderAscNameAsc(); + Map> byKat = allItems.stream() + .collect(Collectors.groupingBy(VorliebeItemEntity::getKategorieId)); + + List result = kategorien.stream() + .map(k -> new ExportKategorie( + k.getKategorieId(), k.getName(), k.getSortOrder(), + byKat.getOrDefault(k.getKategorieId(), List.of()).stream() + .map(i -> new ExportItem(i.getItemId(), i.getName(), i.getSortOrder())) + .toList())) + .toList(); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"vorlieben-export.json\"") + .body(result); + } + + // ── Import ──────────────────────────────────────────────────────────────── + + record ImportItem(String name, int sortOrder) {} + record ImportKategorie(String name, int sortOrder, List items) {} + record ImportResult(int kategorienCreated, int kategorienSkipped, int itemsCreated, int itemsSkipped) {} + + @PostMapping("/import") + public ResponseEntity importData(@RequestBody List data, + Principal principal) { + requireAdmin(principal); + if (data == null) return ResponseEntity.badRequest().build(); + + int kCreated = 0, kSkipped = 0, iCreated = 0, iSkipped = 0; + List existingKategorien = kategorieRepository.findAllByOrderBySortOrderAscNameAsc(); + Map byName = existingKategorien.stream() + .collect(Collectors.toMap(k -> k.getName().toLowerCase(), k -> k, + (a, b) -> a)); + + for (ImportKategorie ik : data) { + if (ik.name() == null || ik.name().isBlank()) { kSkipped++; continue; } + VorliebeKategorieEntity kat = byName.get(ik.name().trim().toLowerCase()); + if (kat == null) { + kat = new VorliebeKategorieEntity(); + kat.setKategorieId(UUID.randomUUID()); + kat.setName(ik.name().trim()); + kat.setSortOrder(ik.sortOrder()); + kategorieRepository.save(kat); + byName.put(kat.getName().toLowerCase(), kat); + kCreated++; + } else { + kSkipped++; + } + + if (ik.items() == null) continue; + final UUID katId = kat.getKategorieId(); + List existingItems = + itemRepository.findAllByOrderBySortOrderAscNameAsc().stream() + .filter(i -> i.getKategorieId().equals(katId)) + .toList(); + Set existingNames = existingItems.stream() + .map(i -> i.getName().toLowerCase()).collect(Collectors.toSet()); + + for (ImportItem ii : ik.items()) { + if (ii.name() == null || ii.name().isBlank()) { iSkipped++; continue; } + if (existingNames.contains(ii.name().trim().toLowerCase())) { iSkipped++; continue; } + VorliebeItemEntity item = new VorliebeItemEntity(); + item.setItemId(UUID.randomUUID()); + item.setKategorieId(katId); + item.setName(ii.name().trim()); + item.setSortOrder(ii.sortOrder()); + itemRepository.save(item); + iCreated++; + } + } + return ResponseEntity.ok(new ImportResult(kCreated, kSkipped, iCreated, iSkipped)); + } +} diff --git a/src/main/java/de/oaa/xxx/vorlieben/VorliebenController.java b/src/main/java/de/oaa/xxx/vorlieben/VorliebenController.java new file mode 100644 index 0000000..cb1b2ff --- /dev/null +++ b/src/main/java/de/oaa/xxx/vorlieben/VorliebenController.java @@ -0,0 +1,148 @@ +package de.oaa.xxx.vorlieben; + +import de.oaa.xxx.social.entity.FriendshipEntity; +import de.oaa.xxx.social.repository.FriendshipRepository; +import de.oaa.xxx.user.Sichtbarkeit; +import de.oaa.xxx.user.UserEntity; +import de.oaa.xxx.user.UserRepository; +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.util.*; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/vorlieben") +@Transactional +public class VorliebenController { + + private final VorliebeKategorieRepository kategorieRepository; + private final VorliebeItemRepository itemRepository; + private final UserVorliebeRepository userVorliebeRepository; + private final UserService userService; + private final UserRepository userRepository; + private final FriendshipRepository friendshipRepository; + + public VorliebenController(VorliebeKategorieRepository kategorieRepository, + VorliebeItemRepository itemRepository, + UserVorliebeRepository userVorliebeRepository, + UserService userService, + UserRepository userRepository, + FriendshipRepository friendshipRepository) { + this.kategorieRepository = kategorieRepository; + this.itemRepository = itemRepository; + this.userVorliebeRepository = userVorliebeRepository; + this.userService = userService; + this.userRepository = userRepository; + this.friendshipRepository = friendshipRepository; + } + + record ItemDto(UUID itemId, String name, int sortOrder) {} + record KategorieWithItems(UUID kategorieId, String name, int sortOrder, List items) {} + + /** Returns all categories with their items – used by the profile edit page. */ + @GetMapping("/items") + public ResponseEntity> getItems() { + List kategorien = kategorieRepository.findAllByOrderBySortOrderAscNameAsc(); + List allItems = itemRepository.findAllByOrderBySortOrderAscNameAsc(); + + Map> byKategorie = allItems.stream() + .collect(Collectors.groupingBy(VorliebeItemEntity::getKategorieId)); + + List result = kategorien.stream() + .map(k -> new KategorieWithItems( + k.getKategorieId(), k.getName(), k.getSortOrder(), + byKategorie.getOrDefault(k.getKategorieId(), List.of()).stream() + .map(i -> new ItemDto(i.getItemId(), i.getName(), i.getSortOrder())) + .toList())) + .filter(k -> !k.items().isEmpty()) + .toList(); + return ResponseEntity.ok(result); + } + + /** Returns the current user's ratings as a map of itemId → bewertung name. */ + @GetMapping("/me") + public ResponseEntity> getMyVorlieben(Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + Map result = userVorliebeRepository.findByUserId(userId).stream() + .collect(Collectors.toMap( + uv -> uv.getItemId().toString(), + uv -> uv.getBewertung().name())); + return ResponseEntity.ok(result); + } + + /** Saves the current user's ratings. Value null or blank removes the rating. */ + @PutMapping("/me") + public ResponseEntity saveMyVorlieben(@RequestBody Map ratings, Principal principal) { + UUID userId = userService.requireUser(principal).getUserId(); + for (var entry : ratings.entrySet()) { + UUID itemId; + try { itemId = UUID.fromString(entry.getKey()); } + catch (IllegalArgumentException e) { continue; } + + String bewertungStr = entry.getValue(); + if (bewertungStr == null || bewertungStr.isBlank()) { + userVorliebeRepository.findByUserIdAndItemId(userId, itemId) + .ifPresent(userVorliebeRepository::delete); + } else { + VorliebeBewertung bewertung; + try { bewertung = VorliebeBewertung.valueOf(bewertungStr); } + catch (IllegalArgumentException e) { continue; } + + UserVorliebeEntity uv = userVorliebeRepository.findByUserIdAndItemId(userId, itemId) + .orElseGet(() -> { + UserVorliebeEntity n = new UserVorliebeEntity(); + n.setId(UUID.randomUUID()); + n.setUserId(userId); + n.setItemId(itemId); + return n; + }); + uv.setBewertung(bewertung); + userVorliebeRepository.save(uv); + } + } + return ResponseEntity.ok().build(); + } + + /** Returns another user's ratings, respecting their privacy setting. */ + @GetMapping("/user/{userId}") + public ResponseEntity> getUserVorlieben( + @PathVariable UUID userId, Principal principal) { + UserEntity targetUser = userRepository.findById(userId).orElse(null); + if (targetUser == null) return ResponseEntity.notFound().build(); + + boolean isOwn = false; + boolean isFriend = false; + if (principal != null) { + UUID myId = userService.requireUser(principal).getUserId(); + isOwn = myId.equals(userId); + if (!isOwn) { + Optional f = friendshipRepository.findExisting(myId, userId); + isFriend = f.isPresent() && f.get().getStatus() == FriendshipEntity.Status.ACCEPTED; + } + } + + Sichtbarkeit sv = targetUser.getSichtbarkeitVorlieben(); + if (sv == null) sv = Sichtbarkeit.ALLE; + + boolean canSee = isOwn + || sv == Sichtbarkeit.ALLE + || (sv == Sichtbarkeit.NUR_FREUNDE && isFriend); + + Map result = new LinkedHashMap<>(); + result.put("sichtbarkeit", sv.name()); + result.put("canSee", canSee); + + if (canSee) { + Map ratingsMap = userVorliebeRepository.findByUserId(userId).stream() + .collect(Collectors.toMap( + uv -> uv.getItemId().toString(), + uv -> uv.getBewertung().name())); + result.put("ratings", ratingsMap); + } + return ResponseEntity.ok(result); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..5432f20 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,64 @@ +# Datasource +spring.datasource.url=jdbc:mysql://localhost:3306/xxx_sphere?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC +spring.datasource.username=${DB_USER:xxx} +spring.datasource.password=${DB_PASSWORD:xxx} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# JPA / Hibernate +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect +spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR + +# Mail +#spring.mail.host=${MAIL_HOST:localhost} +#spring.mail.port=${MAIL_PORT:25} +#spring.mail.username=${MAIL_USER:} +#spring.mail.password=${MAIL_PASSWORD:} +#spring.mail.properties.mail.smtp.auth=false +#spring.mail.properties.mail.smtp.starttls.enable=false + +# Mailpit +spring.mail.host=smtp-relay.brevo.com +spring.mail.port=587 +spring.mail.username=a6b17a001@smtp-brevo.com +spring.mail.password=xsmtpsib-77b691d562154574133d12b09d44a06e166d30091aac6642480771a0ae463a79-8yH3jHOd4nMMAwuS +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +# JWT Keystore +jwt.keystore.path=classpath:xxx.jks +jwt.keystore.password=${JWT_KEYSTORE_PASSWORD:XUR!Rv&f$j3UsqD&} +jwt.keystore.alias=xxx + +# App +app.base-url=http://localhost:8080 + +# Theme – alle Farben hier ändern, Email-Style passt sich automatisch an +app.theme.color-bg=#1a1a2e +app.theme.color-card=#16213e +app.theme.color-primary=#e94560 +app.theme.color-secondary=#0f3460 +app.theme.color-text=#eeeeee +app.theme.color-muted=#888888 +app.theme.color-success=#2ecc71 + +# Logging +logging.level.de.oaa.xxx=DEBUG +# Spring 6.2.3 Bug: NPE in DisconnectedClientHelper bei AsyncRequestTimeoutException (SSE-Reconnect) – harmlos +logging.level.org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver=ERROR +logging.level.org.apache.catalina.core.AsyncContextImpl=ERROR + +# Server +server.port=8080 +server.servlet.context-path=/ +server.shutdown=graceful +spring.lifecycle.timeout-per-shutdown-phase=5s + +# Jackson – Datumsformat als ISO-8601 String statt numerischem Array +spring.jackson.serialization.write-dates-as-timestamps=false + +# Multipart upload +spring.servlet.multipart.max-file-size=20MB +spring.servlet.multipart.max-request-size=20MB diff --git a/src/main/resources/sql/admin.sql b/src/main/resources/sql/admin.sql new file mode 100644 index 0000000..45675b3 --- /dev/null +++ b/src/main/resources/sql/admin.sql @@ -0,0 +1,13 @@ +● -- Person zur admin-Tabelle als SUPERADMIN hinzufügen (über E-Mail-Adresse) + INSERT INTO admin (admin_id, user_id, rolle, created_at) + SELECT UUID(), u.user_id, 'SUPERADMIN', NOW() + FROM user u + WHERE u.email = 'email@beispiel.de'; + + -- Falls der User bereits ein (normaler) Admin ist, Rolle upgraden: + UPDATE admin a + JOIN user u ON a.user_id = u.user_id + SET a.rolle = 'SUPERADMIN' + WHERE u.email = 'email@beispiel.de'; + + --Einfach email@beispiel.de durch die Ziel-E-Mail ersetzen. Das erste Statement fügt einen neuen Admin-Eintrag ein, das zweite upgraded einen bestehenden. Nur eines von beiden ausführen je nach Fall. diff --git a/src/main/resources/sql/testdata_aufgabengruppen.sql b/src/main/resources/sql/testdata_aufgabengruppen.sql new file mode 100644 index 0000000..a87052a --- /dev/null +++ b/src/main/resources/sql/testdata_aufgabengruppen.sql @@ -0,0 +1,588 @@ +-- ============================================================ +-- Testdaten: Aufgabengruppen (generiert aus DefaultFiller) +-- Toys und *Toy-Join-Tabellen werden ignoriert. +-- UUID-Speicherung: varchar(36) als plain UUID-String +-- Spaltennamen: SpringPhysicalNamingStrategy → snake_case +-- ============================================================ + +SET NAMES utf8mb4; + +-- ── Aufgabengruppen ────────────────────────────────────────── +INSERT IGNORE INTO aufgaben_gruppe (gruppen_id, name, beschreibung, user_id, private_gruppe, bild, von) VALUES +('10000000-0000-0000-0000-000000000001', 'Keuschhaltung weiblich', 'Enthält verschiedene Aufgaben für Keuschhaltung von weiblichen Spielpartnern', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000002', 'Keuschhaltung männlich', 'Enthält verschiedene Aufgaben für Keuschhaltung von männlichen Spielpartnern', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000003', 'Plugs', 'Enthält verschiedene Aufgaben für das Tragen von Buttplugs über einen gewissen Zeitraum.', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000004', 'Knebel', 'Enthält verschiedene Aufgaben für das Tragen von Knebeln über einen gewissen Zeitraum.', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000005', 'Strafen', 'Enthält verschiedene Bestrafungen', NULL, 0, NULL, NULL), +('10000000-0000-0000-0000-000000000006', 'Aufgaben', 'Enthält verschiedene Sex-Aufgaben.', NULL, 0, NULL, NULL); + + +-- ── Sperren ────────────────────────────────────────────────── +-- Gruppe: Keuschhaltung weiblich +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000001', 'Voll-KG', + '{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000002', 'Voll-KG + Vaginaldildo', + '{PASSIV} trägt fortan einen Voll-KG mit Vaginaldildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000003', 'Voll-KG + Analdildo', + '{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'), + +('20000000-0000-0000-0000-000000000004', 'Voll-KG + Doubleplugged', + '{PASSIV} trägt fortan einen Voll-KG mit Vaginal- und Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von ihrem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000001'); + +-- Gruppe: Keuschhaltung männlich +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000005', 'Peniskäfig', + '{PASSIV} trägt fortan einen Peniskäfig, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem Peniskäfig zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'), + +('20000000-0000-0000-0000-000000000006', 'Voll-KG', + '{PASSIV} trägt fortan einen Voll-KG, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'), + +('20000000-0000-0000-0000-000000000007', 'Voll-KG + Analdildo', + '{PASSIV} trägt fortan einen Voll-KG mit Analdildo, {AKTIV} ist der Keyholder', + '{AKTIV}, es ist ab der Zeit {PASSIV} von seinem KG zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000002'); + +-- Gruppe: Plugs +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000008', 'Plug klein', + '{AKTIV} führt {PASSIV} einen kleinen Buttplug in anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000009', 'Plug mittel', + '{AKTIV} führt {PASSIV} einen mittelgroßen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000010', 'Plug groß', + '{AKTIV} führt {PASSIV} einen großen Buttplug anal ein, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000011', 'Elektro-Plug anal', + '{AKTIV} führt {PASSIV} einen Elekro-Plug anal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'), + +('20000000-0000-0000-0000-000000000012', 'Elektro-Plug vaginal', + '{AKTIV} führt {PASSIV} einen Elekto-Plug vaginal ein, dieser ist bis auf weiteres zu tragen. {AKTIV} darf {PASSIV} leichte Stromstöße verpassen', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Plug zu befreien', + 10, 30, '10000000-0000-0000-0000-000000000003'); + +-- Gruppe: Knebel +INSERT IGNORE INTO sperre (sperre_id, kurz_text, text, release_text, minuten_von, minuten_bis, gruppe_id) VALUES +('20000000-0000-0000-0000-000000000013', 'Ballknebel', + '{AKTIV}, lege {PASSIV} einen Ballknebel an, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 10, 30, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000014', 'Penisknebel', + '{AKTIV}, lege {PASSIV} einen Dildoknebel an, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 10, 30, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000015', 'Aufblasbarer Knebel', + '{AKTIV}, lege {PASSIV} einen aufblasbaren Knebel an und pumpe diesen soweit auf, dass {PASSIV} noch halbwegs gut atmen kann, dieser ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 5, 15, '10000000-0000-0000-0000-000000000004'), + +('20000000-0000-0000-0000-000000000016', 'Isolationsmaske', + '{AKTIV}, lege {PASSIV} eine Isolationsmaske an, diese ist bis auf weiteres zu tragen.', + '{AKTIV}, es ist Zeit {PASSIV} von seinem Knebel zu befreien.', + 5, 15, '10000000-0000-0000-0000-000000000004'); + +-- sperre_sperre_fuer (war @CollectionTable name="sperre_sperreFuer" → snake_case) +INSERT IGNORE INTO sperre_sperre_fuer (sperre_id, werkzeug) VALUES +('20000000-0000-0000-0000-000000000001', 'VAGINA'), +('20000000-0000-0000-0000-000000000002', 'VAGINA'), +('20000000-0000-0000-0000-000000000003', 'VAGINA'), +('20000000-0000-0000-0000-000000000003', 'ANUS'), +('20000000-0000-0000-0000-000000000004', 'VAGINA'), +('20000000-0000-0000-0000-000000000004', 'ANUS'), +('20000000-0000-0000-0000-000000000005', 'PENIS'), +('20000000-0000-0000-0000-000000000006', 'PENIS'), +('20000000-0000-0000-0000-000000000007', 'PENIS'), +('20000000-0000-0000-0000-000000000007', 'ANUS'), +('20000000-0000-0000-0000-000000000008', 'ANUS'), +('20000000-0000-0000-0000-000000000009', 'ANUS'), +('20000000-0000-0000-0000-000000000010', 'ANUS'), +('20000000-0000-0000-0000-000000000011', 'ANUS'), +('20000000-0000-0000-0000-000000000012', 'VAGINA'), +('20000000-0000-0000-0000-000000000013', 'MUND'), +('20000000-0000-0000-0000-000000000014', 'MUND'), +('20000000-0000-0000-0000-000000000015', 'MUND'), +('20000000-0000-0000-0000-000000000016', 'MUND'); + + +-- ── Strafen ────────────────────────────────────────────────── +INSERT IGNORE INTO strafe (strafe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES +('30000000-0000-0000-0000-000000000001', '5 Schläge mit flachen Hand', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der flachen Hand auf das Gesäß.', + 1, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000002', '15 Schläge mit flachen Hand', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der flachen Hand auf das Gesäß, {PASSIV} zählt laut mit', + 3, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000003', '5 Schläge mit Gerte', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf das Gesäß.', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000004', '15 Schläge mit Gerte', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Gerte auf das Gesäß, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000005', '5 Schläge mit Paddel', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf das Gesäß.', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000006', '15 Schläge mit Paddel', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit dem Paddel auf das Gesäß, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000007', '5 Schläge mit Peitsche', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf das Gesäß.', + 3, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000008', '15 Schläge mit Peitsche', + '{PASSIV} stellt sich mit dem Gesicht zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 15 beherzte Schläge mit der Peitsche auf das Gesäß, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000009', 'Schläge auf Klitoris mit Hand', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Hand auf die Klitoris, {PASSIV} zählt laut mit', + 4, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000010', 'Schläge auf Klitoris mit Peitsche', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Peitsche auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000011', 'Schläge auf Klitoris mit Paddel', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit dem Paddel auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000012', 'Schläge auf Klitoris mit Gerte', + '{PASSIV} liegt auf dem Rücken mit breiten Beinen, {AKTIV} verpasst {PASSIV} 5 Schläge mit der Gerte auf die Klitoris, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000013', '5 Ohrfeigen', + '{PASSIV} stellt sich mit dem Rücken zur Wand, Hände hinterm Kopf, Beine schulterbreit, {AKTIV} verpasst {PASSIV} 5 Ohrfeigen, {PASSIV} zählt laut mit', + 5, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000014', 'Elektroplug anal', + '{AKTIV} führt {PASSIV} anal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000015', 'Elektroplug vaginal', + '{AKTIV} führt {PASSIV} vaginal einen Elektro-Plug ein. {AKTIV} erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000016', 'Pumpplug anal', + '{AKTIV} führt {PASSIV} anal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000017', 'Pumpplug vaginal', + '{AKTIV} führt {PASSIV} vaginal einen Pump-Plug ein. {AKTIV} pumpt ganz langsam auf bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000018', 'Facesitting (Vagina)', + '{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen', + 2, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000019', 'Facesitting gefesselt (Vagina)', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Vaginal und/oder Analbereich verwöhnen', + 4, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000020', 'Facesitting (Penis)', + '{PASSIV} liegt auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen', + 2, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000021', 'Facesitting gefesselt (Penis)', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} setzt sich auf das Gesicht von {PASSIV} und lässt sich den Penis und/oder Analbereich verwöhnen', + 4, 90, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000022', 'Facesitting Doppelpenisknebel', + '{PASSIV} liegt auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000023', 'Facesitting Doppelpenisknebel gefesselt', + '{PASSIV} liegt mit auf den Rücken gefesselten Händen auf dem Rücken, {AKTIV} legt {PASSIV} einen Doppel-Penisknebel an und reitet diesen vaginal oder anal', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000024', 'Nippelklemmen', + '{AKTIV} legt {PASSIV} Nippelklemmen an, {AKTIV} zieht an der Kette und erhöht ganz langsam die Intensität bis {PASSIV} ''STOP'' sagt, dann fängt {AKTIV} wieder bei null an', + 3, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000025', 'Nippelbehandlung', + '{AKTIV} nimmt die Nippel von {PASSIV} zwischen die Finger und erhöht langsam den Druck bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000026', 'Hilflos liegen lassen', + '{AKTIV} fesselt, knebelt und verbindet die Augen von {PASSIV}. {AKTIV} lässt {PASSIV} wehrlos liegen, bei Ablauf der Zeit erlöst {AKTIV} {PASSIV} mit einem beherzten Platsch auf den Po', + 4, 300, 600, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000027', 'Strapon reiten', + '{PASSIV} liegt auf dem Rücken und trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}', + 3, 60, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000028', 'Strapon reiten gefesselt', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo. {AKTIV} reitet den Umschnalldildo von {PASSIV}', + 4, 60, 180, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000029', 'Teaseblowjob mit dem Strapon', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} gibt dem Umschnalldildo einen Blowjob in 69er Position und präsentiert {PASSIV} dabei den Intimbereich', + 5, 180, 300, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000030', 'Teasereiten mit Strapon', + '{AKTIV} fesselt und knebelt {PASSIV}. {PASSIV} trägt dabei einen Umschnalldildo, KG und einen großen Buttplug. {AKTIV} reitet den Umschnalldildo von {PASSIV}.', + 5, 180, 300, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000031', 'Tease mit Selbstbefriedigung (Mann KG)', + '{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber', + 4, 240, 360, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000032', 'Tease mit Selbstbefriedigung (Frau KG)', + '{AKTIV} knebelt und fesselt {PASSIV} an einen Stuhl. {PASSIV} trägt dabei einen KG und einen großen Buttplug. {AKTIV} befriedigt sich dann vor den Augen von {PASSIV} selber', + 4, 240, 360, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000033', 'Blowjob auf allen vieren', + '{AKTIV}, zwinge {PASSIV} vor dir auf die Knie, führe dein Glied (oder Strap on) in den Mund von {PASSIV} ein und zeig mit einem Deepthroat, wer das sagen hat', + 5, 30, 90, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000034', 'Oralsex mit kleinem Dildo in der Vagina', + '{PASSIV}, geh auf die Knie und reite vaginal einen kleinen Dildo, befriedige dabei {AKTIV} oral.', + 2, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000035', 'Oralsex mit großen Dildo in der Vagina', + '{PASSIV}, geh auf die Knie und reite vaginal einen großen Dildo, befriedige dabei {AKTIV} oral.', + 4, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000036', 'Oralsex mit kleinem Dildo im Anus', + '{PASSIV}, geh auf die Knie und reite anal einen kleinen Dildo, befriedige dabei {AKTIV} oral.', + 3, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000037', 'Oralsex mit großen Dildo im Anus', + '{PASSIV}, geh auf die Knie und reite anal einen großen Dildo, befriedige dabei {AKTIV} oral.', + 4, 60, 120, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000038', 'Vagina dehnen', + '{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Vagina von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000039', 'Anus dehnen', + '{PASSIV} geht auf alle viere und streckt den Hintern schön in die Luft, {AKTIV} führe langsam nach und nach mehr Finger in die Anus von {PASSIV} ein, bis {PASSIV} ''STOP'' sagt', + 2, NULL, NULL, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000040', 'Vaginalsex in Missionarstellung und Breathplay', + '{AKTIV} dringt in Missionarsstellung in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu', + 4, 30, 60, '10000000-0000-0000-0000-000000000005'), + +('30000000-0000-0000-0000-000000000041', 'Analsex in Missionarstellung und Breathplay', + '{AKTIV} dringt in Missionarsstellung anal in {PASSIV} und gibt vollgas, dabei packt {AKTIV} {PASSIV} am Hals und drückt beherzt zu', + 4, 30, 60, '10000000-0000-0000-0000-000000000005'); + +-- strafe_benoetigt_passiv (war @CollectionTable name="strafe_benoetigtPassiv") +INSERT IGNORE INTO strafe_benoetigt_passiv (strafe_id, werkzeug) VALUES +('30000000-0000-0000-0000-000000000009', 'VAGINA'), +('30000000-0000-0000-0000-000000000010', 'VAGINA'), +('30000000-0000-0000-0000-000000000011', 'VAGINA'), +('30000000-0000-0000-0000-000000000012', 'VAGINA'), +('30000000-0000-0000-0000-000000000014', 'ANUS'), +('30000000-0000-0000-0000-000000000015', 'VAGINA'), +('30000000-0000-0000-0000-000000000016', 'ANUS'), +('30000000-0000-0000-0000-000000000017', 'VAGINA'), +('30000000-0000-0000-0000-000000000018', 'MUND'), +('30000000-0000-0000-0000-000000000019', 'MUND'), +('30000000-0000-0000-0000-000000000020', 'MUND'), +('30000000-0000-0000-0000-000000000021', 'MUND'), +('30000000-0000-0000-0000-000000000022', 'MUND'), +('30000000-0000-0000-0000-000000000023', 'MUND'), +('30000000-0000-0000-0000-000000000033', 'MUND'), +('30000000-0000-0000-0000-000000000034', 'VAGINA'), +('30000000-0000-0000-0000-000000000035', 'VAGINA'), +('30000000-0000-0000-0000-000000000036', 'ANUS'), +('30000000-0000-0000-0000-000000000037', 'ANUS'), +('30000000-0000-0000-0000-000000000038', 'VAGINA'), +('30000000-0000-0000-0000-000000000039', 'ANUS'), +('30000000-0000-0000-0000-000000000040', 'VAGINA'), +('30000000-0000-0000-0000-000000000041', 'ANUS'); + +-- strafe_benoetigt_aktiv (war @CollectionTable name="strafe_benoetigtAktiv") +INSERT IGNORE INTO strafe_benoetigt_aktiv (strafe_id, werkzeug) VALUES +('30000000-0000-0000-0000-000000000018', 'VAGINA'), +('30000000-0000-0000-0000-000000000018', 'ANUS'), +('30000000-0000-0000-0000-000000000019', 'VAGINA'), +('30000000-0000-0000-0000-000000000019', 'ANUS'), +('30000000-0000-0000-0000-000000000020', 'PENIS'), +('30000000-0000-0000-0000-000000000020', 'ANUS'), +('30000000-0000-0000-0000-000000000021', 'VAGINA'), +('30000000-0000-0000-0000-000000000021', 'PENIS'), +('30000000-0000-0000-0000-000000000022', 'VAGINA'), +('30000000-0000-0000-0000-000000000023', 'VAGINA'), +('30000000-0000-0000-0000-000000000027', 'VAGINA'), +('30000000-0000-0000-0000-000000000027', 'ANUS'), +('30000000-0000-0000-0000-000000000028', 'VAGINA'), +('30000000-0000-0000-0000-000000000028', 'ANUS'), +('30000000-0000-0000-0000-000000000029', 'VAGINA'), +('30000000-0000-0000-0000-000000000030', 'VAGINA'), +('30000000-0000-0000-0000-000000000031', 'VAGINA'), +('30000000-0000-0000-0000-000000000032', 'PENIS'), +('30000000-0000-0000-0000-000000000033', 'PENIS'), +('30000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'), +('30000000-0000-0000-0000-000000000034', 'VAGINA'), +('30000000-0000-0000-0000-000000000034', 'PENIS'), +('30000000-0000-0000-0000-000000000035', 'VAGINA'), +('30000000-0000-0000-0000-000000000035', 'PENIS'), +('30000000-0000-0000-0000-000000000036', 'VAGINA'), +('30000000-0000-0000-0000-000000000036', 'PENIS'), +('30000000-0000-0000-0000-000000000037', 'VAGINA'), +('30000000-0000-0000-0000-000000000037', 'PENIS'), +('30000000-0000-0000-0000-000000000040', 'PENIS'), +('30000000-0000-0000-0000-000000000040', 'UMSCHNALLDILDO'), +('30000000-0000-0000-0000-000000000041', 'PENIS'), +('30000000-0000-0000-0000-000000000041', 'UMSCHNALLDILDO'); + + +-- ── Aufgaben ───────────────────────────────────────────────── +INSERT IGNORE INTO aufgabe (aufgabe_id, kurz_text, text, level, sekunden_von, sekunden_bis, gruppe_id) VALUES +('40000000-0000-0000-0000-000000000001', 'Hintern präsentieren', + '{AKTIV}, zeig {PASSIV} deinen Hintern, gib dir selber dabei ein oder zwei Klappse auf den Po', + 1, NULL, NULL, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000002', 'Hals küssen', + '{AKTIV}, küsse den Hals von {PASSIV} leidenschaftlich', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000003', 'Bauchnabel küssen', + '{AKTIV}, zeichne mit Küssen den Bauchnabel von {PASSIV} nach', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000004', 'Ohren knabbern', + '{AKTIV}, knabber leidenschaftlich an den Ohrläppchen von {PASSIV}', + 1, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000005', 'Berühren ohne anfassen', + '{AKTIV}, berühre den gesamten Körper von {PASSIV} ohne die Hände zu verwenden', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000006', 'Nacken küssen', + '{PASSIV} sitzt vor {AKTIV}, {AKTIV} küsste leidenschaftlich den Nacken von {PASSIV}', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000007', 'Brust küssen', + '{AKTIV}, küsse die Brust von {PASSIV} ohne die Nippel zu berühren', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000008', 'Nippel verwöhnen', + '{AKTIV}, verwöhne die Nippel von {PASSIV} mit Küssen', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000009', 'Hintern küssen', + '{AKTIV}, küsse den Hintern von {PASSIV} ohne den Anus zu berühren', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000010', 'Intimkuss durch Unterwäsche', + '{AKTIV}, küsse den Intimbereich von {PASSIV} durch die Unterwäsche', + 2, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000011', 'Brustmassage', + '{AKTIV}, massiere die Brust von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000012', 'Hinternmassage', + '{AKTIV}, massiere den Hintern von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000013', 'Rückenmassage', + '{AKTIV}, massiere den Rücken von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000014', 'Oberschenkelmassage', + '{AKTIV}, massiere die Oberschenkel von {PASSIV} leidenschaftlich', + 1, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000015', 'Klitoris mit Vibrator verwöhnen', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit einem Vibrator', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000016', 'Cunnilingus und Finger in Vagina', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit dem Mund, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000017', 'Klitoris mit Fingern verwöhnen und Finger in Vagina', + '{AKTIV}, verwöhne die Klitoris von {PASSIV} mit der Hand, führe dabei einen bis zwei Finger in die Vagina von {PASSIV} ein', + 4, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000018', 'Eichel mit Vibrator verwöhnen', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit einem Vibrator', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000019', 'Felatio', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit dem Mund', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000020', 'Handjob', + '{AKTIV}, verwöhne die Eichel von {PASSIV} mit der Hand', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000021', 'Facesitting', + '{AKTIV} liegt auf dem Rücken, {PASSIV} sitzt auf seinem Gesicht. {AKTIV}, verwöhne die Vagina von {PASSIV} mit dem Mund', + 4, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000022', '69er-Position', + '69er-Zeit: {AKTIV} liegt oben. {PASSIV}, falls du verschlossen bist, ziehe einen Strap on an, damit {AKTIV} auch was zu tun hat.', + 4, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000023', 'Kleiner Dildo vaginal', + '{AKTIV}, führe {PASSIV} einen kleinen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem', + 3, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000024', 'Großer Dildo vaginal', + '{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und verwöhne {PASSIV} durch langsame Bewegungen mit selbigem', + 4, 30, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000025', 'Großer Dildo vaginal schnell', + '{AKTIV}, führe {PASSIV} einen großen Dildo vaginal ein und bewege selbigen möglichst schnell rein und raus', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000026', 'Missionarstellung langsam', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000027', 'Missionarstellung schnell', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 30, 90, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000028', 'Missionarstellung Vollgas', + '{AKTIV} dringt in Missionarstellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000029', 'Reiterstellung langsam', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} bestimmt das Tempo', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000030', 'Reiterstellung schnell', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV}. {PASSIV} versucht das Tempo hoch zu halten', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000031', 'Reiterstellung vollgas', + '{PASSIV} setzt sich in Reiterstellung auf {AKTIV} und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000032', 'Doggystyle langsam', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000033', 'Doggystyle schnell', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000034', 'Doggystyle vollgas', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000035', 'Doggystyle vollgas keinen Mucks', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein und gibt vollgas. {PASSIV} darf dabei keinen Laut von sich geben.', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000036', 'Doggystyle Tempo bestimmt die ''gefickte'' Person', + '{AKTIV} dringt in Hundestellung in {PASSIV} ein. {AKTIV} hält still und {PASSIV} gibt das Tempo vor', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000037', 'Löffelchen langsam', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit langsamen Bewegungen', + 3, 60, 180, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000038', 'Löffelchen schnell', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und verwöhnt {PASSIV} mit schnellen Bewegungen', + 4, 60, 120, '10000000-0000-0000-0000-000000000006'), + +('40000000-0000-0000-0000-000000000039', 'Löffelchen vollgas', + '{AKTIV} dringt in Löffelchenstellung in {PASSIV} ein und gibt vollgas', + 5, 30, 60, '10000000-0000-0000-0000-000000000006'); + +-- aufgabe_benoetigt_aktiv (war @CollectionTable name="aufgabe_benoetigtAktiv") +INSERT IGNORE INTO aufgabe_benoetigt_aktiv (aufgabe_id, werkzeug) VALUES +('40000000-0000-0000-0000-000000000002', 'MUND'), +('40000000-0000-0000-0000-000000000003', 'MUND'), +('40000000-0000-0000-0000-000000000004', 'MUND'), +('40000000-0000-0000-0000-000000000006', 'MUND'), +('40000000-0000-0000-0000-000000000007', 'MUND'), +('40000000-0000-0000-0000-000000000008', 'MUND'), +('40000000-0000-0000-0000-000000000009', 'MUND'), +('40000000-0000-0000-0000-000000000010', 'MUND'), +('40000000-0000-0000-0000-000000000016', 'MUND'), +('40000000-0000-0000-0000-000000000019', 'MUND'), +('40000000-0000-0000-0000-000000000021', 'MUND'), +('40000000-0000-0000-0000-000000000022', 'VAGINA'), +('40000000-0000-0000-0000-000000000022', 'MUND'), +('40000000-0000-0000-0000-000000000026', 'PENIS'), +('40000000-0000-0000-0000-000000000026', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000027', 'PENIS'), +('40000000-0000-0000-0000-000000000027', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000028', 'PENIS'), +('40000000-0000-0000-0000-000000000028', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000029', 'PENIS'), +('40000000-0000-0000-0000-000000000029', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000030', 'PENIS'), +('40000000-0000-0000-0000-000000000030', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000031', 'PENIS'), +('40000000-0000-0000-0000-000000000031', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000032', 'PENIS'), +('40000000-0000-0000-0000-000000000032', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000033', 'PENIS'), +('40000000-0000-0000-0000-000000000033', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000034', 'PENIS'), +('40000000-0000-0000-0000-000000000034', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000035', 'PENIS'), +('40000000-0000-0000-0000-000000000035', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000036', 'PENIS'), +('40000000-0000-0000-0000-000000000036', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000037', 'PENIS'), +('40000000-0000-0000-0000-000000000037', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000038', 'PENIS'), +('40000000-0000-0000-0000-000000000038', 'UMSCHNALLDILDO'), +('40000000-0000-0000-0000-000000000039', 'PENIS'), +('40000000-0000-0000-0000-000000000039', 'UMSCHNALLDILDO'); + +-- aufgabe_benoetigt_passiv (war @CollectionTable name="aufgabe_benoetigtPassiv") +INSERT IGNORE INTO aufgabe_benoetigt_passiv (aufgabe_id, werkzeug) VALUES +('40000000-0000-0000-0000-000000000015', 'VAGINA'), +('40000000-0000-0000-0000-000000000016', 'VAGINA'), +('40000000-0000-0000-0000-000000000017', 'VAGINA'), +('40000000-0000-0000-0000-000000000018', 'PENIS'), +('40000000-0000-0000-0000-000000000019', 'PENIS'), +('40000000-0000-0000-0000-000000000020', 'PENIS'), +('40000000-0000-0000-0000-000000000021', 'VAGINA'), +('40000000-0000-0000-0000-000000000022', 'MUND'), +('40000000-0000-0000-0000-000000000023', 'VAGINA'), +('40000000-0000-0000-0000-000000000024', 'VAGINA'), +('40000000-0000-0000-0000-000000000025', 'VAGINA'), +('40000000-0000-0000-0000-000000000026', 'VAGINA'), +('40000000-0000-0000-0000-000000000027', 'VAGINA'), +('40000000-0000-0000-0000-000000000028', 'VAGINA'), +('40000000-0000-0000-0000-000000000029', 'VAGINA'), +('40000000-0000-0000-0000-000000000030', 'VAGINA'), +('40000000-0000-0000-0000-000000000031', 'VAGINA'), +('40000000-0000-0000-0000-000000000032', 'VAGINA'), +('40000000-0000-0000-0000-000000000033', 'VAGINA'), +('40000000-0000-0000-0000-000000000034', 'VAGINA'), +('40000000-0000-0000-0000-000000000035', 'VAGINA'), +('40000000-0000-0000-0000-000000000036', 'VAGINA'), +('40000000-0000-0000-0000-000000000037', 'VAGINA'), +('40000000-0000-0000-0000-000000000038', 'VAGINA'), +('40000000-0000-0000-0000-000000000039', 'VAGINA'); diff --git a/src/main/resources/sql/testdaten.sql b/src/main/resources/sql/testdaten.sql new file mode 100644 index 0000000..6fb7835 --- /dev/null +++ b/src/main/resources/sql/testdaten.sql @@ -0,0 +1,504 @@ +-- ============================================================= +-- XXX The Game – Testdaten +-- ============================================================= +-- Passwort für alle User: Test1234! +-- SHA-256("Test1234!") = 11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a +-- Hinweis: Login erwartet SHA-256-Hash vom Client +-- ============================================================= + +SET FOREIGN_KEY_CHECKS = 0; + +-- Aufräumen (Reihenfolge wegen FK) +DELETE FROM kommentar_like; +DELETE FROM kommentar; +DELETE FROM pinnwand_like; +DELETE FROM pinnwand_eintrag; +DELETE FROM feed_post_vote; +DELETE FROM feed_post_option; +DELETE FROM feed_post_like; +DELETE FROM feed_post; +DELETE FROM umfrage_stimme; +DELETE FROM umfrage_option; +DELETE FROM gruppe_beitrag_like; +DELETE FROM gruppe_beitrag; +DELETE FROM beitrittsanfrage; +DELETE FROM gruppe_mitglied; +DELETE FROM gruppe; +DELETE FROM profile_image_like; +DELETE FROM profile_image; +DELETE FROM friendship; +DELETE FROM registration; +DELETE FROM `user`; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================= +-- BENUTZER (5 User mit unterschiedlichen Profilen) +-- ============================================================= + +INSERT INTO `user` ( + user_id, name, email, password, geburtsdatum, + groesse, gewicht, geschlecht, neigung, beziehungsstatus, beschreibung, + lockee_xp, keyholder_xp, bdsm_xp, + sichtbarkeit_grunddaten, sichtbarkeit_galerie, sichtbarkeit_freunde, + sichtbarkeit_feed, sichtbarkeit_pinnwand, sichtbarkeit_xp, sichtbarkeit_lockhistorie +) VALUES + +-- 1. MaxMuster – dominant, Single +('11111111-1111-1111-1111-000000000001', + 'MaxMuster', 'max@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1990-05-15', + 182, 80, 'MAENNLICH', 'DOMINANT', 'SINGLE', + 'Erfahrener Keyholder, der auf striktes aber faires Spiel steht. Immer offen für neue Spielpartner.', + 120, 850, 300, + 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'), + +-- 2. LisaLust – devot, Single +('11111111-1111-1111-1111-000000000002', + 'LisaLust', 'lisa@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1995-08-22', + 165, 58, 'WEIBLICH', 'DEVOT', 'SINGLE', + 'Neugierigie Lockee auf der Suche nach einem verlässlichen Keyholder. Mag lange Sperren und herausfordernde Aufgaben.', + 740, 0, 150, + 'ALLE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE'), + +-- 3. SamSwitcher – Switcher, in Beziehung +('11111111-1111-1111-1111-000000000003', + 'SamSwitcher', 'sam@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1988-11-03', + 175, 70, 'DIVERS', 'SWITCHER', 'IN_EINER_BEZIEHUNG', + 'Mal oben, mal unten – kommt auf die Stimmung an. Spiele gerne mit meinem Partner zusammen.', + 430, 390, 600, + 'ALLE', 'ALLE', 'ALLE', 'NUR_FREUNDE', 'ALLE', 'NUR_FREUNDE', 'ALLE'), + +-- 4. KajaKette – eher devot, Single +('11111111-1111-1111-1111-000000000004', + 'KajaKette', 'kaja@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1998-02-14', + 170, 62, 'WEIBLICH', 'EHER_DEVOT', 'SINGLE', + 'Chastity-Enthusiastin mit Fokus auf Community-Locks. Schreibe gerne auf Pinnwände!', + 920, 50, 80, + 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE', 'ALLE'), + +-- 5. TomTop – eher dominant, verheiratet +('11111111-1111-1111-1111-000000000005', + 'TomTop', 'tom@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + '1985-07-30', + 178, 85, 'MAENNLICH', 'EHER_DOMINANT', 'VERHEIRATET', + 'Verheiratet, spielen als Paar. Biete Keyholder-Service für seriöse Anfragen.', + 200, 560, 410, + 'ALLE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'NUR_FREUNDE', 'ALLE', 'ALLE', 'NUR_FREUNDE'); + +-- ============================================================= +-- NICHT AKTIVIERTE REGISTRIERUNG (für Registrierungs-Tests) +-- ============================================================= + +INSERT INTO registration ( + registration_id, name, email, password, activated, activation_code, geburtsdatum +) VALUES +('99999999-9999-9999-9999-000000000001', + 'NeuerUser', 'neu@test.de', + '11a1162b984f8cf531e07d9bde6e27f26d6e9c0a2c4c52a6c1f0e2e79cd4e4a', + FALSE, '347821', '2000-01-01'); + +-- ============================================================= +-- FREUNDSCHAFTEN +-- ============================================================= + +INSERT INTO friendship (friendship_id, sender_id, receiver_id, status, created_at) VALUES +-- Max ↔ Lisa (akzeptiert) +('22222222-2222-2222-2222-000000000001', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000002', + 'ACCEPTED', '2025-11-01 10:00:00'), +-- Max ↔ Sam (akzeptiert) +('22222222-2222-2222-2222-000000000002', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000003', + 'ACCEPTED', '2025-11-15 14:30:00'), +-- Lisa ↔ Kaja (akzeptiert) +('22222222-2222-2222-2222-000000000003', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000004', + 'ACCEPTED', '2025-12-03 09:15:00'), +-- Tom → Kaja (ausstehend) +('22222222-2222-2222-2222-000000000004', + '11111111-1111-1111-1111-000000000005', + '11111111-1111-1111-1111-000000000004', + 'PENDING', '2026-01-10 18:45:00'), +-- Sam ↔ Kaja (akzeptiert) +('22222222-2222-2222-2222-000000000005', + '11111111-1111-1111-1111-000000000003', + '11111111-1111-1111-1111-000000000004', + 'ACCEPTED', '2026-01-20 11:00:00'); + +-- ============================================================= +-- PINNWAND-EINTRÄGE +-- ============================================================= + +INSERT INTO pinnwand_eintrag (eintrag_id, profil_user_id, author_id, text, created_at) VALUES +-- Auf Lisas Pinnwand +('33333333-3333-3333-3333-000000000001', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000001', + 'Hey Lisa! Schön, dich hier zu sehen. Viel Spaß beim Spielen 🔒', + '2025-12-10 16:00:00'), +('33333333-3333-3333-3333-000000000002', + '11111111-1111-1111-1111-000000000002', + '11111111-1111-1111-1111-000000000004', + 'Wir sollten mal ein gemeinsames Lock starten! Meld dich 😊', + '2026-01-05 12:30:00'), +-- Auf Maxs Pinnwand +('33333333-3333-3333-3333-000000000003', + '11111111-1111-1111-1111-000000000001', + '11111111-1111-1111-1111-000000000002', + 'Danke für den tollen Keyholder-Service letzte Woche!', + '2026-01-08 20:00:00'), +-- Auf Kajas Pinnwand +('33333333-3333-3333-3333-000000000004', + '11111111-1111-1111-1111-000000000004', + '11111111-1111-1111-1111-000000000003', + 'Kaja, du bist die Community-Queen! Immer so aktiv hier.', + '2026-02-14 09:00:00'); + +-- Pinnwand-Likes +INSERT INTO pinnwand_like (like_id, eintrag_id, user_id, liked_at) VALUES +('33333333-3333-3333-3333-000000000101', + '33333333-3333-3333-3333-000000000001', + '11111111-1111-1111-1111-000000000002', + '2025-12-10 16:05:00'), +('33333333-3333-3333-3333-000000000102', + '33333333-3333-3333-3333-000000000002', + '11111111-1111-1111-1111-000000000001', + '2026-01-05 13:00:00'), +('33333333-3333-3333-3333-000000000103', + '33333333-3333-3333-3333-000000000003', + '11111111-1111-1111-1111-000000000004', + '2026-01-09 10:00:00'); + +-- ============================================================= +-- KOMMENTARE +-- ============================================================= + +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +-- Kommentar auf Pinnwand-Eintrag +('44444444-4444-4444-4444-000000000001', + '11111111-1111-1111-1111-000000000002', + 'PINNWAND', + '33333333-3333-3333-3333-000000000001', + 'Danke Max! Ich freu mich auch 😊', + '2025-12-10 17:00:00'), +('44444444-4444-4444-4444-000000000002', + '11111111-1111-1111-1111-000000000003', + 'PINNWAND', + '33333333-3333-3333-3333-000000000001', + '+1, willkommen in der Community!', + '2025-12-10 18:30:00'), +-- Reply auf Kommentar +('44444444-4444-4444-4444-000000000003', + '11111111-1111-1111-1111-000000000001', + 'KOMMENTAR', + '44444444-4444-4444-4444-000000000001', + 'Na logo! Wir machen das 😄', + '2025-12-10 17:15:00'); + +-- Kommentar-Likes +INSERT INTO kommentar_like (like_id, kommentar_id, user_id, liked_at) VALUES +('44444444-4444-4444-4444-000000000101', + '44444444-4444-4444-4444-000000000001', + '11111111-1111-1111-1111-000000000001', + '2025-12-10 17:10:00'), +('44444444-4444-4444-4444-000000000102', + '44444444-4444-4444-4444-000000000002', + '11111111-1111-1111-1111-000000000002', + '2025-12-10 19:00:00'); + +-- ============================================================= +-- FEED-POSTS (Text + Umfrage) +-- ============================================================= + +INSERT INTO feed_post (post_id, author_id, text, beitrag_typ, multi_choice, is_public, created_at) VALUES +-- Öffentlicher Text-Post von Max +('55555555-5555-5555-5555-000000000001', + '11111111-1111-1111-1111-000000000001', + 'Wer hat Lust auf ein Cardlock-Turnier nächsten Monat? Community vs. Keyholder! 🃏', + 'TEXT', NULL, TRUE, '2026-02-01 10:00:00'), + +-- Öffentlicher Text-Post von Lisa +('55555555-5555-5555-5555-000000000002', + '11111111-1111-1111-1111-000000000002', + '48 Stunden geschafft! Das war mein bisher längstes Lock. Ich bin so stolz auf mich! 🔐✨', + 'TEXT', NULL, TRUE, '2026-02-05 14:30:00'), + +-- Öffentliche Umfrage von Kaja (Single-Choice) +('55555555-5555-5555-5555-000000000003', + '11111111-1111-1111-1111-000000000004', + 'Was bevorzugt ihr: Cardlock oder Timelock?', + 'UMFRAGE', FALSE, TRUE, '2026-02-10 09:00:00'), + +-- Öffentliche Umfrage von Sam (Multi-Choice) +('55555555-5555-5555-5555-000000000004', + '11111111-1111-1111-1111-000000000003', + 'Welche Features wollt ihr als nächstes sehen? (Mehrfachauswahl möglich)', + 'UMFRAGE', TRUE, TRUE, '2026-02-15 20:00:00'), + +-- Nicht-öffentlicher Post von Tom +('55555555-5555-5555-5555-000000000005', + '11111111-1111-1111-1111-000000000005', + 'Spielen heute Abend mit meiner Frau eine Runde BDSM. Sie darf den Keyholder spielen!', + 'TEXT', NULL, FALSE, '2026-02-20 18:00:00'); + +-- Umfrage-Optionen +INSERT INTO feed_post_option (option_id, post_id, text, reihenfolge) VALUES +-- Kajas Umfrage +('55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', 'Cardlock – ich liebe die Ungewissheit!', 0), +('55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', 'Timelock – Struktur ist alles.', 1), +('55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', 'Beides gleich gerne.', 2), +-- Sams Umfrage +('55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', 'Mobile App', 0), +('55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', 'Mehr Aufgaben-Vorlagen', 1), +('55555555-5555-5555-5555-000000000106', '55555555-5555-5555-5555-000000000004', 'Dark/Light Theme Toggle', 2), +('55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', 'Push-Benachrichtigungen', 3); + +-- Umfrage-Stimmen +INSERT INTO feed_post_vote (stimme_id, option_id, post_id, user_id) VALUES +-- Kajas Umfrage +('55555555-5555-5555-5555-000000000201', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000202', '55555555-5555-5555-5555-000000000101', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000203', '55555555-5555-5555-5555-000000000102', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000005'), +('55555555-5555-5555-5555-000000000204', '55555555-5555-5555-5555-000000000103', '55555555-5555-5555-5555-000000000003', '11111111-1111-1111-1111-000000000003'), +-- Sams Umfrage (Multi-Choice) +('55555555-5555-5555-5555-000000000205', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000206', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000001'), +('55555555-5555-5555-5555-000000000207', '55555555-5555-5555-5555-000000000104', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000208', '55555555-5555-5555-5555-000000000107', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000002'), +('55555555-5555-5555-5555-000000000209', '55555555-5555-5555-5555-000000000105', '55555555-5555-5555-5555-000000000004', '11111111-1111-1111-1111-000000000004'); + +-- Feed-Likes +INSERT INTO feed_post_like (like_id, post_id, user_id, liked_at) VALUES +('55555555-5555-5555-5555-000000000301', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000002', '2026-02-01 10:30:00'), +('55555555-5555-5555-5555-000000000302', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000003', '2026-02-01 11:00:00'), +('55555555-5555-5555-5555-000000000303', '55555555-5555-5555-5555-000000000001', '11111111-1111-1111-1111-000000000004', '2026-02-01 11:15:00'), +('55555555-5555-5555-5555-000000000304', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000001', '2026-02-05 15:00:00'), +('55555555-5555-5555-5555-000000000305', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000004', '2026-02-05 15:30:00'), +('55555555-5555-5555-5555-000000000306', '55555555-5555-5555-5555-000000000002', '11111111-1111-1111-1111-000000000003', '2026-02-05 16:00:00'); + +-- Kommentare unter Feed-Posts +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +('66666666-6666-6666-6666-000000000001', + '11111111-1111-1111-1111-000000000002', + 'FEED_POST', + '55555555-5555-5555-5555-000000000001', + 'Bin dabei! Wann genau? 🙋‍♀️', + '2026-02-01 11:00:00'), +('66666666-6666-6666-6666-000000000002', + '11111111-1111-1111-1111-000000000003', + 'FEED_POST', + '55555555-5555-5555-5555-000000000001', + 'Klingt mega! Ich schlage vor: 1 Woche Mindestlaufzeit.', + '2026-02-01 11:30:00'), +('66666666-6666-6666-6666-000000000003', + '11111111-1111-1111-1111-000000000001', + 'FEED_POST', + '55555555-5555-5555-5555-000000000002', + 'Respekt! 48h ist eine echte Leistung 👏', + '2026-02-05 15:00:00'); + +-- ============================================================= +-- GRUPPEN +-- ============================================================= + +INSERT INTO gruppe (gruppe_id, name, beschreibung, bild, is_private, created_at, created_by_user_id) VALUES +-- Öffentliche Gruppe +('77777777-7777-7777-7777-000000000001', + 'Cardlock Community', + 'Die Gruppe für alle Cardlock-Fans! Hier tauschen wir Erfahrungen aus, veranstalten Turniere und helfen Neulingen beim Einstieg.', + NULL, FALSE, '2025-10-01 12:00:00', + '11111111-1111-1111-1111-000000000001'), + +-- Private Gruppe +('77777777-7777-7777-7777-000000000002', + 'Keyholder-Stammtisch', + 'Privater Austausch unter erfahrenen Keyholdern. Nur auf Einladung.', + NULL, TRUE, '2025-11-15 18:00:00', + '11111111-1111-1111-1111-000000000005'), + +-- Öffentliche Gruppe +('77777777-7777-7777-7777-000000000003', + 'Anfänger & Fragen', + 'Neuling? Frag einfach! Hier ist jede Frage willkommen. Keine Scheu.', + NULL, FALSE, '2026-01-01 00:00:00', + '11111111-1111-1111-1111-000000000004'); + +-- ============================================================= +-- GRUPPENMITGLIEDER +-- ============================================================= + +INSERT INTO gruppe_mitglied (mitglied_id, gruppe_id, user_id, rolle, joined_at) VALUES +-- Cardlock Community +('77777777-7777-7777-7777-000000000101', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000001', 'ADMIN', '2025-10-01 12:00:00'), +('77777777-7777-7777-7777-000000000102', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2025-10-05 09:00:00'), +('77777777-7777-7777-7777-000000000103', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2025-10-10 14:00:00'), +('77777777-7777-7777-7777-000000000104', '77777777-7777-7777-7777-000000000001', '11111111-1111-1111-1111-000000000004', 'MITGLIED', '2025-10-20 11:00:00'), +-- Keyholder-Stammtisch +('77777777-7777-7777-7777-000000000105', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000005', 'ADMIN', '2025-11-15 18:00:00'), +('77777777-7777-7777-7777-000000000106', '77777777-7777-7777-7777-000000000002', '11111111-1111-1111-1111-000000000001', 'MITGLIED', '2025-11-20 10:00:00'), +-- Anfänger & Fragen +('77777777-7777-7777-7777-000000000107', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000004', 'ADMIN', '2026-01-01 00:00:00'), +('77777777-7777-7777-7777-000000000108', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000002', 'MITGLIED', '2026-01-03 08:00:00'), +('77777777-7777-7777-7777-000000000109', '77777777-7777-7777-7777-000000000003', '11111111-1111-1111-1111-000000000003', 'MITGLIED', '2026-01-05 12:00:00'); + +-- Ausstehende Beitrittsanfrage zur privaten Gruppe +INSERT INTO beitrittsanfrage (anfrage_id, gruppe_id, user_id, nachricht, angefragt_at, status) VALUES +('77777777-7777-7777-7777-000000000201', + '77777777-7777-7777-7777-000000000002', + '11111111-1111-1111-1111-000000000003', + 'Hallo! Ich bin seit 2 Jahren aktiver Keyholder und würde gerne dazugehören.', + '2026-02-01 15:00:00', 'AUSSTEHEND'), +('77777777-7777-7777-7777-000000000202', + '77777777-7777-7777-7777-000000000002', + '11111111-1111-1111-1111-000000000004', + 'Bitte nehmt mich auf! Habe schon ein paar Monate Erfahrung als Keyholderin.', + '2026-02-10 09:00:00', 'ABGELEHNT'); + +-- ============================================================= +-- GRUPPEN-BEITRÄGE (Text + Umfrage) +-- ============================================================= + +INSERT INTO gruppe_beitrag (beitrag_id, gruppe_id, author_id, beitrag_typ, text, multi_choice, bild, created_at) VALUES +-- Cardlock Community +('88888888-8888-8888-8888-000000000001', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000001', + 'TEXT', + 'Willkommen in der Cardlock Community! Stellt euch kurz vor und erzählt, wie ihr zum Cardlock gekommen seid.', + NULL, NULL, '2025-10-01 12:05:00'), + +('88888888-8888-8888-8888-000000000002', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000002', + 'TEXT', + 'Ich bin Lisa und liebe Cardlocks seit über einem Jahr! Mein Rekord sind 5 Tage – habt ihr Tipps für längere Sperren?', + NULL, NULL, '2025-10-05 10:00:00'), + +('88888888-8888-8888-8888-000000000003', + '77777777-7777-7777-7777-000000000001', + '11111111-1111-1111-1111-000000000004', + 'UMFRAGE', + 'Wie viele Karten startet ihr typischerweise mit?', + FALSE, NULL, '2025-10-20 14:00:00'), + +-- Anfänger & Fragen +('88888888-8888-8888-8888-000000000004', + '77777777-7777-7777-7777-000000000003', + '11111111-1111-1111-1111-000000000002', + 'TEXT', + 'Frage: Wie erkläre ich Cardlocks am besten meinem Partner, der noch nie davon gehört hat?', + NULL, NULL, '2026-01-10 19:00:00'), + +('88888888-8888-8888-8888-000000000005', + '77777777-7777-7777-7777-000000000003', + '11111111-1111-1111-1111-000000000001', + 'TEXT', + 'Gute Frage! Ich würde empfehlen, erst mit einem kurzen Timelock anzufangen. So kann der Partner das Grundkonzept verstehen, ohne direkt mit der Karten-Mechanik überfordert zu werden.', + NULL, NULL, '2026-01-10 19:30:00'); + +-- Umfrage-Optionen für Gruppen-Beitrag +INSERT INTO umfrage_option (option_id, beitrag_id, text, reihenfolge) VALUES +('88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', 'Unter 20 Karten', 0), +('88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '20–40 Karten', 1), +('88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '40–60 Karten', 2), +('88888888-8888-8888-8888-000000000104', '88888888-8888-8888-8888-000000000003', 'Über 60 Karten', 3); + +-- Umfrage-Stimmen (Gruppen) +INSERT INTO umfrage_stimme (stimme_id, option_id, beitrag_id, user_id) VALUES +('88888888-8888-8888-8888-000000000201', '88888888-8888-8888-8888-000000000101', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000002'), +('88888888-8888-8888-8888-000000000202', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000001'), +('88888888-8888-8888-8888-000000000203', '88888888-8888-8888-8888-000000000102', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000003'), +('88888888-8888-8888-8888-000000000204', '88888888-8888-8888-8888-000000000103', '88888888-8888-8888-8888-000000000003', '11111111-1111-1111-1111-000000000004'); + +-- Gruppen-Beitrag-Likes +INSERT INTO gruppe_beitrag_like (like_id, beitrag_id, user_id, liked_at) VALUES +('88888888-8888-8888-8888-000000000301', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000002', '2025-10-01 12:10:00'), +('88888888-8888-8888-8888-000000000302', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000003', '2025-10-01 13:00:00'), +('88888888-8888-8888-8888-000000000303', '88888888-8888-8888-8888-000000000001', '11111111-1111-1111-1111-000000000004', '2025-10-01 14:00:00'), +('88888888-8888-8888-8888-000000000304', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000001', '2025-10-05 10:30:00'), +('88888888-8888-8888-8888-000000000305', '88888888-8888-8888-8888-000000000002', '11111111-1111-1111-1111-000000000004', '2025-10-05 11:00:00'), +('88888888-8888-8888-8888-000000000306', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000002', '2026-01-10 19:45:00'), +('88888888-8888-8888-8888-000000000307', '88888888-8888-8888-8888-000000000005', '11111111-1111-1111-1111-000000000004', '2026-01-10 20:00:00'); + +-- Kommentare auf Gruppen-Beiträge +INSERT INTO kommentar (kommentar_id, author_id, target_type, target_id, text, created_at) VALUES +('99999999-0000-0000-0000-000000000001', + '11111111-1111-1111-1111-000000000003', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000002', + 'Hi Lisa! Mein Tipp: Fang mit mehr Green Cards an als du denkst. Du wirst es brauchen 😄', + '2025-10-05 11:00:00'), +('99999999-0000-0000-0000-000000000002', + '11111111-1111-1111-1111-000000000001', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000002', + 'Mentale Vorbereitung ist alles. Schreib dir vorher auf, warum du es tust.', + '2025-10-05 12:00:00'), +('99999999-0000-0000-0000-000000000003', + '11111111-1111-1111-1111-000000000004', + 'GROUP_POST', + '88888888-8888-8888-8888-000000000004', + 'Ich würde sagen: zeig ihm/ihr einfach die App! Das visuelle Konzept erklärt sich fast von selbst.', + '2026-01-10 19:15:00'); + +-- ============================================================= +-- CHASTITY LOCK (ein aktives Cardlock: Lisa gesperrt von Max) +-- ============================================================= + +INSERT INTO current_lock ( + lock_id, lock_type, name, lockee, keyholder, + test_lock, requires_verification, + unlock_code_length, unlock_code, + start_time, unlock_time, + hygine_opening_duration_minutes, hygine_opening_everyminites, + task_mode, + keyholder_requested_unlock, emergency_auto_unlocked, + -- CARDLOCK-spezifisch + initial_cards, pick_every_minute, accumulate_picks, + show_remaining_cards, open_picks, + available_cards +) VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-000000000001', + 'CARDLOCK', + 'Lisas Frühlings-Lock', + '11111111-1111-1111-1111-000000000002', -- lockee: Lisa + '11111111-1111-1111-1111-000000000001', -- keyholder: Max + FALSE, FALSE, + 6, NULL, + '2026-03-20 10:00:00', NULL, + 30, 1440, -- Hygiene alle 24h, 30 Min offen + 'KEYHOLDER', + FALSE, FALSE, + -- 30 Karten: 5×GREEN, 15×RED, 5×YELLOW, 3×TASK, 2×FREEZE + '["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]', + 240, FALSE, -- Karte alle 4h ziehen, kein Akkumulieren + TRUE, 0, + '["GREEN","GREEN","GREEN","GREEN","GREEN","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","RED","YELLOW","YELLOW","YELLOW","YELLOW","YELLOW","TASK","TASK","TASK","FREEZE","FREEZE"]' +); + +-- ============================================================= +-- FERTIG +-- ============================================================= +-- Überblick: +-- 5 User (max@test.de, lisa@test.de, sam@test.de, kaja@test.de, tom@test.de) +-- 1 nicht aktivierte Registrierung (neu@test.de, Code: 347821) +-- 5 Freundschaften (4 akzeptiert, 1 ausstehend) +-- 4 Pinnwand-Einträge + 3 Likes +-- 3 Kommentare auf Pinnwand + 3 auf Feed + 3 auf Gruppen-Beiträge +-- 5 Feed-Posts (3 Text, 2 Umfragen) + 6 Likes +-- 3 Gruppen (2 öffentlich, 1 privat) mit je 4-6 Mitgliedern +-- 5 Gruppen-Beiträge (4 Text, 1 Umfrage) + 7 Likes +-- 1 aktives Cardlock (Lisa ← Max) +-- ============================================================= diff --git a/src/main/resources/static/activate.html b/src/main/resources/static/activate.html new file mode 100644 index 0000000..0fa0d01 --- /dev/null +++ b/src/main/resources/static/activate.html @@ -0,0 +1,94 @@ + + + + + + + Aktivierung – xXx Sphere + + + + +
            +

            XXX The Game

            +

            E-Mail-Adresse bestätigen

            + +

            + Du hast eine E-Mail mit einem Aktivierungslink erhalten.
            + Falls der Link nicht funktioniert, gib hier deinen Aktivierungscode ein. +

            + + + + + + +
            +
            + + + + diff --git a/src/main/resources/static/admin/admin.html b/src/main/resources/static/admin/admin.html new file mode 100644 index 0000000..9cc1860 --- /dev/null +++ b/src/main/resources/static/admin/admin.html @@ -0,0 +1,2604 @@ + + + + + + + Administration – xXx Sphere + + + + + + + + + + + + + + + + + + + + + + + + +
            +
            + +
            + + + + + + + + +
            + + +
            +
            + + +
            +
            + + + + + + + + + + +
            MelderTypZiel-IDGrundGemeldetStatus
            Wird geladen…
            +
            +
            + + +
            +
            +
            +

            📬 Ungelesen

            +
            + +
            +
            +
            Wird geladen…
            +
            +
            +
            +

            🔧 In Arbeit

            +
            +
            Wird geladen…
            +
            +
            +
            +

            ✅ Beantwortet

            +
            +
            Wird geladen…
            +
            +
            + + + + + +
            +
            +
            +

            System-Aufgabengruppen

            +
            + + + + + + + +
            +
            +
            +
            Wird geladen…
            +
            +
            +
            + + +
            +
            +
            +

            System-Toys

            +
            + + + + + + + + +
            +
            +
            +
            + +
            +
            + + +
            +
            +
            +

            Vorlieben

            +
            + + + + +
            +
            +
            + + + + + +

            Wird geladen…

            +
            +
            + + +
            +
            +

            Admin hinzufügen

            +
            + + +
            + +
            +
            +
            + + + + + + + +
            BenutzernameRolleSeit
            Wird geladen…
            +
            +
            + + +
            + + +
            +
            +

            Aktive Abonnements

            + +
            +
            + + + + + + + + + + + + +
            BenutzerTypGestartetGültig bis
            Laden…
            +
            +
            + + +
            +
            +

            Abonnement verschenken

            +
            +
            +

            + Suche einen Benutzer und schenke ihm 1 Monat Premium. Hat der Benutzer bereits ein + aktives Abo, wird die Laufzeit um 1 Monat verlängert. +

            + + +
            + + + +
            + + + + +
            + +
            + +
            +
            +
            +
            + + +
            +
            +
            +

            TTLock-Konfiguration

            +
            +
            +

            API-Zugangsdaten

            + + + + + + +
            +
            + + +
            +
            +
            +
            + +
            +
            + + + + + + + + + + diff --git a/src/main/resources/static/audio/alarm.mp3 b/src/main/resources/static/audio/alarm.mp3 new file mode 100644 index 0000000..b183e18 Binary files /dev/null and b/src/main/resources/static/audio/alarm.mp3 differ diff --git a/src/main/resources/static/audio/lvlup.mp3 b/src/main/resources/static/audio/lvlup.mp3 new file mode 100644 index 0000000..de69f88 Binary files /dev/null and b/src/main/resources/static/audio/lvlup.mp3 differ diff --git a/src/main/resources/static/audio/message.mp3 b/src/main/resources/static/audio/message.mp3 new file mode 100644 index 0000000..263d334 Binary files /dev/null and b/src/main/resources/static/audio/message.mp3 differ diff --git a/src/main/resources/static/audio/notification.mp3 b/src/main/resources/static/audio/notification.mp3 new file mode 100644 index 0000000..0b94f07 Binary files /dev/null and b/src/main/resources/static/audio/notification.mp3 differ diff --git a/src/main/resources/static/audio/ping.mp3 b/src/main/resources/static/audio/ping.mp3 new file mode 100644 index 0000000..8c765d0 Binary files /dev/null and b/src/main/resources/static/audio/ping.mp3 differ diff --git a/src/main/resources/static/audio/release.mp3 b/src/main/resources/static/audio/release.mp3 new file mode 100644 index 0000000..d846cb8 Binary files /dev/null and b/src/main/resources/static/audio/release.mp3 differ diff --git a/src/main/resources/static/community/abonnements.html b/src/main/resources/static/community/abonnements.html new file mode 100644 index 0000000..6e0c8cf --- /dev/null +++ b/src/main/resources/static/community/abonnements.html @@ -0,0 +1,38 @@ + + + + + + + Abonnements – xXx Sphere + + + + + +
            +
            +

            ⭐ Abonnements

            +

            Übersicht der verfügbaren Abo-Modelle

            + +
            + 🚧 +

            Demnächst verfügbar

            +

            Hier werden bald die verschiedenen Abo-Modelle beschrieben und abschließbar sein.

            +
            +
            +
            + + + + + diff --git a/src/main/resources/static/community/benachrichtigungen.html b/src/main/resources/static/community/benachrichtigungen.html new file mode 100644 index 0000000..7dcdd70 --- /dev/null +++ b/src/main/resources/static/community/benachrichtigungen.html @@ -0,0 +1,252 @@ + + + + + + + Benachrichtigungen – xXx Sphere + + + + + +
            +
            +
            +

            🔔 Benachrichtigungen

            + +
            + +
            + +
            +
            +
            + + + + + + + diff --git a/src/main/resources/static/community/benutzer.html b/src/main/resources/static/community/benutzer.html new file mode 100644 index 0000000..004ca70 --- /dev/null +++ b/src/main/resources/static/community/benutzer.html @@ -0,0 +1,1410 @@ + + + + + + + Profil – xXx Sphere + + + + + + +
            +
            + +

            Wird geladen…

            + + +
            +
            + + + + + + + + + + + + + diff --git a/src/main/resources/static/community/feed.html b/src/main/resources/static/community/feed.html new file mode 100644 index 0000000..2713d1a --- /dev/null +++ b/src/main/resources/static/community/feed.html @@ -0,0 +1,541 @@ + + + + + + + Feed – xXx Sphere + + + + + +
            +
            + +
            + + +
            + + +
            +
            +
            + + +
            + +
            + + +
            +
            + +
            +
            + + +
            +
            + +
            +
            + +
            +
            + + + + + + + + + + + + diff --git a/src/main/resources/static/community/freunde.html b/src/main/resources/static/community/freunde.html new file mode 100644 index 0000000..f2bbd71 --- /dev/null +++ b/src/main/resources/static/community/freunde.html @@ -0,0 +1,351 @@ + + + + + + + Freunde – xXx Sphere + + + + + + +
            +
            +

            Freunde

            + +
            + + +
            + + +
            +
              + +
              + + +
              +
                + +
                +
                +
                + + +
                +
                +

                Freundschaft beenden

                +

                Möchtest du diese Freundschaft wirklich beenden?

                +
                + + +
                +
                +
                + + + + + + + diff --git a/src/main/resources/static/community/gruppe.html b/src/main/resources/static/community/gruppe.html new file mode 100644 index 0000000..7e7fcec --- /dev/null +++ b/src/main/resources/static/community/gruppe.html @@ -0,0 +1,1097 @@ + + + + + + + Gruppe – xXx Sphere + + + + + +
                +
                + +
                +
                👥
                +
                +

                Wird geladen…

                +

                +
                +
                +
                + +
                + + + +
                + + +
                + + +
                + +
                + +
                +
                + + +
                +
                  +
                  + + +
                  +
                  +

                  Gruppe bearbeiten

                  +
                  + + + + + + + + +
                  + + +
                  + + +
                  +
                  + +
                  +

                  Beitrittsanfragen

                  +
                  +

                  Keine ausstehenden Anfragen.

                  +
                  +
                  + +
                  +

                  Gemeldete Beiträge

                  +
                  +

                  Keine Meldungen.

                  +
                  +
                  + +
                  +

                  Gruppe löschen

                  + +
                  +
                  +
                  +
                  + + +
                  +
                  +

                  Gruppe verlassen

                  +

                  Möchtest du diese Gruppe wirklich verlassen?

                  +
                  + + +
                  +
                  +
                  + + +
                  +
                  +

                  Gruppe löschen

                  +

                  Diese Aktion kann nicht rückgängig gemacht werden. Alle Beiträge und Mitgliedschaften werden gelöscht.

                  +
                  + + +
                  +
                  +
                  + + +
                  +
                  +

                  +

                  +
                  +
                  +
                  + + + + + + + + + + + diff --git a/src/main/resources/static/community/gruppen.html b/src/main/resources/static/community/gruppen.html new file mode 100644 index 0000000..cf30f28 --- /dev/null +++ b/src/main/resources/static/community/gruppen.html @@ -0,0 +1,411 @@ + + + + + + + Gruppen – xXx Sphere + + + + + +
                  +
                  +
                  +

                  Gruppen

                  + +
                  + +
                  + + + +
                  + + +
                  +
                  + +
                  + + +
                  +
                  + + +
                  +
                  +

                  Gib einen Suchbegriff ein.

                  +
                  + + +
                  +
                    + +
                    +
                    +
                    + + +
                    +
                    +

                    Gruppe erstellen

                    + + + + + + + + +
                    + + +
                    + +
                    + + +
                    +
                    +
                    + + +
                    +
                    +

                    Beitrittsanfrage senden

                    +

                    + + + +
                    + + +
                    +
                    +
                    + + + + + + + diff --git a/src/main/resources/static/community/nachrichten.html b/src/main/resources/static/community/nachrichten.html new file mode 100644 index 0000000..4f5f054 --- /dev/null +++ b/src/main/resources/static/community/nachrichten.html @@ -0,0 +1,671 @@ + + + + + + + Nachrichten – xXx Sphere + + + + + + +
                    +
                    + +
                    +
                    Nachrichten
                    +
                      +
                    • Wird geladen…
                    • +
                    +
                    + + +
                    +
                    + + + Konversation auswählen +
                    +
                    +
                    Wähle eine Konversation aus oder schreibe jemanden direkt an.
                    +
                    + +
                    +
                    +
                    + + + + + + + + + diff --git a/src/main/resources/static/community/personen-suchen.html b/src/main/resources/static/community/personen-suchen.html new file mode 100644 index 0000000..e18b89e --- /dev/null +++ b/src/main/resources/static/community/personen-suchen.html @@ -0,0 +1,206 @@ + + + + + + + Personen suchen – xXx Sphere + + + + + + +
                    +
                    +

                    Personen suchen

                    + + + +
                      +

                      Gib mindestens 2 Zeichen ein, um zu suchen.

                      +
                      +
                      + + + + + + + diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css new file mode 100644 index 0000000..d968589 --- /dev/null +++ b/src/main/resources/static/css/style.css @@ -0,0 +1,967 @@ +/* ── Reset ── */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Base ── */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--color-bg); + font-family: 'Segoe UI', sans-serif; + color: var(--color-text); + gap: 2rem; +} + +h1 { + color: var(--color-primary); +} + +p { + color: var(--color-muted); + font-size: 1rem; +} + +/* ── Card ── */ +.card { + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + padding: 2.5rem; + width: 100%; + max-width: 380px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + gap: 0; +} + +.card h1 { + text-align: center; + font-size: 1.6rem; + margin-bottom: 0.25rem; +} + +.subtitle { + text-align: center; + font-size: 0.85rem; + color: var(--color-muted); + margin-bottom: 2rem; +} + +/* ── Form elements ── */ +label { + display: block; + font-size: 0.8rem; + color: #aaa; + margin-bottom: 0.3rem; + margin-top: 1rem; +} + +input { + width: 100%; + padding: 0.65rem 0.9rem; + border: 1px solid var(--color-secondary); + border-radius: 6px; + background: var(--color-secondary); + color: var(--color-text); + font-size: 1rem; + outline: none; + transition: border-color 0.2s; +} + +input:focus { + border-color: var(--color-primary); +} + +/* ── Buttons ── */ +button, .btn { + display: inline-block; + padding: 0.75rem 2.5rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: 6px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.2s; +} + +button:hover:not(:disabled), .btn:hover { + background: #c73652; +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button.full-width { + width: 100%; + margin-top: 1.75rem; + padding: 0.75rem; +} + +button.secondary { + background: var(--color-secondary); + font-weight: normal; + padding: 0.3rem 0.7rem; + font-size: 0.75rem; + width: auto; + margin-top: 0.5rem; +} + +button.secondary:hover { + background: #1a4a8a; +} + +/* ── Messages ── */ +.message { + margin-top: 1.25rem; + padding: 0.65rem 0.9rem; + border-radius: 6px; + font-size: 0.85rem; + display: none; + word-break: break-all; +} + +.message.error { + background: #3d0f1a; + border: 2px solid var(--color-primary); + color: var(--color-primary); +} + +.message.warning { + background: #3a2c0a; + border: 2px solid #f5c518; + color: #f5c518; +} + +.message.success { + background: #0f3d1a; + border: 1px solid var(--color-success); + color: var(--color-success); +} + +/* ── App layout ── */ +body.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + background: var(--color-bg); + padding: 1.5rem 1.5rem 0; + gap: 0; +} + +.app-wrapper { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + gap: 1.5rem; + align-items: stretch; + padding-bottom: 1.5rem; + width: 100%; + max-width: calc(240px + 1.5rem + 93.75rem); + margin-left: auto; + margin-right: auto; +} + +.main { + flex: 1; + min-width: 0; + overflow-y: auto; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; +} + +.content { padding: 2rem 1.5rem; flex: 1; } + +/* ── Sidebar ── */ +.sidebar-wrapper { + width: 240px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 0.75rem; + z-index: 10; + transition: transform 0.25s ease; +} + +.sidebar { + flex: 1; + min-height: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.sidebar-scroll-area { + flex: 1; + overflow-y: auto; +} + +.sidebar-logo-area { + padding: 1rem 1rem 0.5rem; + flex-shrink: 0; +} + +.sidebar-logo-area a { display: block; line-height: 0; } + +.sidebar-logo-area img { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.sidebar-desktop-profile { flex-shrink: 0; padding: 0.25rem 0; } + +.sidebar-desktop-profile a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.9rem; + font-weight: 600; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.sidebar-desktop-profile a:hover { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.sidebar-desktop-profile .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.social-sidebar-logo-area { + padding: 1rem 1rem 0.5rem; + flex-shrink: 0; +} + +.social-sidebar-logo-area img { + width: 100%; + height: auto; + object-fit: contain; + display: block; +} + +.sidebar-mobile-only { display: none; } + +.sidebar ul { list-style: none; padding: 0.5rem 0; } + +.sidebar ul li a, +.sidebar-footer ul li a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.95rem; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.sidebar ul li a:hover, +.sidebar ul li a.active, +.sidebar-footer ul li a:hover, +.sidebar-footer ul li a.active { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.sidebar ul li a .icon, +.sidebar-footer ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.sidebar-profile-img { + width: 1.4rem; + height: 1.4rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--color-secondary); +} + +/* ── Burger (mobile only) ── */ +.burger { + display: none; + position: fixed; + top: 0.75rem; right: 0.75rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 6px; + cursor: pointer; + color: var(--color-text); + padding: 0.35rem 0.5rem; + z-index: 110; + transition: background 0.15s; +} + +.burger:hover { background: var(--color-secondary); } + +.burger-icon { display: flex; flex-direction: column; gap: 5px; width: 22px; } + +.burger-icon span { + display: block; + height: 2px; + background: var(--color-text); + border-radius: 2px; + transition: transform 0.25s, opacity 0.25s; +} + +.burger.open .burger-icon span:nth-child(1) { transform: translateY(7px) rotate(45deg); } +.burger.open .burger-icon span:nth-child(2) { opacity: 0; } +.burger.open .burger-icon span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } + +/* ── Sidebar overlay ── */ +.sidebar-overlay { + display: none; + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 90; +} + +.sidebar-overlay.visible { display: block; } + +/* ── Mobile ── */ +@media (max-width: 768px) { + body.app { + height: auto; + min-height: 100vh; + overflow: visible; + padding: 0; + } + + .app-wrapper { + flex-direction: column; + gap: 0; + padding-bottom: 0; + overflow: visible; + } + + .sidebar-wrapper { + position: fixed; + top: 0; right: 0; + width: 240px; + height: 100vh; + gap: 0; + background: var(--color-bg); + border-left: 1px solid var(--color-secondary); + transform: translateX(100%); + align-self: auto; + z-index: 100; + padding: 0; + overflow-y: auto; + } + + .sidebar-wrapper.open { transform: translateX(0); box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5); } + + .sidebar { + flex: none; + border-radius: 0; + border: none; + box-shadow: none; + border-bottom: 1px solid var(--color-secondary); + } + + .sidebar-footer { + border-radius: 0; + box-shadow: none; + border: none; + border-top: 1px solid var(--color-secondary); + } + + .sidebar-logo-area { display: none; } + .sidebar-desktop-profile { display: none; } + + .main { + border-radius: 0; + box-shadow: none; + border: none; + min-height: 100vh; + overflow-y: visible; + } + + .burger { display: flex; } + .sidebar-mobile-only { display: block; } +} + +/* ── Social Sidebar ── */ +.social-sidebar { + width: 260px; + flex-shrink: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-self: flex-start; + position: sticky; + top: 1.5rem; + max-height: calc(100vh - 3rem); + overflow-y: auto; +} + +.social-sidebar ul { list-style: none; padding: 0.5rem 0; display: flex; flex-direction: column; flex: 1; } + +.social-sidebar ul li a { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1.25rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.95rem; + border-left: 3px solid transparent; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.social-sidebar ul li a:hover, +.social-sidebar ul li a.active { + background: var(--color-secondary); + color: var(--color-primary); + border-left-color: var(--color-primary); +} + +.social-sidebar ul li a .icon { font-size: 1rem; width: 1.2rem; text-align: center; flex-shrink: 0; } + +.social-sidebar-title { + padding: 1rem 1.25rem 0.25rem; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--color-muted); + text-transform: uppercase; + flex-shrink: 0; +} + +.social-badge { + margin-left: auto; + background: var(--color-primary); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.1rem 0.4rem; + line-height: 1.4; + flex-shrink: 0; +} + +@media (max-width: 768px) { + .social-sidebar { + position: static; + width: 100%; + max-height: none; + border-radius: 0; + border: none; + border-top: 1px solid var(--color-secondary); + box-shadow: none; + align-self: auto; + } +} + +/* ── Token box ── */ +.token-box { + margin-top: 1.25rem; + padding: 0.65rem 0.9rem; + background: #0f1e3d; + border: 1px solid var(--color-secondary); + border-radius: 6px; + font-size: 0.75rem; + color: #aaa; + word-break: break-all; + display: none; +} + +.token-box span { + display: block; + font-size: 0.7rem; + color: #666; + margin-bottom: 0.4rem; +} + +/* ── Sidebar groups ── */ +.sidebar-footer { + flex-shrink: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + padding: 0.5rem 0; +} +.sidebar-footer ul { + list-style: none; + padding: 0; +} + +.sidebar-group-toggle { + cursor: pointer; + justify-content: space-between; +} + +.sidebar-arrow { + margin-left: auto; + font-size: 0.7rem; + flex-shrink: 0; + transition: transform 0.2s; +} + +.sidebar-group.open > a .sidebar-arrow { + transform: rotate(90deg); +} + +.sidebar-sub { + list-style: none; + padding: 0; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.sidebar-group.open > .sidebar-sub { + max-height: 500px; +} + +.sidebar .sidebar-sub li a { + padding: 0.55rem 1.25rem 0.55rem 2.5rem; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* ═══════════════════════════════════════════ + TOP BAR + ═══════════════════════════════════════════ */ +.topbar { + flex-shrink: 0; + width: 100%; + max-width: calc(240px + 1.5rem + 93.75rem); + margin: 0 auto 1rem; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1rem; +} + +/* Linker Bereich – Banner, gleiche Breite wie Sidebar */ +.topbar-left { + width: 240px; + flex-shrink: 0; + align-self: stretch; + margin: -0.55rem 0 -0.55rem -1rem; + overflow: hidden; + border-radius: 11px 0 0 11px; + display: flex; + align-items: center; + padding: 5px 0 0 5px; + justify-content: center; +} +.topbar-left a { + display: flex; + align-items: center; + height: 100%; +} +.topbar-banner { + height: 3.5rem; + width: auto; + display: block; +} + +/* ── Suche ── */ +.topbar-search-wrap { + flex: 1; + max-width: 520px; + margin: 0 auto; + position: relative; +} + +.topbar-search-wrap input { + background: var(--color-secondary); + border: 1px solid transparent; + border-radius: 8px; + padding: 0.46rem 0.9rem 0.46rem 2.2rem; + width: 100%; + font-size: 0.9rem; + transition: border-color 0.2s; +} + +.topbar-search-wrap input:focus { + border-color: var(--color-primary); +} + +.topbar-search-icon { + position: absolute; + left: 0.7rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-muted); + font-size: 0.85rem; + pointer-events: none; + line-height: 1; +} + +.topbar-search-overlay { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + z-index: 600; + max-height: 360px; + overflow-y: auto; + display: none; +} + +.topbar-search-overlay.open { display: block; } + +.topbar-search-hint { + padding: 0.75rem 1rem; + color: var(--color-muted); + font-size: 0.88rem; +} + +.topbar-search-result { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + text-decoration: none; + color: var(--color-text); + transition: background 0.15s; + border-bottom: 1px solid var(--color-secondary); +} + +.topbar-search-result:last-child { border-bottom: none; } +.topbar-search-result:hover { background: var(--color-secondary); } + +.topbar-search-avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.topbar-search-avatar--placeholder { + background: var(--color-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +/* ── Rechter Bereich ── */ +.topbar-right { + display: flex; + align-items: center; + gap: 0.2rem; + flex-shrink: 0; + margin-left: auto; +} + +.topbar-btn { + position: relative; + background: none; + border: none; + padding: 0.4rem 0.55rem; + border-radius: 8px; + cursor: pointer; + font-size: 1.25rem; + color: var(--color-text); + display: flex; + align-items: center; + gap: 0.4rem; + transition: background 0.15s; + width: auto; + font-weight: normal; + line-height: 1; +} + +.topbar-btn:hover { background: var(--color-secondary); } + +.topbar-badge { + position: absolute; + top: 1px; + right: 1px; + background: var(--color-primary); + color: #fff; + font-size: 0.6rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.05rem 0.3rem; + min-width: 1rem; + text-align: center; + line-height: 1.6; + display: none; + pointer-events: none; +} + +.topbar-avatar { + width: 1.9rem; + height: 1.9rem; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--color-secondary); + display: block; +} + +.topbar-avatar-placeholder { + font-size: 1.2rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.topbar-username { + font-size: 0.88rem; + font-weight: 600; + max-width: 130px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.topbar-profile-btn { gap: 0.5rem; padding: 0.3rem 0.55rem; } + +/* ═══════════════════════════════════════════ + TOPBAR PANELS (Overlays) + ═══════════════════════════════════════════ */ +.topbar-panel { + position: fixed; + background: var(--color-card); + border: 1px solid var(--color-secondary); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.65); + z-index: 550; + width: 360px; + max-height: 500px; + display: none; + flex-direction: column; + overflow: hidden; +} + +.topbar-panel.open { display: flex; } + +.topbar-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.7rem 1rem; + font-weight: 700; + font-size: 0.92rem; + border-bottom: 1px solid var(--color-secondary); + flex-shrink: 0; + background: var(--color-card); +} + +.topbar-panel-close { + background: none; + border: none; + color: var(--color-muted); + cursor: pointer; + font-size: 0.85rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; + width: auto; + line-height: 1; + transition: background 0.15s, color 0.15s; +} + +.topbar-panel-close:hover { background: var(--color-secondary); color: var(--color-text); } + +.topbar-panel-body { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.topbar-panel-footer { + border-top: 1px solid var(--color-secondary); + padding: 0.55rem 1rem; + flex-shrink: 0; + text-align: center; + background: var(--color-card); +} + +.topbar-panel-footer a { + color: var(--color-primary); + font-size: 0.85rem; + text-decoration: none; +} + +.topbar-panel-footer a:hover { text-decoration: underline; } + +.topbar-panel-hint { + padding: 0.9rem 1rem; + color: var(--color-muted); + font-size: 0.88rem; +} + +/* Einzel-Eintrag in Panel */ +.topbar-panel-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--color-secondary); + text-decoration: none; + color: var(--color-text); + transition: background 0.15s; + cursor: pointer; +} + +.topbar-panel-item:last-child { border-bottom: none; } +.topbar-panel-item:hover { background: var(--color-secondary); } + +.topbar-item-avatar { + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.topbar-item-avatar--placeholder { + background: var(--color-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; +} + +.topbar-panel-item-body { + flex: 1; + min-width: 0; +} + +.topbar-panel-item-sub { + font-size: 0.75rem; + color: var(--color-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.1rem; +} + +.topbar-item-badge { + background: var(--color-primary); + color: #fff; + font-size: 0.65rem; + font-weight: 700; + border-radius: 9999px; + padding: 0.1rem 0.4rem; + flex-shrink: 0; +} + +/* Benachrichtigungen */ +.topbar-notif-item--unread { background: rgba(var(--color-primary-rgb, 231,57,84), 0.07); border-left: 3px solid var(--color-primary); } +.topbar-notif-item--unread:hover { background: rgba(var(--color-primary-rgb, 231,57,84), 0.12); } +.topbar-notif-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-primary); + flex-shrink: 0; + margin-top: 0.25rem; + align-self: flex-start; +} + +.topbar-mark-all-btn { + background: none; + border: none; + color: var(--color-primary); + font-size: 0.8rem; + cursor: pointer; + width: auto; + padding: 0.2rem 0.4rem; + border-radius: 4px; + transition: background 0.15s; +} + +.topbar-mark-all-btn:hover { background: var(--color-secondary); } + +/* Einladungen */ +.topbar-inv-card { align-items: center; } + +.topbar-inv-btn { + padding: 0.3rem 0.6rem; + font-size: 0.78rem; + border-radius: 6px; + border: none; + cursor: pointer; + width: auto; + margin: 0; + text-decoration: none; + display: inline-block; + font-weight: 600; + line-height: 1.5; + transition: opacity 0.15s; +} + +.topbar-inv-btn:hover { opacity: 0.85; } +.topbar-inv-btn--decline { background: #c0392b; color: #fff; } +.topbar-inv-btn--accept { background: var(--color-success, #27ae60); color: #fff; } + +/* Profil-Panel */ +.topbar-profile-body { display: flex; flex-direction: column; } + +.topbar-profile-card { + display: flex; + align-items: center; + gap: 0.85rem; + padding: 1rem 1rem 0.75rem; +} + +.topbar-profile-nav { + display: flex; + flex-direction: column; + padding: 0.4rem 0; +} + +.topbar-profile-link { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 1rem; + color: var(--color-text); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s; +} + +.topbar-profile-link:hover { background: var(--color-secondary); } +.topbar-profile-link--danger { color: var(--color-primary); } + +/* ── Mobile: Topbar ausblenden ── */ +@media (max-width: 768px) { + .topbar { display: none; } +} diff --git a/src/main/resources/static/forgot-password.html b/src/main/resources/static/forgot-password.html new file mode 100644 index 0000000..d833d9f --- /dev/null +++ b/src/main/resources/static/forgot-password.html @@ -0,0 +1,113 @@ + + + + + + + Passwort vergessen – xXx Sphere + + + + + +
                      + Logo +

                      Passwort vergessen

                      +

                      Gib deine E-Mail-Adresse ein. Falls sie bei uns registriert ist, erhältst du einen Link zum Zurücksetzen.

                      + + + + + + +
                      + +

                      + Zurück zum Login +

                      +
                      + +
                      + +
                      + + + + diff --git a/src/main/resources/static/games/bdsm/bdsm-einladung.html b/src/main/resources/static/games/bdsm/bdsm-einladung.html new file mode 100644 index 0000000..f181f06 --- /dev/null +++ b/src/main/resources/static/games/bdsm/bdsm-einladung.html @@ -0,0 +1,128 @@ + + + + + + + BDSM Game – Einladung – xXx Sphere + + + + + +
                      +
                      +
                      Einladung wird geladen…
                      + +
                      +
                      + + + + + diff --git a/src/main/resources/static/games/bdsm/bdsmingame.html b/src/main/resources/static/games/bdsm/bdsmingame.html new file mode 100644 index 0000000..be6866e --- /dev/null +++ b/src/main/resources/static/games/bdsm/bdsmingame.html @@ -0,0 +1,1295 @@ + + + + + + + BDSM Game – Im Spiel – xXx Sphere + + + + + + + + + +
                      +
                      + + + + + +
                      +
                      Aufgabe wird geladen…
                      + +
                      + + +
                      + + +
                      +
                      Wird geladen…
                      +
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/bdsm/bdsmplayers.html b/src/main/resources/static/games/bdsm/bdsmplayers.html new file mode 100644 index 0000000..72717e6 --- /dev/null +++ b/src/main/resources/static/games/bdsm/bdsmplayers.html @@ -0,0 +1,11 @@ + + + + + + BDSM Game – xXx Sphere + + + + + diff --git a/src/main/resources/static/games/bdsm/infobdsm.html b/src/main/resources/static/games/bdsm/infobdsm.html new file mode 100644 index 0000000..3b0bcf4 --- /dev/null +++ b/src/main/resources/static/games/bdsm/infobdsm.html @@ -0,0 +1,21 @@ + + + + + + + BDSM Game – Info – xXx Sphere + + + + +
                      +
                      +

                      BDSM Game

                      +

                      Informationen zum BDSM Game folgen hier.

                      +
                      +
                      + + + + diff --git a/src/main/resources/static/games/bdsm/neubdsm.html b/src/main/resources/static/games/bdsm/neubdsm.html new file mode 100644 index 0000000..c30fed5 --- /dev/null +++ b/src/main/resources/static/games/bdsm/neubdsm.html @@ -0,0 +1,1485 @@ + + + + + + + BDSM Game – Session einrichten – xXx Sphere + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/activelock.html b/src/main/resources/static/games/chastity/activelock.html new file mode 100644 index 0000000..9f15bc1 --- /dev/null +++ b/src/main/resources/static/games/chastity/activelock.html @@ -0,0 +1,1749 @@ + + + + + + + Chastity Game – xXx Sphere + + + + + +
                      +
                      + +

                      🔒 Chastity Session

                      + + + + + + + + + + + + + + + + + + + + +
                      + Wird geladen… +
                      + +
                      + +
                      +
                      + + + + + +
                      +
                      +
                      🚿
                      +

                      Hygiene-Öffnung

                      + + +
                      +

                      Dein aktueller Entsperrcode:

                      +
                      +
                      +
                      Verbleibende Zeit
                      +
                      +
                      + +
                      + + + +
                      +
                      + + +
                      +
                      +
                      🔓
                      +

                      Lock geöffnet

                      + + +
                      +

                      Dein aktueller Entsperrcode:

                      +
                      + +
                      + + + +
                      +
                      + + +
                      +
                      +
                      +
                      +
                      + Karte +
                      +
                      + +
                      +
                      +
                      + +
                      +

                      +

                      +
                      + + + + +
                      +

                      + Du hast die grüne Karte gezogen!
                      Möchtest du den Entsperrcode erhalten und die Session beenden,
                      oder die Karte zurücklegen? +

                      +
                      +
                      + + +
                      +
                      + + +
                      +
                      +
                      +
                      TTLock-Kommunikation läuft…
                      +
                      Bitte warten, der TTLock-Server wird kontaktiert.
                      +
                      +
                      + + +
                      +
                      +

                      Lock beenden?

                      +

                      Dein Entsperrcode:

                      +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +

                      🆘 Notfall-Entsperrung

                      +
                      +
                      + + +
                      +
                      +
                      + + + + + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/activetimelock.html b/src/main/resources/static/games/chastity/activetimelock.html new file mode 100644 index 0000000..2fd94af --- /dev/null +++ b/src/main/resources/static/games/chastity/activetimelock.html @@ -0,0 +1,1382 @@ + + + + + + + TimeLock – xXx Sphere + + + + + +
                      +
                      + +

                      ⏱ TimeLock Session

                      + + + + + + + + +
                      +
                      Verbleibende Zeit
                      +
                      + + +
                      + + + + + + + + + + + + + +
                      Wird geladen…
                      + +
                      + +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      + + +
                      +
                      +
                      🚿
                      +

                      Hygiene-Öffnung

                      + +
                      +

                      Dein aktueller Entsperrcode:

                      +
                      +
                      +
                      Verbleibende Zeit
                      +
                      +
                      + +
                      + + + + +
                      +
                      + + + + + +
                      +
                      +
                      🔓
                      +

                      Lock entsperrt

                      +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +

                      🔓 Lock beenden?

                      +

                      Dein Entsperrcode:

                      +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +

                      🆘 Notfall-Entsperrung

                      +
                      +
                      + + +
                      +
                      +
                      + + + + + + + + diff --git a/src/main/resources/static/games/chastity/communityvotes.html b/src/main/resources/static/games/chastity/communityvotes.html new file mode 100644 index 0000000..8662519 --- /dev/null +++ b/src/main/resources/static/games/chastity/communityvotes.html @@ -0,0 +1,413 @@ + + + + + + + Community Votes – xXx Sphere + + + + + +
                      +
                      + +
                      Community Votes
                      +
                      Verifikationen, Aufgaben-Abstimmungen & Pranger
                      + +
                      + + +
                      + +
                      +
                      + + + + + + + diff --git a/src/main/resources/static/games/chastity/entdecken-vorlagen.html b/src/main/resources/static/games/chastity/entdecken-vorlagen.html new file mode 100644 index 0000000..854f3f1 --- /dev/null +++ b/src/main/resources/static/games/chastity/entdecken-vorlagen.html @@ -0,0 +1,528 @@ + + + + + + + Vorlagen entdecken – xXx Sphere + + + + + +
                      +
                      + +

                      🔍 Vorlagen entdecken

                      + + + + + +
                      +
                      + + + +
                      +
                      + + +
                      +
                      +
                      +
                      + +
                      +

                      +
                      +
                      +
                      + +
                      + +
                      + + +
                      +
                      + + + + + + + diff --git a/src/main/resources/static/games/chastity/entdecken.html b/src/main/resources/static/games/chastity/entdecken.html new file mode 100644 index 0000000..e119188 --- /dev/null +++ b/src/main/resources/static/games/chastity/entdecken.html @@ -0,0 +1,485 @@ + + + + + + + Entdecken – xXx Sphere + + + + + + +
                      +
                      + +
                      Wird geladen…
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/chastity/infochastity.html b/src/main/resources/static/games/chastity/infochastity.html new file mode 100644 index 0000000..a08b00d --- /dev/null +++ b/src/main/resources/static/games/chastity/infochastity.html @@ -0,0 +1,21 @@ + + + + + + + Chastity Game – Info – xXx Sphere + + + + +
                      +
                      +

                      Chastity Game

                      +

                      Informationen zum Chastity Game folgen hier.

                      +
                      +
                      + + + + diff --git a/src/main/resources/static/games/chastity/joinlock.html b/src/main/resources/static/games/chastity/joinlock.html new file mode 100644 index 0000000..85a600b --- /dev/null +++ b/src/main/resources/static/games/chastity/joinlock.html @@ -0,0 +1,307 @@ + + + + + + + Lock-Einladung – xXx Sphere + + + + + +
                      +
                      + +
                      Lade Einladung…
                      + +
                      +
                      ⚠️
                      +

                      Einladung nicht gefunden

                      +

                      Diese Einladung existiert nicht oder wurde bereits bearbeitet.

                      + Zu meinen Einladungen +
                      + +
                      +
                      🔒
                      +

                      Lock bereits aktiv

                      +

                      Diese Einladung wurde bereits angenommen.

                      +
                      + +
                      +
                      +

                      Einladung abgelehnt

                      +

                      Du hast die Einladung abgelehnt. Der Keyholder wurde benachrichtigt.

                      + Zu meinen Einladungen +
                      + + + +
                      +
                      + + +
                      +
                      +
                      +
                      🔒
                      +

                      Dein Entsperrcode

                      +

                      + Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem. +

                      +
                      + + +
                      +
                      + + + + + + + diff --git a/src/main/resources/static/games/chastity/keyholder-finden.html b/src/main/resources/static/games/chastity/keyholder-finden.html new file mode 100644 index 0000000..fe4d44b --- /dev/null +++ b/src/main/resources/static/games/chastity/keyholder-finden.html @@ -0,0 +1,530 @@ + + + + + + + Keyholder finden – xXx Sphere + + + + + +
                      +
                      +

                      🔍 Keyholder finden

                      +

                      + Hier findest du Nutzer*innen, die sich als Keyholder für ein bestimmtes Lock-Template anbieten. + Die beliebtesten Angebote erscheinen ganz oben. +

                      + +
                      + +

                      Wird geladen…

                      +
                      +
                      + + +
                      +
                      + +
                      + +
                      +

                      +
                      +
                      +
                      +
                      + +
                      +
                      + + +
                      +
                      + +

                      🔒 Angebot annehmen

                      +

                      + +
                      +
                      Schloss-Steuerung
                      + +
                      + +
                      +
                      Code-Länge
                      + +
                      + + + +
                      + + +
                      +
                      +
                      + + +
                      +
                      +
                      🔒
                      +

                      +

                      + +
                      + + +
                      +
                      +
                      + + + + + + + diff --git a/src/main/resources/static/games/chastity/keyholder-invitation-confirmed.html b/src/main/resources/static/games/chastity/keyholder-invitation-confirmed.html new file mode 100644 index 0000000..76e7b95 --- /dev/null +++ b/src/main/resources/static/games/chastity/keyholder-invitation-confirmed.html @@ -0,0 +1,45 @@ + + + + + + + Keyholder*In bestätigt – xXx Sphere + + + + +
                      +
                      + + + + + +
                      +
                      + + + diff --git a/src/main/resources/static/games/chastity/keyholder.html b/src/main/resources/static/games/chastity/keyholder.html new file mode 100644 index 0000000..b96da76 --- /dev/null +++ b/src/main/resources/static/games/chastity/keyholder.html @@ -0,0 +1,1923 @@ + + + + + + + Keyholder – xXx Sphere + + + + + +
                      +
                      +

                      Keyholder

                      + + +
                      + + +
                      + + +
                      +
                      + +
                      + + + +
                      +
                      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/meine-locks.html b/src/main/resources/static/games/chastity/meine-locks.html new file mode 100644 index 0000000..137d536 --- /dev/null +++ b/src/main/resources/static/games/chastity/meine-locks.html @@ -0,0 +1,1308 @@ + + + + + + + Meine Vorlagen – xXx Sphere + + + + + +
                      +
                      +
                      +

                      Meine Vorlagen

                      + +
                      + +
                      + +
                      +

                      Abonnierte Vorlagen

                      +
                      + +
                      +
                      +
                      + + + + + + + + +
                      +
                      +
                      + +

                      +

                      + +
                      +
                      + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/neulock.html b/src/main/resources/static/games/chastity/neulock.html new file mode 100644 index 0000000..2ff849c --- /dev/null +++ b/src/main/resources/static/games/chastity/neulock.html @@ -0,0 +1,935 @@ + + + + + + + Neues Lock – xXx Sphere + + + + + +
                      +
                      +

                      🔒 Neues Lock

                      + + +
                      +
                      Vorlage*
                      +
                      +
                      + +
                      + +
                      +
                      +
                      + + +
                      +
                      Personen
                      + +
                      + +
                      + +
                      + +
                      +
                      Wähle dich selbst oder einen Freund als Lockee.
                      +
                      + + + +
                      + +
                      + +
                      + +
                      +
                      Ohne Keyholder läuft das Lock als Self-Lock.
                      +
                      +
                      + + +
                      +
                      Optionen
                      + + +
                      + +
                      +
                      +
                      + + + +
                      + Tage +
                      +
                      :
                      +
                      +
                      + + + +
                      + Std +
                      +
                      +
                      Das Lock öffnet spätestens nach dieser Zeit automatisch. 0 : 00 = keine Begrenzung.
                      +
                      + + + + + +
                      + +
                      + + + +
                      +
                      + +
                      + +
                      + + Ziffern +
                      +
                      + +
                      + + +
                      +
                      + +
                      + +
                      + + +
                      +
                      +
                      + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/sessionchastity.html b/src/main/resources/static/games/chastity/sessionchastity.html new file mode 100644 index 0000000..d726745 --- /dev/null +++ b/src/main/resources/static/games/chastity/sessionchastity.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/main/resources/static/games/chastity/unlock-history.html b/src/main/resources/static/games/chastity/unlock-history.html new file mode 100644 index 0000000..e4e9ca3 --- /dev/null +++ b/src/main/resources/static/games/chastity/unlock-history.html @@ -0,0 +1,90 @@ + + + + + + + Code-Historie – xXx Sphere + + + + + +
                      +
                      +

                      🔙 Entsperrcode-Historie

                      +

                      Die letzten 10 Entsperrcodes, die dir angezeigt wurden.

                      +
                      + Wird geladen… +
                      +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/common/aufgaben.html b/src/main/resources/static/games/common/aufgaben.html new file mode 100644 index 0000000..08cc6b2 --- /dev/null +++ b/src/main/resources/static/games/common/aufgaben.html @@ -0,0 +1,1786 @@ + + + + + + + Aufgaben – xXx Sphere + + + + + + + + + + + + + + + + + + +
                      +
                      + + +
                      +
                      +

                      Meine Aufgabengruppen

                      +
                      + + + + +
                      +
                      +
                      +
                      Wird geladen…
                      +
                      + +
                      + + +
                      +
                      +

                      Abonnierte Aufgabengruppen

                      +
                      + + +
                      +
                      +
                      +
                      Wird geladen…
                      +
                      + +
                      + + +
                      +
                      +

                      System-Aufgabengruppen

                      +
                      + +
                      +
                      +
                      +
                      Wird geladen…
                      +
                      + +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/common/einladungen.html b/src/main/resources/static/games/common/einladungen.html new file mode 100644 index 0000000..52b7707 --- /dev/null +++ b/src/main/resources/static/games/common/einladungen.html @@ -0,0 +1,982 @@ + + + + + + + Einladungen – xXx Sphere + + + + + +
                      +
                      +

                      Einladungen

                      + +
                      + + +
                      + + +
                      +
                      + + +
                      + + +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +
                      + +
                      +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +
                      + +
                      +
                      🎲
                      +
                      +
                      +
                      Vanilla Game – Einladung
                      +
                      +
                      +

                      + Du wurdest zu einem Vanilla Game eingeladen. Wie möchtest du mitspielen? +

                      +
                      +
                      + + + +
                      +
                      +
                      + + +
                      +
                      +
                      + +
                      +
                      ⛓️
                      +
                      +
                      +
                      BDSM Game – Einladung
                      +
                      +
                      +

                      + Du wurdest zu einem BDSM Game eingeladen. Wie möchtest du mitspielen? +

                      +
                      +
                      + + + +
                      +
                      +
                      + + +
                      +
                      +
                      + +
                      +
                      🔒
                      +
                      +
                      +
                      +
                      +
                      +
                      +
                      +
                      +
                      + + + Ziffern +
                      +
                      +
                      +
                      + + +
                      +
                      +
                      + + +
                      +
                      +
                      +
                      🔒
                      +

                      Dein Entsperrcode

                      +

                      + Stelle die Kombination deines Tresors auf den folgenden Code ein und verschließe deinen Schlüssel in diesem. +

                      +
                      + + +
                      +
                      + + + + + + + + diff --git a/src/main/resources/static/games/common/toys.html b/src/main/resources/static/games/common/toys.html new file mode 100644 index 0000000..334c6ea --- /dev/null +++ b/src/main/resources/static/games/common/toys.html @@ -0,0 +1,642 @@ + + + + + + + Toys – xXx Sphere + + + + + + + + + +
                      +
                      + + +
                      +
                      +

                      Meine Toys

                      +
                      + + + +
                      +
                      +
                      +
                      + +
                      +
                      + + +
                      +
                      +

                      System-Toys

                      +
                      + +
                      +
                      +
                      +
                      + +
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/vanilla/infovanilla.html b/src/main/resources/static/games/vanilla/infovanilla.html new file mode 100644 index 0000000..6976334 --- /dev/null +++ b/src/main/resources/static/games/vanilla/infovanilla.html @@ -0,0 +1,21 @@ + + + + + + + Vanilla Game – Info – xXx Sphere + + + + +
                      +
                      +

                      Vanilla Game

                      +

                      Informationen zum Vanilla Game folgen hier.

                      +
                      +
                      + + + + diff --git a/src/main/resources/static/games/vanilla/neuvanilla.html b/src/main/resources/static/games/vanilla/neuvanilla.html new file mode 100644 index 0000000..d37a768 --- /dev/null +++ b/src/main/resources/static/games/vanilla/neuvanilla.html @@ -0,0 +1,1340 @@ + + + + + + + Vanilla Game – Session einrichten – xXx Sphere + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/games/vanilla/sessionvanilla.html b/src/main/resources/static/games/vanilla/sessionvanilla.html new file mode 100644 index 0000000..06b3b36 --- /dev/null +++ b/src/main/resources/static/games/vanilla/sessionvanilla.html @@ -0,0 +1,21 @@ + + + + + + + Vanilla Game – Neue Session – xXx Sphere + + + + +
                      +
                      +

                      Vanilla Game – Neue Session

                      +

                      Session-Setup für das Vanilla Game folgt hier.

                      +
                      +
                      + + + + diff --git a/src/main/resources/static/games/vanilla/vanillaingame.html b/src/main/resources/static/games/vanilla/vanillaingame.html new file mode 100644 index 0000000..77aa53a --- /dev/null +++ b/src/main/resources/static/games/vanilla/vanillaingame.html @@ -0,0 +1,1019 @@ + + + + + + + Vanilla Game – Im Spiel – xXx Sphere + + + + + + + + + +
                      +
                      + + + + + +
                      +
                      Aufgabe wird geladen…
                      + +
                      + + +
                      + + +
                      +
                      Wird geladen…
                      +
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/games/vanilla/vanillawarten.html b/src/main/resources/static/games/vanilla/vanillawarten.html new file mode 100644 index 0000000..afd7d44 --- /dev/null +++ b/src/main/resources/static/games/vanilla/vanillawarten.html @@ -0,0 +1,11 @@ + + + + + + Vanilla Game – xXx Sphere + + + + + diff --git a/src/main/resources/static/help/impressum.html b/src/main/resources/static/help/impressum.html new file mode 100644 index 0000000..546c4c2 --- /dev/null +++ b/src/main/resources/static/help/impressum.html @@ -0,0 +1,108 @@ + + + + + + + Impressum – xXx Sphere + + + + + +
                      +
                      + +
                      +

                      📄 Impressum

                      +

                      Angaben gemäß § 5 TMG

                      +
                      + +
                      +

                      Verantwortlich

                      +
                      + Vorname Nachname
                      + Musterstraße 1
                      + 12345 Musterstadt
                      + Deutschland +
                      +
                      + +
                      +

                      Kontakt

                      +

                      + E-Mail: kontakt@xxx-sphere.de +

                      +
                      + +
                      +

                      Hinweis

                      +

                      + xXx Sphere ist ein privat betriebenes Projekt ohne kommerzielle Absicht. + Die Plattform richtet sich ausschließlich an volljährige Personen. +

                      +
                      + +
                      +

                      Haftungsausschluss

                      +

                      + Trotz sorgfältiger inhaltlicher Kontrolle übernehmen wir keine Haftung für die Inhalte externer Links. + Für den Inhalt verlinkter Seiten sind ausschließlich deren Betreiber verantwortlich. +

                      +
                      + +
                      +
                      + + + + diff --git a/src/main/resources/static/help/kontakt.html b/src/main/resources/static/help/kontakt.html new file mode 100644 index 0000000..30a48b6 --- /dev/null +++ b/src/main/resources/static/help/kontakt.html @@ -0,0 +1,265 @@ + + + + + + + Kontakt & Feedback – xXx Sphere + + + + + +
                      +
                      + +
                      +

                      ✉️ Kontakt & Feedback

                      +

                      Hast du Fragen, Ideen oder einen Fehler gefunden? Schreib uns!

                      +
                      + +
                      + Alternativ per E-Mail: Du kannst uns auch direkt schreiben – + kontakt@xxx-sphere.de +
                      + + + +
                      +
                      + + + + + diff --git a/src/main/resources/static/help/overview.html b/src/main/resources/static/help/overview.html new file mode 100644 index 0000000..247dda7 --- /dev/null +++ b/src/main/resources/static/help/overview.html @@ -0,0 +1,221 @@ + + + + + + + Hilfe-Übersicht – xXx Sphere + + + + + +
                      +
                      + +
                      +

                      ❓ Hilfe-Übersicht

                      +

                      Hier findest du Anleitungen und Erklärungen zu allen Bereichen von xXx Sphere.

                      +
                      + + + +
                      +
                      +
                      ⚙️ Allgemeine Einstellungen
                      +
                      Profil, Benachrichtigungen, Datenschutz und weitere Kontoeinstellungen.
                      + +
                      +
                      +
                      🔒 TTLock-Integration
                      +
                      Verbinde deine physische Schlüsselbox mit xXx Sphere für automatische Code-Verwaltung.
                      + +
                      +
                      +
                      💳 Abonnements
                      +
                      Informationen zu Premium-Funktionen und wie du dein Abonnement verwaltest.
                      + +
                      +
                      + + + +
                      +
                      +
                      🔒 Chastity Game
                      +
                      Alles rund um Schlösser, Keyholder, Karten und Aufgaben im Chastity Game.
                      + +
                      +
                      +
                      ⛓️ BDSM Game
                      +
                      Sessions erstellen, Spieler einladen und Aufgaben verwalten.
                      + +
                      +
                      +
                      ⚪ Vanilla Game
                      +
                      Leichtere Spiele ohne strenge Regeln – für den entspannten Einstieg.
                      + +
                      +
                      + + + +
                      +
                      +
                      👥 Gruppen
                      +
                      Gruppen erstellen, beitreten und verwalten.
                      + +
                      +
                      +
                      📰 Feed & Profil
                      +
                      Beiträge teilen, Profile entdecken und die Community kennenlernen.
                      + +
                      +
                      +
                      🏆 Community Votes
                      +
                      Verifikationen bewerten und an Community-Abstimmungen teilnehmen.
                      + +
                      +
                      + + + +
                      +
                      +
                      🔐 Sicherheit & Datenschutz
                      +
                      Wie deine Daten gespeichert werden und welche Sicherheitsmaßnahmen wir treffen.
                      + +
                      +
                      +
                      🐛 Fehler melden
                      +
                      Hast du einen Fehler gefunden oder einen Verbesserungsvorschlag?
                      + +
                      +
                      + +
                      +
                      + + + + diff --git a/src/main/resources/static/help/template.html b/src/main/resources/static/help/template.html new file mode 100644 index 0000000..fa9f0b8 --- /dev/null +++ b/src/main/resources/static/help/template.html @@ -0,0 +1,345 @@ + + + + + + + SEITENTITEL – xXx Sphere + + + + + +
                      +
                      + + +
                      +

                      🔒 SEITENTITEL

                      +

                      Kurze Beschreibung, worum es auf dieser Hilfeseite geht.

                      +
                      + + +
                      +
                      + 📖 Was ist das? + +
                      +
                      +

                      + Hier steht ein einleitender Text. Du kannst mehrere Absätze verwenden, + um das Thema zu erklären. +

                      +

                      + Zweiter Absatz mit weiteren Informationen. Links gehen so: + neues Lock starten. +

                      + + +
                      + Hinweis: Hier steht ein wichtiger, aber freundlicher Hinweis. +
                      +
                      +
                      + + +
                      +
                      + 🚀 So funktioniert es + +
                      +
                      +

                      Führe diese Schritte der Reihe nach aus:

                      +
                        +
                      1. + 1 + Erster Schritt – was der Nutzer hier tun muss. +
                      2. +
                      3. + 2 + Zweiter Schritt – weitere Aktion mit Erklärung. +
                      4. +
                      5. + 3 + Dritter Schritt – Abschluss oder Ergebnis. +
                      6. +
                      + + +
                      + Achtung: Hier steht eine Warnung, z. B. dass eine Aktion nicht rückgängig gemacht werden kann. +
                      +
                      +
                      + + +
                      +
                      + 📋 Übersicht + +
                      +
                      + + + + + + + + + + + + + + + + + + + + + +
                      FunktionBeschreibung
                      Beispiel AErklärung zu Funktion A.
                      Beispiel BErklärung zu Funktion B.
                      Beispiel CErklärung zu Funktion C.
                      +
                      +
                      + + +
                      +
                      + ❓ Häufige Frage 1? + +
                      +
                      +

                      + Antwort auf die erste häufige Frage. Kann auch mehrere Absätze haben. +

                      +
                      + Neutrale Info-Box für ergänzende Details ohne Wertung. +
                      +
                      +
                      + +
                      +
                      + ❓ Häufige Frage 2? + +
                      +
                      +

                      + Antwort auf die zweite häufige Frage. +

                      +
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/help/ttlock.html b/src/main/resources/static/help/ttlock.html new file mode 100644 index 0000000..8a6956f --- /dev/null +++ b/src/main/resources/static/help/ttlock.html @@ -0,0 +1,360 @@ + + + + + + + Hilfe TTLock – xXx Sphere + + + + + +
                      +
                      + + +
                      +

                      🔒 TTLock

                      +

                      Hilfe zur Einrichtung der Kommunikation mit einer TTLock-Schlüsselbox

                      +
                      + + +
                      +
                      + 📖 Was ist das? + +
                      +
                      +

                      + TTLock ist ein weit verbreitetes System für die Verwaltung von smarten Schlössern und Schlüsselboxen. Die Hardware kommuniziert in der Regel via Bluetooth, lässt sich aber über ein G2-Gateway auch aus der Ferne steuern. +

                      +

                      + Für Entwickler und Unternehmen bietet die TTLock Open Platform eine leistungsstarke REST-API. Damit lässt sich die Schlossverwaltung in eigene Anwendungen integrieren. + Diese API verwenden wir, um die Codes deiner Schlüsselbox zu steuern - kein nerviges manuelles Eintragen des generierten Schlüssels mehr. +

                      +

                      + TTLock steht allen Premium-Abonenten zur Verfügung. +

                      +
                      +
                      + + +
                      +
                      + 📋 Voraussetzungen + +
                      +
                      +
                      + Achtung: Für die Verwendung ist zwingend ein G2-Gateway für die Kommunikation von TTLock-Server zu Deiner Schlüsselbox notwendig. +
                      +
                      + Hinweis: Für die Verwendung einer TTLock-Schlüsselbox in Spielen ist zwingend ein Premium-Abonement notwendig. +
                      +
                      +
                      + + +
                      +
                      + 🚀 So funktioniert es + +
                      +
                      +

                      Führe diese Schritte der Reihe nach aus:

                      +
                        +
                      1. + 1 + App-Setup: Verbinde deine Schlüsselbox in der TTLock-App. Wichtig: Für die Fernsteuerung muss ein Gateway (G2) eingerichtet und aktiv sein. +
                      2. +
                      3. + 2 + Fernzugriff aktivieren: Aktiviere die Funktion in den App-Einstellungen. Tipp: Schalte das WLAN an deinem Handy aus und versuche, die Box über mobile Daten zu öffnen. Funktioniert das? Dann ist alles bereit. +
                      4. +
                      5. + 3 + Accounts verknüpfen: Trage deine TTLock-Zugangsdaten unter Einstellungen > TTLock ein. +
                      6. +
                      7. + 4 + Lock-ID hinterlegen: Gib die ID deiner Box an (zu finden unter MAC/ID). Wichtig: Nur den Teil hinter dem Schrägstrich nutzen (z.B. bei 00:11.../123456 nur 123456). +
                      8. +
                      9. + 5 + Verbindung testen: Klicke auf „Verbindung testen“. Erst nach einem grünen Licht ist das System aktiv. +
                      10. +
                      + + +
                      + Hinweis: Wir speichern dein Passwort nicht im Klartext in der Datenbank sondern nur als MD5-Hash. +
                      +
                      +
                      + + +
                      +
                      + ❓ Warum steht dieser Dienst nur für Abonennten zur Verfügung? + +
                      +
                      +

                      + Die Verwendung der API von TTLock ist nur begrenzt kostenlos verwendbar. Ab bestimmten Kontingenten wird die Verwendung für uns kostenpflichtig. +

                      +
                      + Es gilt weiter der Grundsatz, XXX-Sphere soll niemanden reich machen - Die Abonemments dienen dazu die laufenden Kosten (Server, API-Schnittstellen etc.) zu decken. +
                      +
                      +
                      + +
                      +
                      + ❓ Hilfe - ich komme nicht mehr raus...Was machen Sachen? + +
                      +
                      +

                      + Sollte sich der Schlüssel noch in der Box befinden und ihr nicht in einem aktiven Lock sein, besteht die Möglichkeit einen neuen Code für eine Notfallöffnung zu generieren: +

                      + +
                        +
                      1. + 1 + Öffne die Einstellungen +
                      2. +
                      3. + 2 + Navigiere zum Bereich '🔒 TTLock' +
                      4. +
                      5. + 3 + Drücke '🔒 Öffnen' +
                      6. +
                      +

                      + Der temporäre Code zum Öffnen wird euch angezeigt - damit lässt sich die Box dann öffnen. +

                      +
                      +
                      + +
                      +
                      + + + + + + diff --git a/src/main/resources/static/img/banner.png b/src/main/resources/static/img/banner.png new file mode 100644 index 0000000..9f3671b Binary files /dev/null and b/src/main/resources/static/img/banner.png differ diff --git a/src/main/resources/static/img/card.png b/src/main/resources/static/img/card.png new file mode 100644 index 0000000..8bf5d1d Binary files /dev/null and b/src/main/resources/static/img/card.png differ diff --git a/src/main/resources/static/img/card_cum.png b/src/main/resources/static/img/card_cum.png new file mode 100644 index 0000000..25828fa Binary files /dev/null and b/src/main/resources/static/img/card_cum.png differ diff --git a/src/main/resources/static/img/card_cum_caged.png b/src/main/resources/static/img/card_cum_caged.png new file mode 100644 index 0000000..5da6f37 Binary files /dev/null and b/src/main/resources/static/img/card_cum_caged.png differ diff --git a/src/main/resources/static/img/card_doubleup.png b/src/main/resources/static/img/card_doubleup.png new file mode 100644 index 0000000..7772187 Binary files /dev/null and b/src/main/resources/static/img/card_doubleup.png differ diff --git a/src/main/resources/static/img/card_freeze.png b/src/main/resources/static/img/card_freeze.png new file mode 100644 index 0000000..a046939 Binary files /dev/null and b/src/main/resources/static/img/card_freeze.png differ diff --git a/src/main/resources/static/img/card_green.png b/src/main/resources/static/img/card_green.png new file mode 100644 index 0000000..d4f980a Binary files /dev/null and b/src/main/resources/static/img/card_green.png differ diff --git a/src/main/resources/static/img/card_old.png b/src/main/resources/static/img/card_old.png new file mode 100644 index 0000000..b3e1068 Binary files /dev/null and b/src/main/resources/static/img/card_old.png differ diff --git a/src/main/resources/static/img/card_red.png b/src/main/resources/static/img/card_red.png new file mode 100644 index 0000000..5e09a93 Binary files /dev/null and b/src/main/resources/static/img/card_red.png differ diff --git a/src/main/resources/static/img/card_reset.png b/src/main/resources/static/img/card_reset.png new file mode 100644 index 0000000..6cd9642 Binary files /dev/null and b/src/main/resources/static/img/card_reset.png differ diff --git a/src/main/resources/static/img/card_task.png b/src/main/resources/static/img/card_task.png new file mode 100644 index 0000000..022fc94 Binary files /dev/null and b/src/main/resources/static/img/card_task.png differ diff --git a/src/main/resources/static/img/card_yellow.png b/src/main/resources/static/img/card_yellow.png new file mode 100644 index 0000000..ca6e293 Binary files /dev/null and b/src/main/resources/static/img/card_yellow.png differ diff --git a/src/main/resources/static/img/icon.png b/src/main/resources/static/img/icon.png new file mode 100644 index 0000000..da7b7fd Binary files /dev/null and b/src/main/resources/static/img/icon.png differ diff --git a/src/main/resources/static/img/logo.png b/src/main/resources/static/img/logo.png new file mode 100644 index 0000000..ff1aafe Binary files /dev/null and b/src/main/resources/static/img/logo.png differ diff --git a/src/main/resources/static/img/logo_community.png b/src/main/resources/static/img/logo_community.png new file mode 100644 index 0000000..7d0d122 Binary files /dev/null and b/src/main/resources/static/img/logo_community.png differ diff --git a/src/main/resources/static/img/lvl1.png b/src/main/resources/static/img/lvl1.png new file mode 100644 index 0000000..3bde9b7 Binary files /dev/null and b/src/main/resources/static/img/lvl1.png differ diff --git a/src/main/resources/static/img/lvl2.png b/src/main/resources/static/img/lvl2.png new file mode 100644 index 0000000..bcf3a9d Binary files /dev/null and b/src/main/resources/static/img/lvl2.png differ diff --git a/src/main/resources/static/img/lvl3.png b/src/main/resources/static/img/lvl3.png new file mode 100644 index 0000000..c9a3e29 Binary files /dev/null and b/src/main/resources/static/img/lvl3.png differ diff --git a/src/main/resources/static/img/lvl4.png b/src/main/resources/static/img/lvl4.png new file mode 100644 index 0000000..88ba411 Binary files /dev/null and b/src/main/resources/static/img/lvl4.png differ diff --git a/src/main/resources/static/img/lvl5.png b/src/main/resources/static/img/lvl5.png new file mode 100644 index 0000000..72e16e4 Binary files /dev/null and b/src/main/resources/static/img/lvl5.png differ diff --git a/src/main/resources/static/img/vorlieben/dunno.png b/src/main/resources/static/img/vorlieben/dunno.png new file mode 100644 index 0000000..8cdf8fb Binary files /dev/null and b/src/main/resources/static/img/vorlieben/dunno.png differ diff --git a/src/main/resources/static/img/vorlieben/negative.png b/src/main/resources/static/img/vorlieben/negative.png new file mode 100644 index 0000000..7478998 Binary files /dev/null and b/src/main/resources/static/img/vorlieben/negative.png differ diff --git a/src/main/resources/static/img/vorlieben/neutral.png b/src/main/resources/static/img/vorlieben/neutral.png new file mode 100644 index 0000000..64381f2 Binary files /dev/null and b/src/main/resources/static/img/vorlieben/neutral.png differ diff --git a/src/main/resources/static/img/vorlieben/positiv.png b/src/main/resources/static/img/vorlieben/positiv.png new file mode 100644 index 0000000..172c0d8 Binary files /dev/null and b/src/main/resources/static/img/vorlieben/positiv.png differ diff --git a/src/main/resources/static/img/vorlieben/verynegative.png b/src/main/resources/static/img/vorlieben/verynegative.png new file mode 100644 index 0000000..7cfd217 Binary files /dev/null and b/src/main/resources/static/img/vorlieben/verynegative.png differ diff --git a/src/main/resources/static/img/vorlieben/verypositiv.png b/src/main/resources/static/img/vorlieben/verypositiv.png new file mode 100644 index 0000000..8fda4f4 Binary files /dev/null and b/src/main/resources/static/img/vorlieben/verypositiv.png differ diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..60bd6de --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,35 @@ + + + + + + xXx Sphere + + + + + + Icon +

                      Kinky Games und Communities

                      + + +
                      + + + + diff --git a/src/main/resources/static/js/card-defs.js b/src/main/resources/static/js/card-defs.js new file mode 100644 index 0000000..765fc0f --- /dev/null +++ b/src/main/resources/static/js/card-defs.js @@ -0,0 +1,86 @@ +/** + * Zentrale Kartendefinitionen für das Chastity Game. + * + * Exportiert (global): + * CARD_DEFS – Array mit { id, img, name, desc, defMin, defMax } + * CARD_LABELS – Object { ID: { name, img, desc } } (Lookup für card-display.js u.a.) + */ +const CARD_DEFS = [ + { + id: 'RED', + img: '/img/card_red.png', + name: 'Rote Karte', + desc: 'Niete - Viel Erfolg beim nächsten Zug', + defMin: 5, + defMax: 10, + }, + { + id: 'GREEN', + img: '/img/card_green.png', + name: 'Grüne Karte', + desc: 'Öffnet das Lock. Kann wieder ins Deck zurück gelegt werden', + defMin: 1, + defMax: 2, + }, + { + id: 'YELLOW', + img: '/img/card_yellow.png', + name: 'Gelbe Karte', + desc: 'Per Zufall werden rote Karten entfernt oder hinzugefügt', + defMin: 1, + defMax: 2, + }, + { + id: 'TASK', + img: '/img/card_task.png', + name: 'Aufgabe', + desc: 'Keyholder*In, Community oder der Zufall teilt eine Aufgabe zu.', + defMin: 0, + defMax: 0, + }, + { + id: 'FREEZE', + img: '/img/card_freeze.png', + name: 'Freeze', + desc: 'Friert das Lock für eine festgelegte Zeit ein – in diesem Zeitraum können keine Karten gezogen werden.', + defMin: 0, + defMax: 0, + }, + { + id: 'RESET', + img: '/img/card_reset.png', + name: 'Reset', + desc: 'Setzt das Kartendeck auf den Ausgangszustand zurück. Alle bisher gezogenen Karten kommen wieder rein.', + defMin: 0, + defMax: 0, + }, + { + id: 'DOUBLE_UP', + img: '/img/card_doubleup.png', + name: 'Double Up', + desc: 'Verdoppelt alle noch im Deck vorhandenen Karten.', + defMin: 0, + defMax: 0, + }, + { + id: 'CUM', + img: '/img/card_cum.png', + name: 'Cum', + desc: 'Du wirst entsperrt, nutze diese Entsperrung um zu kommen. Je länger du brauchst, desto schlimmer.', + defMin: 0, + defMax: 0, + }, + { + id: 'CUM_IN_CAGE', + img: '/img/card_cum_caged.png', + name: 'Cum in Cage', + desc: 'Komme in deinem Keuschheitsgürtel, wie du es anstellst ist deine Sache.', + defMin: 0, + defMax: 0, + }, +]; + +/** Lookup-Objekt für Konsumenten, die nach ID auf Name/Bild/Beschreibung zugreifen. */ +const CARD_LABELS = Object.fromEntries( + CARD_DEFS.map(c => [c.id, { name: c.name, img: c.img, desc: c.desc }]) +); diff --git a/src/main/resources/static/js/card-display.js b/src/main/resources/static/js/card-display.js new file mode 100644 index 0000000..e55ba9e --- /dev/null +++ b/src/main/resources/static/js/card-display.js @@ -0,0 +1,60 @@ +/** + * Gemeinsame Kartenanzeige für Chastity Game. + * Benötigt: /js/card-defs.js (CARD_LABELS muss bereits global verfügbar sein) + * Exportiert: cardTypeGridHtml(cardCounts) + */ +(function () { + const style = document.createElement('style'); + style.textContent = ` + .card-type-grid { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin-top: 0.4rem; + } + .card-type-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + width: calc((100% - 6 * 0.6rem) / 14); + min-width: 28px; + } + .card-type-item img { + width: 100%; + height: auto; + border-radius: 4px; + display: block; + } + .card-type-badge { + font-size: 1rem; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; + } + `; + document.head.appendChild(style); +})(); + +/** + * Gibt HTML für ein Karten-Typ-Raster zurück (ein Bild pro Typ, Anzahl-Badge). + * @param {Object} cardCounts – { RED: 3, GREEN: 1, … } + * @returns {string} HTML-String + */ +function cardTypeGridHtml(cardCounts) { + if (!cardCounts || Object.keys(cardCounts).length === 0) { + return 'Keine Karten mehr im Stapel.'; + } + const items = Object.entries(cardCounts) + .filter(([, n]) => n > 0) + .map(([type, n]) => { + const info = CARD_LABELS[type] || { img: '/img/card.png', name: type }; + return `
                      + ${info.name} + ${n} +
                      `; + }).join(''); + return items + ? `
                      ${items}
                      ` + : 'Keine Karten mehr im Stapel.'; +} diff --git a/src/main/resources/static/js/icons.js b/src/main/resources/static/js/icons.js new file mode 100644 index 0000000..b36b876 --- /dev/null +++ b/src/main/resources/static/js/icons.js @@ -0,0 +1,190 @@ +/** + * Zentrale Icon-Verwaltung – XXX The Game + * + * Typen: + * emoji – Standard-Emoji oder Unicode-Zeichen (value: string) + * symbol – Unicode-Symbol (value: string) + * image – Pfad zu einer Bilddatei (value: string) + * compound – Doppel-Icon: base-Icon + kleines Overlay-Icon (bottom-right) + * Felder: base { type, value }, overlay { type, value } + * base kann emoji, symbol oder image sein. + */ +window.ICONS = { + // ── Navigation / Sidebar ────────────────────────────────────────────── + HOME: { type: 'emoji', value: '🏠' }, + VANILLA: { type: 'emoji', value: '⚪' }, + BDSM: { type: 'emoji', value: '⛓️' }, + CHASTITY: { type: 'emoji', value: '🔒' }, + + // ── Aktionen ────────────────────────────────────────────────────────── + PLAY_NEW: { type: 'emoji', value: '🆕' }, + PLAY_ACTIVE: { type: 'emoji', value: '▶️' }, + ACTIVE_LOCK: { type: 'emoji', value: '▶️' }, + WAITING: { type: 'emoji', value: '⏳' }, + CHECK: { type: 'emoji', value: '✅' }, + DISCOVER: { type: 'emoji', value: '🗺️' }, + ARROW: { type: 'emoji', value: '▶️' }, + REFRESH: { type: 'emoji', value: '🔄' }, // Erneuern / Neu laden + START: { type: 'emoji', value: '🚀' }, // Starten / Los + CELEBRATE: { type: 'emoji', value: '🎉' }, // Erfolg / Abschluss + + // ── UI-Symbole ──────────────────────────────────────────────────────── + CLOSE: { type: 'symbol', value: '✕' }, // Schließen / Ablehnen / Löschen + CONFIRM: { type: 'symbol', value: '✓' }, // Bestätigen / Abschließen / Annehmen + LIKE: { type: 'symbol', value: '♥' }, // Like-Button + AVATAR: { type: 'symbol', value: '◉' }, // Avatar-Platzhalter (kein Bild) + ERROR: { type: 'emoji', value: '❌' }, // Fehlerzustand + TIMER: { type: 'emoji', value: '⏱️' }, // Zeitanzeige / Stoppuhr + LIGHTNING: { type: 'emoji', value: '⚡' }, // Aktion (z. B. Zeit entfernen) + EMOJI_PICKER: { type: 'emoji', value: '😊' }, // Emoji-Picker öffnen + REMOVE: { type: 'symbol', value: '⊗' }, // Eintrag/Spiel entfernen + EDIT: { type: 'symbol', value: '✎' }, // Bearbeiten-Button + TRASH: { type: 'emoji', value: '🗑' }, // Löschen-Button + WARNING: { type: 'emoji', value: '⚠️' }, // Warnung / Hinweis + REPORT: { type: 'symbol', value: '⚑' }, // Melden-Button (Flag) + VISIBILITY: { type: 'emoji', value: '👁' }, // Sichtbar / Details sichtbar + THUMBS_UP: { type: 'emoji', value: '👍' }, // Upvote / Zustimmung + THUMBS_DOWN: { type: 'emoji', value: '👎' }, // Downvote / Ablehnung + ARROW_UP: { type: 'symbol', value: '⬆' }, // Sortierung aufsteigend + ARROW_DOWN: { type: 'symbol', value: '⬇' }, // Sortierung absteigend + NAV_PREV: { type: 'symbol', value: '←' }, // Zurück / Vorheriges Bild + NAV_NEXT: { type: 'symbol', value: '→' }, // Weiter / Nächstes Bild + CAROUSEL_PREV: { type: 'symbol', value: '‹' }, // Karussell zurück + CAROUSEL_NEXT: { type: 'symbol', value: '›' }, // Karussell weiter + TIP: { type: 'emoji', value: '💡' }, // Hinweis / Tipp + DOT_RED: { type: 'emoji', value: '🔴' }, // Status-Indikator rot + COMING_SOON: { type: 'emoji', value: '🚧' }, // In Entwicklung / Demnächst + + // ── Chastity Game ───────────────────────────────────────────────────── + NEW_LOCK: { type: 'emoji', value: '🆕' }, + LOCK: { type: 'emoji', value: '🔒' }, + UNLOCK: { type: 'emoji', value: '🔓' }, // Entsperren + LOCKED_SECURE: { type: 'emoji', value: '🔐' }, // Sicher gesperrt (mit Schlüssel) + KEY: { type: 'emoji', value: '🔑' }, + HISTORY: { type: 'emoji', value: '🔙' }, + VOTES: { type: 'emoji', value: '🗳️' }, + TRUST: { type: 'emoji', value: '🤝' }, // Trust-Lock + EMERGENCY: { type: 'emoji', value: '🆘' }, // Notfall-Entsperrung + HYGIENE: { type: 'emoji', value: '🚿' }, // Hygiene-Öffnung + FROZEN: { type: 'emoji', value: '❄️' }, // Eingefroren (zeitlich) + FROZEN_HARD: { type: 'emoji', value: '🧊' }, // Eingefroren (unlimitiert) + UNFREEZE: { type: 'emoji', value: '🌊' }, // Aufgetaut / Unfreeze + CODE_DIGITS: { type: 'emoji', value: '🔢' }, // Zahlenkombination / PIN-Länge + + // ── CardLock ────────────────────────────────────────────────────────── + CARD: { type: 'emoji', value: '🃏' }, // Karte (standalone) + DICE: { type: 'emoji', value: '🎲' }, // Zufällig / Würfeln + + // ── TimeLock / Spinning Wheel ────────────────────────────────────────── + SPINNING_WHEEL: { type: 'emoji', value: '🎡' }, // Glücksrad drehen + TASK_ACTIVE: { type: 'emoji', value: '🎯' }, // Aktuelle Aufgabe + CLOCK: { type: 'emoji', value: '🕐' }, // Uhr / Zeitpunkt + + // ── Social ──────────────────────────────────────────────────────────── + FEED: { type: 'emoji', value: '📰' }, + SEARCH: { type: 'emoji', value: '🔍' }, + FRIENDS: { type: 'emoji', value: '❤️' }, + MESSAGES: { type: 'emoji', value: '💬' }, + NOTIFICATIONS: { type: 'emoji', value: '🔔' }, + GROUPS: { type: 'emoji', value: '👥' }, + INVITATIONS: { type: 'emoji', value: '✨' }, + SETTINGS: { type: 'emoji', value: '⚙️' }, + LOGOUT: { type: 'emoji', value: '⏏️' }, + PROFILE: { type: 'emoji', value: '👤' }, + HELP: { type: 'emoji', value: '❓' }, + CONTACT: { type: 'emoji', value: '✉️' }, // Kontakt / E-Mail + + // ── Medien / Dateien ────────────────────────────────────────────────── + PHOTO: { type: 'emoji', value: '📷' }, // Foto / Kamera + FILE_UPLOAD: { type: 'emoji', value: '📁' }, // Datei auswählen / Upload + TEMPLATE: { type: 'emoji', value: '📋' }, // Vorlage / Template + DOCUMENT: { type: 'emoji', value: '📄' }, // Dokument / Impressum + GUIDE: { type: 'emoji', value: '📖' }, // Anleitung / Hilfeseite + STATS: { type: 'emoji', value: '📊' }, // Statistik / Umfrage-Ergebnis + PACKAGE: { type: 'emoji', value: '📦' }, // Paket / Einladung + MAILBOX: { type: 'emoji', value: '📬' }, // Posteingang (Admin) + + // ── Abo / Premium ───────────────────────────────────────────────────── + PREMIUM: { type: 'emoji', value: '⭐' }, // Abonnement / Premium + TROPHY: { type: 'emoji', value: '🏆' }, // Auszeichnung / Erfolg + PAYMENT: { type: 'emoji', value: '💳' }, // Zahlung / Abonnement + + // ── TTLock / Technik ────────────────────────────────────────────────── + MOBILE: { type: 'emoji', value: '📱' }, // TTLock-App / Mobilgerät + CONNECTION: { type: 'emoji', value: '🔌' }, // Verbindung / Integration + GAMEPAD: { type: 'emoji', value: '🕹️' }, // Spielsteuerung + SHIELD: { type: 'emoji', value: '🛡️' }, // Sicherheit / Datenschutz + ADMIN_TOOLS: { type: 'emoji', value: '🔧' }, // Admin / Werkzeuge + + // ── Aufgaben / Items ────────────────────────────────────────────────── + TOYS: { type: 'emoji', value: '➰' }, + + // ── Spielhistorie – Spieltypen ──────────────────────────────────────── + GAME_BDSM: { type: 'emoji', value: '⛓️' }, + GAME_VANILLA: { type: 'emoji', value: '❤️' }, + + // Doppel-Icons: großes Basis-Icon + kleines 🔒-Overlay + GAME_CARDLOCK: { + type: 'compound', + base: { type: 'image', value: '/img/card.png' }, + overlay: { type: 'emoji', value: '🔒' } + }, + GAME_TIMELOCK: { + type: 'compound', + base: { type: 'emoji', value: '⏰' }, + overlay: { type: 'emoji', value: '🔒' } + }, + + // ── Spielhistorie – Rollen-Badges ───────────────────────────────────── + ROLE_KEYHOLDER: { type: 'emoji', value: '🔑' }, + ROLE_LOCKEE: { type: 'emoji', value: '🔒' }, +}; + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────────── + +/** Gibt den rohen Wert-String zurück (nur für einfache Icons; '' für compound). */ +window.IC = function(key) { + const icon = window.ICONS[key]; + return (icon && icon.type !== 'compound') ? (icon.value || '') : ''; +}; + +/** + * Gibt ein fertiges HTML-Fragment zurück, das das Icon darstellt. + * + * @param {string} key – Schlüssel aus window.ICONS + * @param {number} [size] – Basisgröße in rem (Standard: 2.7) + * @returns {string} HTML-String + */ +window.IChtml = function(key, size) { + const icon = window.ICONS[key]; + if (!icon) return ''; + return _iconToHtml(icon, size != null ? size : 2.7); +}; + +function _iconToHtml(icon, size) { + switch (icon.type) { + case 'emoji': + case 'symbol': + return `${icon.value}`; + case 'image': + return ``; + case 'compound': { + const baseHtml = _compoundBase(icon.base, size); + const overlayHtml = _compoundOverlay(icon.overlay, size * 0.48); + return `${baseHtml}${overlayHtml}`; + } + default: + return ''; + } +} + +function _compoundBase(base, size) { + if (base.type === 'image') { + return ``; + } + return `${base.value}`; +} + +function _compoundOverlay(overlay, size) { + return `${overlay.value}`; +} diff --git a/src/main/resources/static/js/image-viewer.js b/src/main/resources/static/js/image-viewer.js new file mode 100644 index 0000000..148a4d0 --- /dev/null +++ b/src/main/resources/static/js/image-viewer.js @@ -0,0 +1,237 @@ +// ───────────────────────────────────────────────────────────────────────────── +// image-viewer.js – Universelle Bild-Lightbox +// +// Einbinden: (vorher) +// +// +// Zwei Modi: +// Modus A – Nur Bild (kein Like, keine Kommentare): +// imageViewer.open({ images: [{ src }] }) +// +// Modus B – Galerie mit Like + Kommentare: +// imageViewer.open({ +// images: [{ src, id, likedByMe, likeCount }], +// index: 0, +// showLike: true, +// showComments: true, +// myUserId: '...', +// onLike: async (img) => {} // optional; sonst POST /social/profile-images/{id}/like +// }) +// +// Globale Instanz: window.imageViewer +// ───────────────────────────────────────────────────────────────────────────── + +class ImageViewer { + constructor() { + this._cfg = null; + this._idx = 0; + this.isOpen = false; + this._injectStyles(); + this._injectHTML(); + this._bindEvents(); + } + + // ── Öffentliche API ─────────────────────────────────────────────────────── + + open(cfg) { + this._cfg = cfg; + this._idx = cfg.index || 0; + this.isOpen = true; + + const multi = cfg.images.length > 1; + const showLike = !!cfg.showLike; + const showCom = !!cfg.showComments; + + this._q('ivPrev').style.display = multi ? '' : 'none'; + this._q('ivNext').style.display = multi ? '' : 'none'; + this._q('ivCounter').style.display = multi ? '' : 'none'; + this._q('ivLikeBtn').style.display = showLike ? '' : 'none'; + this._q('ivComments').style.display = showCom ? '' : 'none'; + + this._render(); + this._q('imageViewer').classList.add('open'); + this._updateLayout(); + } + + close() { + this._q('imageViewer').classList.remove('open'); + this.isOpen = false; + this._cfg = null; + } + + /** Kommentare im offenen Viewer neu laden (z.B. nach externem Löschen) */ + reloadComments() { + if (this.isOpen && this._cfg?.showComments) this._loadComments(); + } + + // ── Internes Rendering ──────────────────────────────────────────────────── + + _q(id) { return document.getElementById(id); } + + _render() { + const img = this._cfg.images[this._idx]; + this._q('ivImg').src = img.src; + + const total = this._cfg.images.length; + this._q('ivCounter').textContent = `${this._idx + 1} / ${total}`; + this._q('ivPrev').disabled = this._idx === 0; + this._q('ivNext').disabled = this._idx === total - 1; + + if (this._cfg.showLike) this._syncLike(); + if (this._cfg.showComments) this._loadComments(); + } + + _syncLike() { + const img = this._cfg.images[this._idx]; + const btn = this._q('ivLikeBtn'); + btn.className = 'btn-like' + (img.likedByMe ? ' liked' : ''); + this._q('ivLikeCount').textContent = img.likeCount; + } + + async _loadComments() { + const img = this._cfg.images[this._idx]; + const res = await fetch(`/social/kommentare?targetType=IMAGE&targetId=${img.id}`); + const comments = await res.json(); + const myUserId = this._cfg.myUserId || null; + this._q('ivCommentsList').innerHTML = comments.length === 0 + ? '

                      Noch keine Kommentare.

                      ' + : comments.map(k => renderKommentarHtml(k, 'IMAGE', img.id, { myUserId, showReplies: true })).join(''); + } + + async _postComment() { + const input = this._q('ivCommentInput'); + const text = input.value.trim(); + if (!text) return; + const img = this._cfg.images[this._idx]; + await fetch('/social/kommentare', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'IMAGE', targetId: img.id, text }) + }); + input.value = ''; + await this._loadComments(); + } + + async _toggleLike() { + const img = this._cfg.images[this._idx]; + const onLike = this._cfg.onLike; + img.likedByMe = !img.likedByMe; + img.likeCount += img.likedByMe ? 1 : -1; + this._syncLike(); + try { + if (onLike) await onLike(img); + else await fetch('/social/profile-images/' + img.id + '/like', { method: 'POST' }); + } catch { + img.likedByMe = !img.likedByMe; + img.likeCount += img.likedByMe ? 1 : -1; + this._syncLike(); + } + } + + _prev() { if (this._idx > 0) { this._idx--; this._render(); } } + _next() { if (this._idx < this._cfg.images.length - 1) { this._idx++; this._render(); } } + + _updateLayout() { + const el = this._q('ivLayout'); + if (!el) return; + const bp = parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--breakpoint-mobile').trim()) || 768; + el.classList.toggle('iv-narrow', window.innerWidth <= bp); + } + + // ── CSS + HTML Injection ────────────────────────────────────────────────── + + _injectStyles() { + if (document.getElementById('iv-styles')) return; + const s = document.createElement('style'); + s.id = 'iv-styles'; + s.textContent = ` +#imageViewer{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.88);z-index:500;align-items:center;justify-content:center;padding:2rem} +#imageViewer.open{display:flex} +#ivLayout{display:flex;flex-direction:row;gap:1rem;height:min(78vh,660px);max-width:calc(100vw - 4rem);align-items:stretch} +#ivImageSide{width:660px;flex-shrink:1;min-width:0;display:flex;flex-direction:column} +.iv-image-box{flex:1;position:relative;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;overflow:hidden;display:flex;align-items:center;justify-content:center} +#ivImg{width:100%;height:100%;object-fit:contain;display:block} +.iv-overlay{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.6));border-radius:0 0 12px 12px;padding:2rem 0.75rem 0.6rem;display:flex;align-items:center;justify-content:space-between;gap:0.5rem} +.iv-nav-btn{background:rgba(0,0,0,0.35);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;padding:0.3rem 0.75rem;cursor:pointer;margin:0;width:auto;font-size:1rem;flex-shrink:0;transition:background 0.15s} +.iv-nav-btn:hover{background:rgba(0,0,0,0.65)} +.iv-nav-btn:disabled{opacity:.25;cursor:default} +.iv-overlay-center{display:flex;align-items:center;gap:0.6rem;flex:1;justify-content:center} +#ivCounter{font-size:0.8rem;color:rgba(255,255,255,0.75)} +.iv-close{position:fixed;top:1rem;right:1rem;background:rgba(0,0,0,0.55);border:1px solid rgba(255,255,255,0.2);color:#fff;font-size:1.1rem;width:2.2rem;height:2.2rem;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;margin:0;z-index:502;transition:background 0.15s} +.iv-close:hover{background:rgba(180,30,30,0.8)} +#ivComments{background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;width:280px;flex-shrink:0;display:flex;flex-direction:column;overflow:hidden} +.iv-comments-header{font-size:0.78rem;font-weight:600;color:var(--color-muted);text-transform:uppercase;letter-spacing:0.06em;padding:0.7rem 1rem;border-bottom:1px solid var(--color-secondary);flex-shrink:0} +#ivCommentsList{flex:1;overflow-y:auto;padding:0.65rem 0.75rem;scrollbar-width:thin;scrollbar-color:var(--color-secondary) transparent} +.iv-comment-compose{display:flex;gap:0.4rem;padding:0.65rem 0.75rem;border-top:1px solid var(--color-secondary);flex-shrink:0;align-items:center} +.iv-comment-compose input{flex:1;padding:0.4rem 0.65rem;font-size:0.85rem} +.iv-comment-compose button{width:auto;padding:0.4rem 0.7rem;font-size:0.82rem;white-space:nowrap} +#ivLayout.iv-narrow{flex-direction:column;height:auto;max-height:90vh;overflow-y:auto;width:calc(100vw - 1rem);max-width:calc(100vw - 1rem)} +#ivLayout.iv-narrow #ivImageSide{width:100%;flex-shrink:0} +#ivLayout.iv-narrow .iv-image-box{height:min(45vh,360px);flex:none} +#ivLayout.iv-narrow #ivComments{width:100%;max-height:40vh;flex-shrink:0} +`; + document.head.appendChild(s); + } + + _injectHTML() { + if (document.getElementById('imageViewer')) return; + const div = document.createElement('div'); + div.id = 'imageViewer'; + div.innerHTML = ` + +
                      +
                      +
                      + +
                      + +
                      + + +
                      + +
                      +
                      +
                      +
                      +
                      Kommentare
                      +
                      +
                      + + + +
                      +
                      +
                      `; + document.body.appendChild(div); + } + + _bindEvents() { + const init = () => { + this._q('ivClose').addEventListener('click', () => this.close()); + this._q('imageViewer').addEventListener('click', e => { + if (e.target === this._q('imageViewer')) this.close(); + }); + this._q('ivPrev').addEventListener('click', () => this._prev()); + this._q('ivNext').addEventListener('click', () => this._next()); + this._q('ivLikeBtn').addEventListener('click', () => this._toggleLike()); + this._q('ivCommentSend').addEventListener('click', () => this._postComment()); + this._q('ivCommentInput').addEventListener('keydown', e => { + if (e.key === 'Enter') this._postComment(); + }); + window.addEventListener('resize', () => this._updateLayout()); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); + + document.addEventListener('keydown', e => { + if (!this.isOpen) return; + if (e.key === 'Escape') this.close(); + if (e.key === 'ArrowLeft') this._prev(); + if (e.key === 'ArrowRight') this._next(); + }); + } +} + +window.imageViewer = new ImageViewer(); diff --git a/src/main/resources/static/js/meldung.js b/src/main/resources/static/js/meldung.js new file mode 100644 index 0000000..c5044b5 --- /dev/null +++ b/src/main/resources/static/js/meldung.js @@ -0,0 +1,87 @@ +/** + * Wiederverwendbares Meldungs-Modul. + * Bietet openMeldungDialog(zielTyp, zielId) und renderMeldenBtn(zielTyp, zielId). + */ +(function () { + // Dialog einmalig in den DOM einfügen + if (!document.getElementById('meldungDialog')) { + document.body.insertAdjacentHTML('beforeend', ` +
                      +
                      +

                      Inhalt melden

                      +

                      + +
                      + + +
                      + +
                      +
                      + `); + + document.getElementById('meldungAbbrechen').addEventListener('click', () => closeMeldungDialog()); + document.getElementById('meldungDialog').addEventListener('click', function (e) { + if (e.target === this) closeMeldungDialog(); + }); + } + + let _zielTyp = null, _zielId = null; + + window.openMeldungDialog = function (zielTyp, zielId) { + _zielTyp = zielTyp; + _zielId = zielId; + document.getElementById('meldungGrund').value = ''; + document.getElementById('meldungMsg').style.display = 'none'; + document.getElementById('meldungDialogLabel').textContent = + zielTyp === 'PROFIL' ? 'Profil melden' : 'Post melden'; + document.getElementById('meldungDialog').style.display = 'flex'; + + document.getElementById('meldungSenden').onclick = async function () { + const grund = document.getElementById('meldungGrund').value.trim(); + const r = await fetch('/meldung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ zielTyp: _zielTyp, zielId: _zielId, grund: grund || null }) + }); + const msg = document.getElementById('meldungMsg'); + msg.style.display = 'block'; + if (r.status === 201) { + msg.style.color = 'var(--color-success, #2ecc71)'; + msg.textContent = 'Meldung wurde übermittelt.'; + setTimeout(closeMeldungDialog, 1500); + } else if (r.status === 409) { + msg.style.color = 'var(--color-primary)'; + msg.textContent = 'Du hast diesen Inhalt bereits gemeldet.'; + } else { + msg.style.color = 'var(--color-primary)'; + msg.textContent = 'Fehler beim Senden.'; + } + }; + }; + + window.closeMeldungDialog = function () { + document.getElementById('meldungDialog').style.display = 'none'; + }; + + /** + * Erzeugt einen kleinen "Melden"-Button-HTML-String. + * Verwendung: in innerHTML-Templates, wo onclick genutzt werden kann. + */ + window.renderMeldenBtn = function (zielTyp, zielId) { + return ``; + }; +})(); diff --git a/src/main/resources/static/js/shared.js b/src/main/resources/static/js/shared.js new file mode 100644 index 0000000..9d79b8e --- /dev/null +++ b/src/main/resources/static/js/shared.js @@ -0,0 +1,237 @@ +// ───────────────────────────────────────────────────────────────────────────── +// shared.js – Gemeinsame Helfer & Komponenten +// Einbinden: +// (vor allen Seiten-Skripten, nach CSS-Links) +// ───────────────────────────────────────────────────────────────────────────── + +// ── CSS-Injection (Comment + Carousel) ──────────────────────────────────────── +(function injectSharedStyles() { + if (document.getElementById('shared-styles')) return; + const s = document.createElement('style'); + s.id = 'shared-styles'; + s.textContent = ` +/* ── Karussell ── */ +.post-carousel{position:relative;margin-top:0.5rem} +.car-slide{display:none} +.car-slide.active{display:block} +.car-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);border:none;color:#fff;font-size:2.2rem;width:auto;min-width:2.4rem;height:3.2rem;border-radius:8px;cursor:pointer;z-index:5;display:flex;align-items:center;justify-content:center;padding:0 0.5rem;margin:0;line-height:1} +.car-prev{left:0.3rem} +.car-next{right:0.3rem} +.car-indicator{text-align:center;font-size:0.75rem;color:var(--color-muted);margin-top:0.25rem} + +/* ── Like / Löschen-Buttons ── */ +.btn-like{background:none;border:1px solid rgba(255,255,255,0.15);border-radius:20px;padding:0.2rem 0.65rem;color:var(--color-muted);font-size:0.78rem;cursor:pointer;display:inline-flex;align-items:center;gap:0.3rem;margin:0;width:auto;transition:border-color 0.15s,color 0.15s} +.btn-like:hover,.btn-like.liked{border-color:var(--color-primary);color:var(--color-primary)} +.btn-delete-small{background:none;border:none;color:rgba(200,50,50,0.6);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0} +.btn-delete-small:hover{color:var(--color-primary)} +.btn-text{background:none;border:none;color:var(--color-muted);font-size:0.78rem;cursor:pointer;margin:0;width:auto;padding:0;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.2)} +.btn-text:hover{color:var(--color-text)} + +/* ── Kommentare ── */ +.comment-item{display:flex;gap:0.5rem;margin-bottom:0.5rem} +.comment-avatar{width:28px;height:28px;border-radius:50%;background:var(--color-secondary);display:flex;align-items:center;justify-content:center;font-size:0.75rem;flex-shrink:0;overflow:hidden} +.comment-avatar img{width:100%;height:100%;object-fit:cover} +.comment-body{flex:1;background:rgba(255,255,255,0.04);border-radius:6px;padding:0.5rem 0.65rem} +.comment-author{font-size:0.8rem;font-weight:600;color:var(--color-text)} +.comment-date{font-size:0.72rem;color:var(--color-muted);margin-left:0.4rem} +.comment-text{font-size:0.85rem;color:rgba(255,255,255,0.75);margin-top:0.2rem;line-height:1.45;white-space:pre-wrap;word-break:break-word} +.comment-actions{display:flex;gap:0.4rem;margin-top:0.3rem;align-items:center} +.replies-section{margin-top:0.5rem;padding-left:0.5rem;border-left:2px solid rgba(255,255,255,0.06)} +.comment-write{display:flex;gap:0.4rem;margin-top:0.5rem} +.comment-write input{flex:1;padding:0.4rem 0.75rem;font-size:0.85rem} +.comment-write button{width:auto;padding:0.4rem 0.75rem;font-size:0.82rem;white-space:nowrap} +`; + document.head.appendChild(s); +})(); + +// ── HTML-Escape ──────────────────────────────────────────────────────────────── +function esc(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/\n/g, '
                      '); +} + +// ── Datum-Format ────────────────────────────────────────────────────────────── +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +// ── Emoji-Picker ────────────────────────────────────────────────────────────── +const EMOJIS = ['😊','😂','❤️','😍','🔥','👍','🥰','😎','🤔','😘','💕','🎉','✨','💋','😈','🫦','🍑','🍆','🔞','🥵','😭','😢','😤','🙄','🤦','🤷','🙏','💪','😏','🤩']; +let _emojiTarget = null; + +function toggleEmojiPicker(btn, targetId) { + _emojiTarget = document.getElementById(targetId); + let picker = document.getElementById('sharedEmojiPicker'); + if (!picker) { + picker = document.createElement('div'); + picker.id = 'sharedEmojiPicker'; + picker.style.cssText = 'position:fixed;z-index:9000;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:10px;padding:0.5rem;display:flex;flex-wrap:wrap;gap:0.2rem;max-width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.5);'; + EMOJIS.forEach(em => { + const b = document.createElement('button'); + b.textContent = em; + b.style.cssText = 'background:none;border:none;font-size:1.3rem;cursor:pointer;padding:0.2rem;margin:0;width:auto;line-height:1;'; + b.onclick = e => { e.stopPropagation(); insertEmoji(em); }; + picker.appendChild(b); + }); + document.body.appendChild(picker); + } + if (picker.style.display === 'flex') { picker.style.display = 'none'; return; } + picker.style.display = 'flex'; + requestAnimationFrame(() => { + const rect = btn.getBoundingClientRect(); + const ph = picker.offsetHeight, pw = picker.offsetWidth; + let top = rect.top - ph - 8; + let left = rect.left; + if (top < 8) top = rect.bottom + 8; + if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8; + picker.style.top = top + 'px'; + picker.style.left = left + 'px'; + }); +} + +function insertEmoji(emoji) { + if (!_emojiTarget) return; + const start = _emojiTarget.selectionStart ?? _emojiTarget.value.length; + const end = _emojiTarget.selectionEnd ?? start; + _emojiTarget.value = _emojiTarget.value.slice(0, start) + emoji + _emojiTarget.value.slice(end); + _emojiTarget.selectionStart = _emojiTarget.selectionEnd = start + emoji.length; + _emojiTarget.focus(); +} + +document.addEventListener('click', e => { + const picker = document.getElementById('sharedEmojiPicker'); + if (picker && picker.style.display === 'flex' + && !picker.contains(e.target) + && !e.target.closest('[onclick*="toggleEmojiPicker"]')) { + picker.style.display = 'none'; + } +}); + +// ── Bild-Karussell ──────────────────────────────────────────────────────────── +function bilderCarousel(bilder) { + if (!bilder || bilder.length === 0) return ''; + if (bilder.length === 1) { + return `
                      `; + } + const slides = bilder.map((b, i) => + `
                      ` + ).join(''); + return `
                      + ${slides} + + +
                      1/${bilder.length}
                      +
                      `; +} + +function carNav(btn, dir) { + const car = btn.closest('.post-carousel'); + const slides = Array.from(car.querySelectorAll('.car-slide')); + const cur = slides.findIndex(s => s.classList.contains('active')); + slides[cur].classList.remove('active'); + const next = (cur + dir + slides.length) % slides.length; + slides[next].classList.add('active'); + const ind = car.querySelector('.car-cur'); + if (ind) ind.textContent = next + 1; +} + +// ── Kommentar-Rendering ─────────────────────────────────────────────────────── +// opts: { myUserId, showReplies } +// Seite muss definieren: deleteKommentar(kommentarId, targetType, targetId) +function renderKommentarHtml(k, targetType, targetId, opts) { + const { myUserId = null, showReplies = false } = opts || {}; + const avatarHtml = k.authorPicture + ? `` + : '◉'; + const canDelete = k.authorId === myUserId; + const replyLabel = k.replyCount > 0 ? `Antworten (${k.replyCount})` : 'Antworten'; + return `
                      +
                      ${avatarHtml}
                      +
                      + ${esc(k.authorName)} + ${fmtDate(k.createdAt)} +
                      ${esc(k.text)}
                      +
                      + + ${showReplies ? `` : ''} + ${canDelete ? `` : ''} +
                      + ${showReplies ? `` : ''} +
                      +
                      `; +} + +function renderReplyHtml(r, parentId) { + const avatarHtml = r.authorPicture + ? `` + : '◉'; + const canDelete = typeof window.myUserId !== 'undefined' && r.authorId === window.myUserId; + return `
                      +
                      ${avatarHtml}
                      +
                      + ${esc(r.authorName)} + ${fmtDate(r.createdAt)} +
                      ${esc(r.text)}
                      +
                      + + ${canDelete ? `` : ''} +
                      +
                      +
                      `; +} + +async function toggleKommentarLike(kommentarId) { + await fetch('/social/kommentare/' + kommentarId + '/like', { method: 'POST' }); + const btn = document.getElementById('lk-kom-' + kommentarId); + const lc = document.getElementById('lkc-kom-' + kommentarId); + if (!btn || !lc) return; + const was = btn.classList.contains('liked'); + btn.classList.toggle('liked', !was); + lc.textContent = parseInt(lc.textContent) + (was ? -1 : 1); +} + +async function toggleReplies(kommentarId) { + const section = document.getElementById('replies-' + kommentarId); + if (section.style.display === 'none') { + section.style.display = ''; + await loadReplies(kommentarId); + } else { + section.style.display = 'none'; + } +} + +async function loadReplies(kommentarId) { + const res = await fetch(`/social/kommentare?targetType=KOMMENTAR&targetId=${kommentarId}`); + const replies = await res.json(); + const section = document.getElementById('replies-' + kommentarId); + section.innerHTML = (replies.length === 0 + ? '

                      Noch keine Antworten.

                      ' + : replies.map(r => renderReplyHtml(r, kommentarId)).join('')) + + `
                      + + +
                      `; +} + +async function postReply(kommentarId) { + const input = document.getElementById('ri-' + kommentarId); + const text = input.value.trim(); + if (!text) return; + await fetch('/social/kommentare', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType: 'KOMMENTAR', targetId: kommentarId, text }) + }); + input.value = ''; + await loadReplies(kommentarId); +} + +async function deleteReply(replyId, parentId) { + await fetch('/social/kommentare/' + replyId, { method: 'DELETE' }); + await loadReplies(parentId); +} diff --git a/src/main/resources/static/js/sidebar.js b/src/main/resources/static/js/sidebar.js new file mode 100644 index 0000000..668d233 --- /dev/null +++ b/src/main/resources/static/js/sidebar.js @@ -0,0 +1,254 @@ +(function () { + const path = window.location.pathname; + const I = window.IC || function() { return ''; }; + + const groups = [ + { + label: 'Vanilla Game', + icon: I('VANILLA'), + items: [ + { href: '/games/vanilla/neuvanilla.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navVanillaNeu' }, + { href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navVanillaAktiv' }, + { href: '/games/vanilla/vanillaingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navVanillaImSpiel' }, + { href: '/games/common/aufgaben.html', icon: I('CHECK'), label: 'Aufgaben' }, + { href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' }, + { href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' }, + ] + }, + { + label: 'BDSM Game', + icon: I('BDSM'), + items: [ + { href: '/games/bdsm/neubdsm.html', icon: I('PLAY_NEW'), label: 'Neue Session', id: 'navBdsmNeu' }, + { href: '#', icon: I('WAITING'), label: 'Aktive Session', id: 'navBdsmAktiv' }, + { href: '/games/bdsm/bdsmingame.html', icon: I('PLAY_ACTIVE'), label: 'Im Spiel', id: 'navBdsmImSpiel' }, + { href: '/games/common/aufgaben.html?mode=bdsm', icon: I('CHECK'), label: 'Aufgaben' }, + { href: '/games/common/toys.html', icon: I('TOYS'), label: 'Toys' }, + { href: '/games/chastity/entdecken.html', icon: I('DISCOVER'), label: 'Entdecken' }, + ] + }, + { + label: 'Chastity Game', + icon: I('CHASTITY'), + items: [ + { href: '/games/chastity/neulock.html', icon: I('NEW_LOCK'), label: 'Neues Lock', id: 'navChastityNeu' }, + { href: '#', icon: I('ACTIVE_LOCK'), label: 'Aktives Lock', id: 'navChastityAktiv' }, + { href: '/games/chastity/communityvotes.html', icon: I('VOTES'), label: 'Community Votes' }, + { href: '/games/chastity/meine-locks.html', icon: I('LOCK'), label: 'Meine Vorlagen' }, + { href: '/games/chastity/entdecken-vorlagen.html', icon: I('DISCOVER'), label: 'Entdecken' }, + { href: '/games/chastity/keyholder-finden.html', icon: I('FRIENDS'), label: 'Keyholder finden' }, + { href: '/games/chastity/keyholder.html', icon: I('KEY'), label: 'Keyholder' }, + { href: '/games/chastity/unlock-history.html', icon: I('HISTORY'), label: 'Code-Historie' }, + ] + }, + ]; + + const homeCls = path === '/userhome.html' ? ' class="active"' : ''; + const homeItem = ` + `; + + // ── Community-Links (immer sichtbar, oberhalb der Spiele) ── + const socialLinks = [ + { href: '/community/feed.html', icon: I('FEED'), label: 'Feed', badgeId: null }, + { href: '/community/freunde.html', icon: I('FRIENDS'), label: 'Freunde', badgeId: 'socialFriendsBadge'}, + { href: '/community/nachrichten.html', icon: I('MESSAGES'), label: 'Nachrichten', badgeId: null }, + { href: '/community/benachrichtigungen.html', icon: I('NOTIFICATIONS'), label: 'Benachrichtigungen', badgeId: null }, + { href: '/community/gruppen.html', icon: I('GROUPS'), label: 'Gruppen', badgeId: 'socialGruppenBadge'}, + { href: '/games/common/einladungen.html', icon: I('INVITATIONS'), label: 'Einladungen', badgeId: null }, + ]; + const socialNav = socialLinks.map(({ href, icon, label, badgeId }) => { + const cls = path === href ? ' class="active"' : ''; + const badge = badgeId ? `` : ''; + return `
                    • ${icon} ${label}${badge}
                    • `; + }).join(''); + + const fullHref = path + window.location.search; + const nav = groups.map(({ label, icon, items }) => { + const isOpen = items.some(item => item.href === path || item.href === fullHref); + const openCls = isOpen ? ' open' : ''; + const subItems = items.map(({ href, icon: iIcon, label: iLabel, id: iId }) => { + const cls = (href === path || href === fullHref) ? ' class="active"' : ''; + const idAt = iId ? ` id="${iId}"` : ''; + return `${iIcon} ${iLabel}`; + }).join(''); + return ` + `; + }).join(''); + + const adminCls = path === '/admin/admin.html' ? ' class="active"' : ''; + const adminItem = ``; + + const footerLinks = [ + { href: '/help/kontakt.html', icon: '✉️', label: 'Kontakt & Feedback' }, + { href: '/help/impressum.html', icon: '📄', label: 'Impressum' }, + ]; + const footerNav = footerLinks.map(({ href, icon, label }) => { + const cls = path === href ? ' class="active"' : ''; + return `
                    • ${icon} ${label}
                    • `; + }).join(''); + + document.body.insertAdjacentHTML('afterbegin', ` + + + + `); + + // Sidebar und .main in einen zentrierten App-Wrapper verschieben + const appWrapper = document.createElement('div'); + appWrapper.className = 'app-wrapper'; + const sidebarEl = document.getElementById('sidebar'); + const mainEl = document.querySelector('.main'); + document.body.insertBefore(appWrapper, sidebarEl); + appWrapper.appendChild(sidebarEl); + if (mainEl) appWrapper.appendChild(mainEl); + + // Group toggle + document.querySelectorAll('.sidebar-group-toggle').forEach(toggle => { + toggle.addEventListener('click', e => { + e.preventDefault(); + toggle.closest('.sidebar-group').classList.toggle('open'); + }); + }); + + // "Im Spiel" und "Aktive Session" standardmäßig ausblenden; wird nach Session-Check ggf. wieder eingeblendet + const navNeu = document.getElementById('navBdsmNeu'); + const navAktiv = document.getElementById('navBdsmAktiv'); + const navImSpiel = document.getElementById('navBdsmImSpiel'); + const navCAktiv = document.getElementById('navChastityAktiv'); + const navVNeu = document.getElementById('navVanillaNeu'); + const navVAktiv = document.getElementById('navVanillaAktiv'); + const navVImSpiel = document.getElementById('navVanillaImSpiel'); + if (navAktiv) navAktiv.style.display = 'none'; + if (navImSpiel) navImSpiel.style.display = 'none'; + if (navCAktiv) navCAktiv.style.display = 'none'; + if (navVAktiv) navVAktiv.style.display = 'none'; + if (navVImSpiel) navVImSpiel.style.display = 'none'; + + // Session-Status prüfen + fetch('/login/me') + .then(r => r.ok ? r.json() : null) + .then(async user => { + if (!user) return; + + // BDSM Session-Status + try { + const aktivRes = await fetch('/bdsm/einladung/meine-aktive'); + if (aktivRes.ok) { + const aktiv = await aktivRes.json(); + if (navNeu) navNeu.style.display = 'none'; + if (navImSpiel) navImSpiel.style.display = 'none'; + if (navAktiv) { + navAktiv.style.display = ''; + navAktiv.querySelector('a').href = aktiv.sessionId ? '/games/bdsm/bdsmingame.html' : '/games/bdsm/neubdsm.html'; + } + } else { + const sessionRes = await fetch(`/bdsm?userId=${user.userId}`); + const hasSession = sessionRes.status === 200; + if (navNeu) navNeu.style.display = hasSession ? 'none' : ''; + if (navImSpiel) navImSpiel.style.display = hasSession ? '' : 'none'; + } + } catch (_) {} + + // Vanilla Session-Status + try { + const vAktivRes = await fetch('/vanilla/einladung/meine-aktive'); + if (vAktivRes.ok) { + const vAktiv = await vAktivRes.json(); + if (navVNeu) navVNeu.style.display = 'none'; + if (navVImSpiel) navVImSpiel.style.display = 'none'; + if (navVAktiv) { + navVAktiv.style.display = ''; + navVAktiv.querySelector('a').href = vAktiv.sessionId ? '/games/vanilla/vanillaingame.html' : '/games/vanilla/neuvanilla.html'; + } + } else { + const vSessionRes = await fetch(`/vanilla?userId=${user.userId}`); + const vHasSession = vSessionRes.status === 200; + if (navVNeu) navVNeu.style.display = vHasSession ? 'none' : ''; + if (navVImSpiel) navVImSpiel.style.display = vHasSession ? '' : 'none'; + } + } catch (_) {} + + // Chastity Lock-Status + try { + const lockRes = await fetch('/keyholder/mylock'); + if (lockRes.ok) { + const lockData = await lockRes.json(); + if (navCAktiv) { + navCAktiv.style.display = ''; + navCAktiv.querySelector('a').href = '/games/chastity/activelock.html?lockId=' + lockData.lockId; + } + } + } catch (_) {} + + // Admin-Link + if (user.admin) { + const navAdminLink = document.getElementById('navAdminLink'); + const navAdminDivider = document.getElementById('navAdminDivider'); + if (navAdminLink) navAdminLink.style.display = ''; + if (navAdminDivider) navAdminDivider.style.display = ''; + } + }) + .catch(() => {}); + + const sidebar = document.getElementById('sidebar'); + const burgerBtn = document.getElementById('burgerBtn'); + const overlay = document.getElementById('sidebarOverlay'); + + function openMenu() { + sidebar.classList.add('open'); + overlay.classList.add('visible'); + burgerBtn.classList.add('open'); + burgerBtn.setAttribute('aria-label', 'Menü schließen'); + } + + function closeMenu() { + sidebar.classList.remove('open'); + overlay.classList.remove('visible'); + burgerBtn.classList.remove('open'); + burgerBtn.setAttribute('aria-label', 'Menü öffnen'); + } + + burgerBtn.addEventListener('click', () => + sidebar.classList.contains('open') ? closeMenu() : openMenu() + ); + overlay.addEventListener('click', closeMenu); + sidebar.querySelectorAll('a:not(.sidebar-group-toggle)').forEach(l => + l.addEventListener('click', () => { + if (window.innerWidth <= (parseInt(getComputedStyle(document.documentElement).getPropertyValue('--breakpoint-mobile').trim()) || 768)) + closeMenu(); + }) + ); + + // Topbar und Social-Sidebar nachladen + function loadScript(src) { + const s = document.createElement('script'); + s.src = src; + document.head.appendChild(s); + } + loadScript('/js/topbar.js'); + loadScript('/js/social-sidebar.js'); +})(); diff --git a/src/main/resources/static/js/social-sidebar.js b/src/main/resources/static/js/social-sidebar.js new file mode 100644 index 0000000..12c6246 --- /dev/null +++ b/src/main/resources/static/js/social-sidebar.js @@ -0,0 +1,109 @@ +(function () { + // Badge + SSE service (kein Sidebar-Rendering mehr) + + // ── Badge-Zähler ── + function setBadge(ids, count, topbarType) { + ids.forEach(id => { + if (!id) return; + const el = document.getElementById(id); + if (!el) return; + el.textContent = count; + el.style.display = count > 0 ? '' : 'none'; + }); + if (topbarType && window.__topbarSetBadge) window.__topbarSetBadge(topbarType, count); + } + + // ── Ton abspielen ── + let userHasInteracted = false; + document.addEventListener('click', () => { userHasInteracted = true; }, { passive: true }); + document.addEventListener('keydown', () => { userHasInteracted = true; }, { passive: true }); + document.addEventListener('touchstart', () => { userHasInteracted = true; }, { passive: true }); + + function playSound(src) { + if (!userHasInteracted) return; + try { + const audio = new Audio(src); + audio.volume = 0.6; + audio.play().catch(() => {}); + } catch(e) {} + } + + // ── Initiale Badge-Counts laden ── + fetch('/social/friends/pending/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialFriendsBadge'], n, null)) + .catch(() => {}); + + fetch('/social/messages/unread/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialMsgBadge'], n, 'msg')) + .catch(() => {}); + + fetch('/notifications/unread/count') + .then(r => r.ok ? r.json() : 0) + .then(n => setBadge(['socialNotifBadge'], n, 'notif')) + .catch(() => {}); + + Promise.all([ + fetch('/gruppen/requests/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/gruppen/reports/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([joins, reports]) => setBadge(['socialGruppenBadge'], joins + reports, null)) + .catch(() => {}); + + Promise.all([ + fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([kh, lockee, bdsm, vanilla]) => + setBadge(['socialInvBadge'], kh + lockee + bdsm + vanilla, 'inv') + ).catch(() => {}); + + // ── SSE: Echtzeit-Push vom Server ── + function connectSse() { + const es = new EventSource('/events/stream'); + + es.addEventListener('DM', e => { + try { + const data = JSON.parse(e.data); + setBadge(['socialMsgBadge'], data.unreadCount || 0, 'msg'); + if (window.location.pathname !== '/community/nachrichten.html') { + playSound('/audio/message.mp3'); + } + if (typeof window.__sseOnDm === 'function') window.__sseOnDm(data); + } catch(ex) {} + }); + + es.addEventListener('NOTIFICATION', e => { + try { + const data = JSON.parse(e.data); + setBadge(['socialNotifBadge'], data.unreadCount || 0, 'notif'); + if (window.location.pathname !== '/community/benachrichtigungen.html') { + playSound('/audio/notification.mp3'); + } + if (typeof window.__sseOnNotification === 'function') window.__sseOnNotification(data); + } catch(ex) {} + }); + + es.addEventListener('INVITATION', () => { + try { + if (typeof window.__topbarReloadInvBadge === 'function') window.__topbarReloadInvBadge(); + } catch(ex) {} + }); + + es.onerror = () => { + es.close(); + // Vor dem Reconnect prüfen ob noch eingeloggt (verhindert Endlos-Schleife bei abgelaufener Session) + setTimeout(() => { + fetch('/login/me', { method: 'GET' }) + .then(r => { if (r.ok) connectSse(); }) + .catch(() => {}); + }, 5000); + }; + } + + // SSE nur starten wenn authentifiziert – verhindert Fehler-Spam bei nicht eingeloggten Seiten + fetch('/login/me', { method: 'GET' }) + .then(r => { if (r.ok) connectSse(); }) + .catch(() => {}); +})(); diff --git a/src/main/resources/static/js/topbar.js b/src/main/resources/static/js/topbar.js new file mode 100644 index 0000000..411428e --- /dev/null +++ b/src/main/resources/static/js/topbar.js @@ -0,0 +1,405 @@ +(function () { + if (document.querySelector('.topbar')) return; + + function esc(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ── Warten bis app-wrapper existiert (sidebar.js läuft synchron davor) ── + function init() { + const appWrapper = document.querySelector('.app-wrapper'); + if (!appWrapper) { setTimeout(init, 30); return; } + injectHTML(appWrapper); + loadProfile(); + setupSearch(); + setupOverlayButtons(); + loadInitialBadges(); + } + setTimeout(init, 0); + + // ── HTML Struktur ── + function injectHTML(appWrapper) { + const topbar = document.createElement('div'); + topbar.className = 'topbar'; + topbar.id = 'topbar'; + topbar.innerHTML = ` +
                      + xXx Sphere +
                      +
                      + ${IC('SEARCH')} + +
                      +
                      +
                      + + + + +
                      `; + appWrapper.insertAdjacentElement('beforebegin', topbar); + + // Panel-Overlays am Ende von body einfügen + document.body.insertAdjacentHTML('beforeend', ` +
                      +
                      + ${IC('MESSAGES')} Nachrichten + +
                      +
                      + +
                      + +
                      +
                      + ${IC('NOTIFICATIONS')} Benachrichtigungen + +
                      +
                      + +
                      + +
                      +
                      + ${IC('INVITATIONS')} Einladungen + +
                      +
                      + +
                      + + + `); + } + + function IC(key) { return window.IC ? window.IC(key) : (window.ICONS?.[key]?.value || ''); } + + // ── Profil laden ── + function loadProfile() { + fetch('/login/me') + .then(r => r.ok ? r.json() : null) + .then(user => { + if (!user) return; + const nameEl = document.getElementById('topbarUsername'); + if (nameEl) nameEl.textContent = user.name; + + const avatarWrap = document.getElementById('topbarAvatarWrap'); + if (avatarWrap && user.profilePicture) { + avatarWrap.innerHTML = ``; + } + const panelName = document.getElementById('topbarPanelName'); + if (panelName) panelName.textContent = user.name; + + const panelAvatar = document.getElementById('topbarPanelAvatarWrap'); + if (panelAvatar && user.profilePicture) { + panelAvatar.innerHTML = ``; + } + const profileLink = document.getElementById('topbarProfileLink'); + if (profileLink && user.userId) profileLink.href = '/community/benutzer.html?userId=' + user.userId; + }) + .catch(() => {}); + } + + // ── Suche ── + function setupSearch() { + const input = document.getElementById('topbarSearchInput'); + const overlay = document.getElementById('topbarSearchOverlay'); + if (!input || !overlay) return; + + let timer; + input.addEventListener('input', () => { + clearTimeout(timer); + const q = input.value.trim(); + if (q.length < 2) { overlay.innerHTML = ''; overlay.classList.remove('open'); return; } + overlay.innerHTML = '
                      Suche…
                      '; + overlay.classList.add('open'); + timer = setTimeout(() => doSearch(q, overlay), 300); + }); + + document.addEventListener('click', e => { + if (!e.target.closest('.topbar-search-wrap')) { + overlay.classList.remove('open'); + } + }); + } + + async function doSearch(q, overlay) { + try { + const res = await fetch('/social/users/search?q=' + encodeURIComponent(q)); + if (!res.ok) { overlay.innerHTML = '
                      Fehler bei der Suche.
                      '; return; } + const users = await res.json(); + if (!users || users.length === 0) { + overlay.innerHTML = '
                      Keine Ergebnisse.
                      '; + return; + } + overlay.innerHTML = users.map(u => { + const av = u.profilePicture + ? `` + : `${IC('PROFILE')}`; + return ` + ${av} + ${esc(u.name)} + `; + }).join(''); + } catch (e) { + overlay.innerHTML = '
                      Fehler bei der Suche.
                      '; + } + } + + // ── Panel-Overlays ── + let _activePanel = null; + + function positionPanel(panel, btn) { + const topbar = document.getElementById('topbar'); + const tRect = topbar ? topbar.getBoundingClientRect() : btn.getBoundingClientRect(); + panel.style.top = tRect.bottom + 'px'; + panel.style.right = Math.max(4, window.innerWidth - tRect.right) + 'px'; + panel.style.left = 'auto'; + } + + function openPanel(panelId, btnId, loadFn) { + const panel = document.getElementById(panelId); + const btn = document.getElementById(btnId); + if (!panel || !btn) return; + if (_activePanel === panel && panel.classList.contains('open')) { + closeAllPanels(); return; + } + closeAllPanels(); + positionPanel(panel, btn); + panel.classList.add('open'); + _activePanel = panel; + if (loadFn) loadFn(); + } + + function closeAllPanels() { + document.querySelectorAll('.topbar-panel.open').forEach(p => p.classList.remove('open')); + _activePanel = null; + } + + window.__topbarCloseAll = closeAllPanels; + + document.addEventListener('click', e => { + if (!e.target.closest('.topbar-panel') && !e.target.closest('.topbar-btn')) + closeAllPanels(); + }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAllPanels(); }); + + function setupOverlayButtons() { + const msgBtn = document.getElementById('topbarMsgBtn'); + const notifBtn = document.getElementById('topbarNotifBtn'); + const invBtn = document.getElementById('topbarInvBtn'); + const profileBtn = document.getElementById('topbarProfileBtn'); + if (msgBtn) msgBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarMsgPanel', 'topbarMsgBtn', loadMessages); }); + if (notifBtn) notifBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarNotifPanel', 'topbarNotifBtn', loadNotifications); }); + if (invBtn) invBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarInvPanel', 'topbarInvBtn', loadInvitations); }); + if (profileBtn) profileBtn.addEventListener('click', e => { e.stopPropagation(); openPanel('topbarProfilePanel', 'topbarProfileBtn', null); }); + } + + // ── Nachrichten ── + async function loadMessages() { + const body = document.getElementById('topbarMsgBody'); + if (!body) return; + body.innerHTML = '
                      Wird geladen…
                      '; + try { + const res = await fetch('/social/messages'); + if (!res.ok) { body.innerHTML = '
                      Keine Nachrichten.
                      '; return; } + const convos = await res.json(); + if (!convos.length) { body.innerHTML = '
                      Noch keine Nachrichten.
                      '; return; } + body.innerHTML = convos.slice(0, 7).map(c => { + const av = c.partner?.profilePicture + ? `` + : `${IC('PROFILE')}`; + const bold = c.unreadCount > 0 ? 'font-weight:700;' : ''; + const badge = c.unreadCount > 0 + ? `${c.unreadCount > 99 ? '99+' : c.unreadCount}` : ''; + return ` + ${av} +
                      +
                      ${esc(c.partner?.name || '')}
                      +
                      ${esc(c.lastMessage?.text || '')}
                      +
                      + ${badge} +
                      `; + }).join(''); + } catch (e) { body.innerHTML = '
                      Fehler beim Laden.
                      '; } + } + + // ── Benachrichtigungen ── + async function loadNotifications() { + const body = document.getElementById('topbarNotifBody'); + if (!body) return; + body.innerHTML = '
                      Wird geladen…
                      '; + try { + const res = await fetch('/notifications'); + if (!res.ok) { body.innerHTML = '
                      Keine Benachrichtigungen.
                      '; return; } + const unread = (await res.json()).filter(n => !n.read); + if (!unread.length) { body.innerHTML = '
                      Keine neuen Benachrichtigungen.
                      '; return; } + body.innerHTML = ''; + unread.forEach(n => { + const el = document.createElement('div'); + const tag = n.targetUrl ? 'a' : 'div'; + const href = n.targetUrl ? `href="${esc(n.targetUrl)}"` : ''; + const av = n.senderAvatar + ? `` + : `${IC('PROFILE')}`; + el.innerHTML = `<${tag} ${href} class="topbar-panel-item topbar-notif-item${n.read ? '' : ' topbar-notif-item--unread'}"> + ${av} +
                      +
                      ${esc(n.text)}
                      +
                      ${n.sentAt ? new Date(n.sentAt).toLocaleString('de-DE',{dateStyle:'short',timeStyle:'short'}) : ''}
                      +
                      + `; + body.appendChild(el.firstElementChild); + }); + // Alle als gelesen markieren + fetch('/notifications/read-all', { method: 'POST' }).then(() => setTopbarBadge('notif', 0)).catch(() => {}); + } catch (e) { body.innerHTML = '
                      Fehler beim Laden.
                      '; } + } + + window.__topbarMarkNotifRead = async function (id) { + try { + await fetch('/notifications/' + id + '/read', { method: 'POST' }); + const el = document.querySelector(`.topbar-notif-item--unread[onclick*="${id}"]`); + if (el) el.classList.remove('topbar-notif-item--unread'); + const r = await fetch('/notifications/unread/count'); + if (r.ok) setTopbarBadge('notif', await r.json()); + } catch (e) {} + }; + + window.__topbarMarkAllRead = async function () { + try { + await fetch('/notifications/read-all', { method: 'POST' }); + setTopbarBadge('notif', 0); + loadNotifications(); + } catch (e) {} + }; + + // ── Einladungen ── + async function loadInvitations() { + const body = document.getElementById('topbarInvBody'); + if (!body) return; + body.innerHTML = '
                      Wird geladen…
                      '; + try { + const [lr, kr, br, vr] = await Promise.all([ + fetch('/lockee/invitations/mine'), + fetch('/keyholder/invitations/mine'), + fetch('/bdsm/einladung/pending'), + fetch('/vanilla/einladung/pending') + ]); + const lockee = lr.ok ? await lr.json() : []; + const kh = kr.ok ? await kr.json() : []; + const bdsm = br.ok ? await br.json() : []; + const vanilla = vr.ok ? await vr.json() : []; + const all = [ + ...lockee.map(i => ({ ...i, _type: 'lockee' })), + ...kh.map(i => ({ ...i, _type: 'keyholder' })), + ...bdsm.map(i => ({ ...i, _type: 'bdsm' })), + ...vanilla.map(i => ({ ...i, _type: 'vanilla' })) + ]; + if (!all.length) { body.innerHTML = '
                      Keine offenen Einladungen.
                      '; return; } + body.innerHTML = ''; + all.forEach(inv => body.appendChild(buildInvCard(inv))); + } catch (e) { body.innerHTML = '
                      Fehler beim Laden.
                      '; } + } + + function buildInvCard(inv) { + let typeIcon, typeName, line; + + if (inv._type === 'lockee') { + typeIcon = IC('LOCK'); typeName = 'Lockee-Einladung'; line = inv.lockName || 'Lock'; + } else if (inv._type === 'keyholder') { + typeIcon = IC('KEY'); typeName = 'Keyholder-Einladung'; line = inv.lockName || 'Lock'; + } else if (inv._type === 'vanilla') { + typeIcon = IC('INVITATIONS'); typeName = 'Vanilla Game'; line = inv.inviterName || 'Einladung'; + } else { + typeIcon = IC('BDSM'); typeName = 'BDSM Game'; line = inv.senderName || 'Einladung'; + } + + const senderPic = inv.senderAvatar || inv.lockOwnerAvatar || inv.inviterAvatar; + const av = senderPic + ? `` + : `${IC('PROFILE')}`; + + const div = document.createElement('div'); + div.className = 'topbar-panel-item topbar-inv-card'; + div.style.cursor = 'pointer'; + div.innerHTML = `${av} +
                      +
                      ${typeIcon} ${typeName}
                      +
                      ${esc(line)}
                      +
                      `; + div.addEventListener('click', () => { window.location.href = '/games/common/einladungen.html'; }); + return div; + } + + // ── Badge-Verwaltung ── + function setTopbarBadge(type, count) { + const map = { msg: 'topbarMsgBadge', notif: 'topbarNotifBadge', inv: 'topbarInvBadge' }; + const el = document.getElementById(map[type]); + if (!el) return; + el.textContent = count > 99 ? '99+' : count; + el.style.display = count > 0 ? 'inline-block' : 'none'; + } + + // Für social-sidebar.js zugänglich + window.__topbarSetBadge = setTopbarBadge; + + function reloadInvBadge() { + Promise.all([ + fetch('/lockee/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/keyholder/invitations/mine/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/bdsm/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0), + fetch('/vanilla/einladung/pending/count').then(r => r.ok ? r.json() : 0).catch(() => 0) + ]).then(([l, k, b, v]) => setTopbarBadge('inv', l + k + b + v)).catch(() => {}); + } + window.__topbarReloadInvBadge = reloadInvBadge; + + function loadInitialBadges() { + fetch('/social/messages/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('msg', n)).catch(() => {}); + fetch('/notifications/unread/count').then(r => r.ok ? r.json() : 0).then(n => setTopbarBadge('notif', n)).catch(() => {}); + reloadInvBadge(); + } +})(); diff --git a/src/main/resources/static/konto/einstellungen.html b/src/main/resources/static/konto/einstellungen.html new file mode 100644 index 0000000..d76ce54 --- /dev/null +++ b/src/main/resources/static/konto/einstellungen.html @@ -0,0 +1,1323 @@ + + + + + + + Einstellungen – xXx Sphere + + + + + +
                      +
                      +

                      ⚙️ Einstellungen

                      + + +
                      +
                      + 👤 Grunddaten + +
                      +
                      + +
                      +
                      +
                      Nickname
                      +
                      +
                      + +
                      + +
                      +
                      +
                      E-Mail
                      +
                      +
                      + +
                      + +
                      +
                      +
                      Geburtsdatum
                      +
                      +
                      + +
                      + +
                      +
                      +
                      Konto löschen
                      +
                      Alle Daten werden unwiderruflich gelöscht
                      +
                      + +
                      + +
                      +
                      + + +
                      +
                      + 🕹️ Spiel Einstellungen + +
                      +
                      + + +
                      +
                      BDSM Game
                      + +
                      +
                      Spiele mit Geschlecht
                      +
                      + + + +
                      +
                      + +
                      +
                      Meine Rollen
                      +
                      + + + + +
                      +
                      + +
                      +
                      Was ich einsetze
                      +
                      + + + + + +
                      +
                      +
                      + +
                      +
                      + + +
                      +
                      + 🔔 Benachrichtigungen + +
                      +
                      +
                      + + +
                      +
                      +
                      In-App
                      +
                      E-Mail
                      +
                      + + +
                      +
                      +
                      Einladungen
                      +
                      Einladungen zu Locks und Spielen, Annahmen und Ablehnungen
                      +
                      +
                      + +
                      +
                      + +
                      +
                      + + +
                      +
                      +
                      Spielstatus
                      +
                      Karten, Aufgaben, Verifikationen, Einfrierungen und andere Spielereignisse
                      +
                      +
                      + +
                      +
                      + +
                      +
                      + + +
                      +
                      +
                      Notfall
                      +
                      Notfall-Entsperrungen und dringende Meldungen
                      +
                      +
                      + +
                      +
                      + +
                      +
                      + + +
                      +
                      +
                      Freundschaftsanfragen
                      +
                      Neue Freundschaftsanfragen von anderen Nutzern
                      +
                      +
                      + +
                      +
                      + +
                      +
                      + +
                      +
                      +
                      + + +
                      +
                      + ⭐ Abonnements + +
                      +
                      +
                      +
                      +
                      Aktuelles Abo
                      +
                      Wird geladen…
                      +
                      + +
                      + + +
                      +
                      + + +
                      +
                      + 🛡️ Datenschutz + +
                      +
                      + + +
                      +
                      +
                      Grunddaten
                      +
                      Alter, Größe, Gewicht, Geschlecht, Neigung, Beziehungsstatus, Beschreibung
                      +
                      + +
                      + + +
                      +
                      +
                      Galerie
                      +
                      Fotos auf dem Profil
                      +
                      + +
                      + + +
                      +
                      +
                      Freundesliste
                      +
                      Wer kann sehen, wer deine Freunde sind
                      +
                      + +
                      + + +
                      +
                      +
                      Feed / Posts
                      +
                      Posts auf dem Profil-Tab
                      +
                      + +
                      + + +
                      +
                      +
                      Pinnwand
                      +
                      Einträge auf der Pinnwand
                      +
                      + +
                      + + +
                      +
                      +
                      XP-Punkte
                      +
                      Lockee-XP und Keyholder-XP
                      +
                      + +
                      + + +
                      +
                      +
                      Lock-Historie
                      +
                      Abgeschlossene Locks und Keyholder-Aktivitäten
                      +
                      + +
                      + + +
                      +
                      +
                      Vorlieben
                      +
                      Deine Vorlieben und Bewertungen im Profil
                      +
                      + +
                      + + +
                      +
                      +
                      Profil bei Veröffentlichungen sichtbar
                      +
                      Dein Name wird bei veröffentlichten Lock-Vorlagen angezeigt
                      +
                      + +
                      + +
                      + + +
                      + Profil-Vorschau – wie sieht mein Profil für andere aus? + + +
                      + +
                      +
                      + + +
                      +
                      + 🔒 TTLock + +
                      +
                      +

                      + Verknüpfe deinen TTLock-Account, um deine physische Schlüsselbox direkt über das Spiel zu steuern. +

                      + + + +
                      +
                      Benutzername (E-Mail)
                      + +
                      + +
                      +
                      Passwort
                      + +
                      + +
                      +
                      Lock-ID
                      + +
                      + +
                      + +
                      + Verbindungsstatus: + +
                      + +
                      + + + +
                      + + + + + +
                      + + +
                      +
                      + +
                      +
                      + + + + + + + + + + + + + + + + + + + +
                      ✓ Gespeichert
                      + + + + + + + diff --git a/src/main/resources/static/konto/profile.html b/src/main/resources/static/konto/profile.html new file mode 100644 index 0000000..92369fb --- /dev/null +++ b/src/main/resources/static/konto/profile.html @@ -0,0 +1,766 @@ + + + + + + + Profil – xXx Sphere + + + + + + +
                      +
                      + +
                      +
                      + +
                      + + + +
                      +
                      + + +
                      +
                      + + +
                      +
                      + + +
                      +
                      + + +
                      +
                      + + +
                      +
                      + + +
                      +
                      + + +
                      0 / 600
                      +
                      +
                      + +
                      + + + + +

                      Wähle für jede Vorliebe aus, wie du dazu stehst. Nicht ausgefüllte Einträge werden nicht angezeigt.

                      +

                      Wird geladen…

                      + + + + + +
                      +
                      +
                      + + + + + + + + diff --git a/src/main/resources/static/login.html b/src/main/resources/static/login.html new file mode 100644 index 0000000..7d01738 --- /dev/null +++ b/src/main/resources/static/login.html @@ -0,0 +1,107 @@ + + + + + + + Login – xXx Sphere + + + + +
                      + Logo +

                      Bitte melde dich an

                      + + + + + + + + + +
                      + +

                      + Passwort vergessen? +

                      +

                      + Noch kein Konto? Registrieren +

                      +
                      + + + + diff --git a/src/main/resources/static/registration.html b/src/main/resources/static/registration.html new file mode 100644 index 0000000..3e9293e --- /dev/null +++ b/src/main/resources/static/registration.html @@ -0,0 +1,130 @@ + + + + + + + Neues Konto erstellen – xXx Sphere + + + + +
                      + Logo +

                      Neues Konto erstellen

                      + + + + + + + + + + + + + + + + + + +
                      + +

                      + Bereits registriert? Anmelden +

                      +
                      + + + + diff --git a/src/main/resources/static/reset-password.html b/src/main/resources/static/reset-password.html new file mode 100644 index 0000000..01f5b49 --- /dev/null +++ b/src/main/resources/static/reset-password.html @@ -0,0 +1,142 @@ + + + + + + + Neues Passwort – xXx Sphere + + + + + +
                      + Logo +

                      Neues Passwort

                      +

                      Gib dein neues Passwort ein.

                      + + + + + + + + + +
                      +
                      + +
                      + +
                      + + + + diff --git a/src/main/resources/static/userhome.html b/src/main/resources/static/userhome.html new file mode 100644 index 0000000..e3f067b --- /dev/null +++ b/src/main/resources/static/userhome.html @@ -0,0 +1,87 @@ + + + + + + + Home – xXx Sphere + + + + + +
                      +
                      +

                      Home

                      +

                      + +
                      +
                      +
                      +

                      Vanilla Game

                      +

                      + Entdecke spielerische Rollenspiele und Aufgaben in einem entspannten Rahmen. + Ideal für den Einstieg – ohne Regeln, nur Spaß zu zweit oder in der Gruppe. +

                      + +
                      + +
                      +
                      +

                      BDSM Game

                      +

                      + Tauche ein in strukturierte Sessions mit Aufgaben, Toys und klaren Rollen. + Definiere Grenzen, vergib Aufgaben und erlebe intensive Momente mit deinem Partner. +

                      + +
                      + +
                      +
                      +

                      Chastity Game

                      +

                      + Erlebe Keuschheit auf eine neue Art: Kartenbasierte Locks, Keyholder-System, + Community-Abstimmungen und tägliche Verifizierungen machen jedes Lock einzigartig. +

                      + +
                      +
                      +
                      +
                      + + + + + + diff --git a/src/main/resources/xxx.jks b/src/main/resources/xxx.jks new file mode 100644 index 0000000..2d75a54 Binary files /dev/null and b/src/main/resources/xxx.jks differ diff --git a/src/test/java/de/oaa/xxx/XxxThegameApplicationTests.java b/src/test/java/de/oaa/xxx/XxxThegameApplicationTests.java new file mode 100644 index 0000000..f489e3a --- /dev/null +++ b/src/test/java/de/oaa/xxx/XxxThegameApplicationTests.java @@ -0,0 +1,14 @@ +package de.oaa.xxx; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class XxxThegameApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.java b/src/test/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.java new file mode 100644 index 0000000..227aa62 --- /dev/null +++ b/src/test/java/de/oaa/xxx/games/chastity/timelock/TimeLockTemplateServiceTest.java @@ -0,0 +1,150 @@ +package de.oaa.xxx.games.chastity.timelock; + +import de.oaa.xxx.games.chastity.spinningwheel.EntryType; +import de.oaa.xxx.games.chastity.spinningwheel.SpinningWheelEntry; +import de.oaa.xxx.games.chastity.tasks.Task; +import de.oaa.xxx.games.chastity.tasks.TaskMode; +import de.oaa.xxx.util.ValidationResult; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimeLockTemplateServiceTest { + + private final TimeLockTemplateService service = new TimeLockTemplateService(null); + + /** + * Zentrale Factory-Methode für den Test. + * Erzeugt immer ein frisches Record-Objekt. + */ + private TimeLockTemplate createTemplate( + UUID owner, String name, Integer maxTime, + Integer taskEvery, Integer minTasks, List tasks, + Integer spinsEvery, Integer minSpins, List entries, + Integer hygEvery, Integer hygDuration) { + return createTemplate(owner, name, maxTime, taskEvery, minTasks, tasks, spinsEvery, minSpins, entries, hygEvery, hygDuration, TaskMode.RANDOM); + } + + private TimeLockTemplate createTemplate( + UUID owner, String name, Integer maxTime, + Integer taskEvery, Integer minTasks, List tasks, + Integer spinsEvery, Integer minSpins, List entries, + Integer hygEvery, Integer hygDuration, TaskMode taskMode) { + + return new TimeLockTemplate(null, + owner, + name, + 0, // minTimeInMinutes + maxTime, + true, // endTimeVisible + hygDuration, + hygEvery, + tasks, + taskEvery, + minTasks, + entries, + spinsEvery, + minSpins, + false, // requiresVerification + taskMode, + null, // penaltyType + null // penaltyValue + ); + } + + // Hilfsmethode für ein schnelles Standard-Template + private TimeLockTemplate validBase() { + return createTemplate(UUID.randomUUID(), "Standard", 60, null, null, null, null, null, null, null, null); + } + + @Test + void validate_OK_Minimal() { + assertThat(service.validate(validBase())).isEqualTo(ValidationResult.OK); + } + + @Test + void validate_ERROR_MissingMandatoryFields() { + assertThat(service.validate(createTemplate(null, "Name", 10, null, null, null, null, null, null, null, null))).isEqualTo(ValidationResult.ERROR); + assertThat(service.validate(createTemplate(UUID.randomUUID(), null, 10, null, null, null, null, null, null, null, null))).isEqualTo(ValidationResult.ERROR); + assertThat(service.validate(createTemplate(UUID.randomUUID(), "Name", null, null, null, null, null, null, null, null, null))).isEqualTo(ValidationResult.ERROR); + } + + @Test + void validate_ERROR_TasksButNoList() { + // Intervall gesetzt, aber Liste ist null oder leer + TimeLockTemplate tNull = createTemplate(UUID.randomUUID(), "T", 60, 10, null, null, null, null, null, null, null); + TimeLockTemplate tEmpty = createTemplate(UUID.randomUUID(), "T", 60, 10, null, Collections.emptyList(), null, null, null, null, null); + + assertThat(service.validate(tNull)).isEqualTo(ValidationResult.ERROR); + assertThat(service.validate(tEmpty)).isEqualTo(ValidationResult.ERROR); + } + + @Test + void validate_ERROR_SpinningWheelTaskConflict() { + SpinningWheelEntry taskEntry = new SpinningWheelEntry(); + taskEntry.setType(EntryType.TASK); + + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, 10, null, List.of(new Task()), null, null, List.of(taskEntry), null, null); + + assertThat(service.validate(t)).isEqualTo(ValidationResult.ERROR); + } + + @Test + void validate_WARNING_TaskTimeLimitReached() { + // 120 Min Intervall * 7 Tasks = 840 Min (> 12h) + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, 120, 7, List.of(new Task()), null, null, null, null, null); + + assertThat(service.validate(t)).isEqualTo(ValidationResult.WARNING); + } + + @Test + void validate_ERROR_MinTasksWithoutInterval() { + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, null, 5, null, null, null, null, null, null); + assertThat(service.validate(t)).isEqualTo(ValidationResult.ERROR); + } + + @Test + void validate_ERROR_SpinsButNoEntries() { + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, null, null, null, 30, 2, null, null, null); + assertThat(service.validate(t)).isEqualTo(ValidationResult.ERROR); + } + + @Test + void validate_WARNING_SpinTimeLimitReached() { + // 200 Min Intervall * 4 Spins = 800 Min (> 12h) + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, null, null, null, 200, 4, List.of(new SpinningWheelEntry()), null, null); + + assertThat(service.validate(t)).isEqualTo(ValidationResult.WARNING); + } + + @Test + void validate_ERROR_HygieneIncomplete() { + // Intervall gesetzt, aber Dauer fehlt + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, null, null, null, null, null, null, 60, null); + assertThat(service.validate(t)).isEqualTo(ValidationResult.ERROR); + } + + @Test + public void validate_communityVotes() { + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, 60, 6, List.of(new Task()), null, null, null, null, null,TaskMode.RANDOM); + assertThat(service.validate(t)).isEqualTo(ValidationResult.OK); + + t = createTemplate(UUID.randomUUID(), "T", 60, 60, 7, List.of(new Task()), null, null, null, null, null,TaskMode.COMMUNITY); + assertThat(service.validate(t)).isEqualTo(ValidationResult.WARNING); + + t = createTemplate(UUID.randomUUID(), "T", 60, 60, 12, List.of(new Task()), null, null, null, null, null,TaskMode.COMMUNITY); + assertThat(service.validate(t)).isEqualTo(ValidationResult.WARNING); + } + + public void testErrorOnTasksAndTaskInWheel() { + SpinningWheelEntry entry = Mockito.mock(SpinningWheelEntry.class); + Mockito.when(entry.getType()).thenReturn(EntryType.TASK); + TimeLockTemplate t = createTemplate(UUID.randomUUID(), "T", 60, 60, 6, List.of(new Task()), null, null, List.of(), null, null); + assertThat(service.validate(t)).isEqualTo(ValidationResult.ERROR); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..ebacde7 --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.properties.hibernate.type.preferred_uuid_jdbc_type=VARCHAR + +jwt.keystore.path=classpath:xxx.jks +jwt.keystore.password=XUR!Rv&f$j3UsqD& +jwt.keystore.alias=xxx + +spring.mail.host=localhost +spring.mail.port=25