Volle Mindflux-App ins Deploy-Repo übernehmen

Ersetzt das Hello-World-Gerüst durch die vollständige Anwendung
(com.example.mindflux: 64 Klassen, Thymeleaf-Templates, Static-Assets,
Flyway V1–V6). Deploy-Infrastruktur (.gitea, Dockerfile) bleibt erhalten.

- pom: volle Abhängigkeiten (Security, Thymeleaf, JPA, Flyway), Spring Boot 3.4.4 / Java 17
- docker-compose: SPRING_DATASOURCE_* unverändert; App lauscht auf 8080
  (nginx -> mindflux:8080); Uploads-Volume; Admin-Passwörter via Gitea-Secrets
- Workflow: Deploy-Step injiziert MINDFLUX_ADMIN*_PASSWORD aus Secrets
- .dockerignore ergänzt; Hello-World (com.mindflux) und Test-Datei 'main' entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AliceDarkest
2026-06-26 14:51:30 +02:00
parent f3309c1574
commit 9022961762
185 changed files with 22772 additions and 142 deletions
@@ -0,0 +1,12 @@
package com.example.mindflux;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class AppStartCheck implements CommandLineRunner {
@Override
public void run(String... args) {
System.out.println("✅ App läuft. Sicherheitsmodul sollte aktiv sein.");
}
}
@@ -1,10 +1,11 @@
package com.mindflux;
package com.example.mindflux;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MindfluxApplication {
public static void main(String[] args) {
SpringApplication.run(MindfluxApplication.class, args);
}
@@ -0,0 +1,60 @@
package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@Configuration
public class AdminSetup {
@Value("${MINDFLUX_ADMIN_USERNAME:admin}")
private String adminUsername;
@Value("${MINDFLUX_ADMIN_EMAIL:admin@mindflux.de}")
private String adminEmail;
@Value("${MINDFLUX_ADMIN_PASSWORD:}")
private String adminPassword;
// Optionaler zweiter Bootstrap-Admin. Wird nur angelegt, wenn Username + Passwort gesetzt sind.
@Value("${MINDFLUX_ADMIN2_USERNAME:}")
private String admin2Username;
@Value("${MINDFLUX_ADMIN2_EMAIL:}")
private String admin2Email;
@Value("${MINDFLUX_ADMIN2_PASSWORD:}")
private String admin2Password;
@Bean
public CommandLineRunner createAdmin(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder) {
return args -> {
createAdminIfAbsent(userRepo, passwordEncoder, adminUsername, adminEmail, adminPassword);
createAdminIfAbsent(userRepo, passwordEncoder, admin2Username, admin2Email, admin2Password);
};
}
private void createAdminIfAbsent(UserRepository userRepo, BCryptPasswordEncoder passwordEncoder,
String username, String email, String password) {
if (username == null || username.isBlank()) {
return; // Admin-Slot nicht konfiguriert -> still überspringen.
}
if (password == null || password.isBlank()) {
System.out.println("Kein Bootstrap-Admin erstellt: Passwort für '" + username + "' ist nicht gesetzt.");
return;
}
if (userRepo.findByUsername(username).isEmpty()) {
User admin = new User();
admin.setUsername(username);
admin.setEmail(email);
admin.setPassword(passwordEncoder.encode(password));
admin.setRole("ADMIN");
userRepo.save(admin);
System.out.println("Bootstrap-Admin erstellt: " + username);
}
}
}
@@ -0,0 +1,65 @@
package com.example.mindflux.config;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/css/**", "/js/**", "/img/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // NEU: Adminbereich absichern
.requestMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/") // login.html liegt auf "/"
.loginProcessingUrl("/login")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/?error") // zeigt Loginfehler an
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/?logout")
.permitAll()
)
.csrf(csrf -> csrf.disable()); // optional deaktivieren bei Custom-Logik/API-Nutzung
return http.build();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
return username -> {
User user = userRepo.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Benutzer nicht gefunden"));
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // Achtung: Nur ein String, z.B. "USER" oder "ADMIN"
.build();
};
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
@@ -0,0 +1,127 @@
package com.example.mindflux.config;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
/**
* Befüllt TechTacular einmalig (wenn leer) aus der kuratierten Inhaltsdatei
* techtacular/content.json. Format: Jeopardy umgekehrt – pro Hinweis eine Aussage
* ("clue") und der gesuchte Begriff ("solution", = richtige Antwort) plus Distraktoren.
*/
@Component
public class TechtacularSeeder implements CommandLineRunner {
private final TechtacularCategoryRepository categoryRepository;
private final ObjectMapper objectMapper;
public TechtacularSeeder(TechtacularCategoryRepository categoryRepository, ObjectMapper objectMapper) {
this.categoryRepository = categoryRepository;
this.objectMapper = objectMapper;
}
@Override
@Transactional
public void run(String... args) {
if (categoryRepository.count() > 0) {
return; // schon befüllt
}
try {
JsonNode root = objectMapper.readTree(new ClassPathResource("techtacular/content.json").getInputStream());
JsonNode categories = root.get("categories");
if (categories == null || !categories.isArray()) {
System.out.println("⚠️ TechTacular-Seed: content.json ohne 'categories'.");
return;
}
Set<String> usedSlugs = new HashSet<>();
int order = 1;
int catCount = 0;
int clueCount = 0;
for (JsonNode catNode : categories) {
String title = catNode.path("title").asText("");
JsonNode clues = catNode.get("clues");
if (title.isBlank() || clues == null || !clues.isArray()) {
continue;
}
TechtacularCategory category =
new TechtacularCategory(uniqueSlug(title, usedSlugs), trim(title, 200), order, true);
for (JsonNode clue : clues) {
String clueText = clue.path("clue").asText("");
String solution = clue.path("solution").asText("");
JsonNode distractors = clue.get("distractors");
if (clueText.isBlank() || solution.isBlank() || distractors == null || !distractors.isArray()) {
continue;
}
int points = clue.path("points").asInt(100);
String explanation = clue.hasNonNull("explanation") ? clue.get("explanation").asText() : null;
TechtacularQuestion question = new TechtacularQuestion(
trim(clueText, 1000),
(explanation == null || explanation.isBlank()) ? null : trim(explanation, 1000),
points);
int sort = 0;
question.addAnswer(new TechtacularAnswer(trim(solution, 500), true, sort++)); // richtige Antwort
for (JsonNode d : distractors) {
String dt = d.asText("");
if (!dt.isBlank()) {
question.addAnswer(new TechtacularAnswer(trim(dt, 500), false, sort++));
}
}
if (question.getAnswers().size() >= 2) {
category.addQuestion(question);
clueCount++;
}
}
if (!category.getQuestions().isEmpty()) {
categoryRepository.save(category);
order++;
catCount++;
}
}
System.out.println("✅ TechTacular: " + catCount + " Kategorien / " + clueCount + " Hinweise geladen.");
} catch (Exception e) {
System.out.println("⚠️ TechTacular-Seed übersprungen: " + e.getMessage());
}
}
private String uniqueSlug(String title, Set<String> used) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "kategorie";
}
String slug = base;
int counter = 2;
while (used.contains(slug) || categoryRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
used.add(slug);
return slug;
}
private String trim(String value, int max) {
if (value == null) {
return null;
}
return value.length() <= max ? value : value.substring(0, max);
}
}
@@ -0,0 +1,16 @@
package com.example.mindflux.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + System.getProperty("user.dir") + "/uploads/");
}
}
@@ -0,0 +1,38 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.service.AchievementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class AchievementController {
@Autowired
private AchievementService achievementService;
@GetMapping("/profile/achievements")
public String showAchievements(Model model, Principal principal) {
String username = principal.getName();
List<Achievement> achievements = achievementService.getAchievementsForUser(username);
// 🔥 Dummy-Daten nur für Tests!
if (achievements == null || achievements.isEmpty()) {
achievements = List.of(
new Achievement("Hangman Hero", "Gewinne 10 Runden Hangman", "hangman.png", true),
new Achievement("Memory Meister", "Erreiche 1000 Punkte im Memory", "memory.png", false),
new Achievement("Quizkönig", "Beantworte 50 Fragen richtig", "quiz.png", true),
new Achievement("Early Bird", "Lerne vor 7 Uhr morgens", "earlybird.png", false)
);
}
model.addAttribute("achievements", achievements);
return "achievements"; // lädt achievements.html
}
}
@@ -0,0 +1,59 @@
package com.example.mindflux.controller;
import com.example.mindflux.service.MindMatchService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
/**
* Admin-Verwaltung der MindMatch-Decks und -Paare. Liegt unter /admin/** und ist
* damit per SecurityConfig auf ROLE_ADMIN beschränkt.
*/
@Controller
@RequestMapping("/admin/mindmatch")
public class AdminMindMatchController {
private final MindMatchService mindMatchService;
public AdminMindMatchController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("")
public String manage(Model model) {
model.addAttribute("decks", mindMatchService.allDecksWithPairs());
return "admin/mindmatch";
}
@PostMapping("/decks")
public String createDeck(@RequestParam String title) {
mindMatchService.createDeck(title);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/pairs")
public String addPair(@PathVariable Long deckId,
@RequestParam String term,
@RequestParam String match) {
mindMatchService.addPair(deckId, term, match);
return "redirect:/admin/mindmatch";
}
@PostMapping("/pairs/{pairId}/delete")
public String deletePair(@PathVariable Long pairId) {
mindMatchService.deletePair(pairId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/delete")
public String deleteDeck(@PathVariable Long deckId) {
mindMatchService.deleteDeck(deckId);
return "redirect:/admin/mindmatch";
}
@PostMapping("/decks/{deckId}/toggle")
public String toggleDeck(@PathVariable Long deckId) {
mindMatchService.toggleDeck(deckId);
return "redirect:/admin/mindmatch";
}
}
@@ -0,0 +1,79 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.service.TechtacularService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
/** Admin-Verwaltung der TechTacular-Kategorien, -Fragen und -Antworten (ROLE_ADMIN via /admin/**). */
@Controller
@RequestMapping("/admin/techtacular")
public class AdminTechtacularController {
private final TechtacularService techtacularService;
public AdminTechtacularController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** Übersicht aller Kategorien. */
@GetMapping("")
public String manage(Model model) {
model.addAttribute("categories", techtacularService.categoryRows());
return "admin/techtacular";
}
/** Detailseite einer Kategorie mit ihren Fragen. */
@GetMapping("/categories/{categoryId}")
public String category(@PathVariable Long categoryId, Model model) {
TechtacularCategory category = techtacularService.categoryWithQuestions(categoryId);
if (category == null) {
return "redirect:/admin/techtacular";
}
model.addAttribute("category", category);
return "admin/techtacular-category";
}
@PostMapping("/categories")
public String createCategory(@RequestParam String title) {
techtacularService.createCategory(title);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/questions")
public String addQuestion(@PathVariable Long categoryId,
@RequestParam String text,
@RequestParam(required = false) String explanation,
@RequestParam(defaultValue = "100") int points,
@RequestParam(required = false, defaultValue = "") String answer1,
@RequestParam(required = false, defaultValue = "") String answer2,
@RequestParam(required = false, defaultValue = "") String answer3,
@RequestParam(required = false, defaultValue = "") String answer4,
@RequestParam(defaultValue = "0") int correct) {
List<String> answers = Arrays.asList(answer1, answer2, answer3, answer4);
techtacularService.addQuestion(categoryId, text, explanation, points, answers, correct);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/questions/{questionId}/delete")
public String deleteQuestion(@PathVariable Long questionId, @RequestParam Long categoryId) {
techtacularService.deleteQuestion(questionId);
return "redirect:/admin/techtacular/categories/" + categoryId;
}
@PostMapping("/categories/{categoryId}/delete")
public String deleteCategory(@PathVariable Long categoryId) {
techtacularService.deleteCategory(categoryId);
return "redirect:/admin/techtacular";
}
@PostMapping("/categories/{categoryId}/toggle")
public String toggleCategory(@PathVariable Long categoryId) {
techtacularService.toggleCategory(categoryId);
return "redirect:/admin/techtacular";
}
}
@@ -0,0 +1,66 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.AdminUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/admin")
public class AdminUserController {
@Autowired
private AdminUserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
// ⬅️ Neu: Dashboard-Startseite
@GetMapping("")
public String adminDashboard() {
return "admin/dashboard"; // du brauchst: templates/admin/dashboard.html
}
// ⬅️ Benutzerverwaltung
@GetMapping("/users")
public String listUsers(Model model) {
model.addAttribute("users", userService.findAll());
return "admin/users/list";
}
@GetMapping("/users/new")
public String createUserForm(Model model) {
model.addAttribute("user", new User());
return "admin/users/form";
}
@PostMapping("/users/save")
public String saveUser(@ModelAttribute("user") User user) {
if (user.getId() != null && (user.getPassword() == null || user.getPassword().isBlank())) {
User existingUser = userService.findById(user.getId())
.orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + user.getId()));
user.setPassword(existingUser.getPassword());
} else {
user.setPassword(passwordEncoder.encode(user.getPassword()));
}
userService.save(user);
return "redirect:/admin/users";
}
@GetMapping("/users/edit/{id}")
public String editUserForm(@PathVariable Long id, Model model) {
User user = userService.findById(id).orElseThrow(() -> new IllegalArgumentException("Ungültige User-ID: " + id));
model.addAttribute("user", user);
return "admin/users/form";
}
@PostMapping("/users/delete/{id}")
public String deleteUser(@PathVariable Long id) {
userService.deleteById(id);
return "redirect:/admin/users";
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DashboardController {
@GetMapping("/dashboard")
public String showDashboard() {
return "dashboard"; // lädt dashboard.html aus templates
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DummyController {
@GetMapping("/comingsoon")
public String comingSoon() {
return "dummy"; // verweist auf dummy.html in src/main/resources/templates
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class FinalfluxController {
@GetMapping("/finalflux")
public String showFinalfluxOverview() {
return "finalflux"; // Lädt templates/finalflux.html
}
}
@@ -0,0 +1,50 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.service.GameScoreService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.Set;
/**
* Generisches Score-API für alle Mindflux-Spiele. Der Spielname kommt aus der URL
* und wird gegen eine Allowlist geprüft, damit keine beliebigen Spiele-Kennungen
* in die Datenbank wandern.
*/
@RestController
@RequestMapping("/api/games")
public class GameScoreController {
/** Bekannte Spiele (entsprechen den Dashboard-Routen). */
private static final Set<String> KNOWN_GAMES = Set.of(
"mindmatch", "techtacular", "syntax-saga", "escape-room",
"service-clash", "bug-hunter", "netzwerker");
private final GameScoreService gameScoreService;
public GameScoreController(GameScoreService gameScoreService) {
this.gameScoreService = gameScoreService;
}
@PostMapping("/{game}/scores")
public ResponseEntity<ScoreResult> submitScore(@PathVariable String game,
@RequestBody ScoreSubmission submission,
Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.record(principal.getName(), game, submission));
}
@GetMapping("/{game}/stats")
public ResponseEntity<GameStats> getStats(@PathVariable String game, Principal principal) {
if (!KNOWN_GAMES.contains(game)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(gameScoreService.getStats(principal.getName(), game));
}
}
@@ -0,0 +1,26 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.MindMatchDeckView;
import com.example.mindflux.service.MindMatchService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** Liefert die aktiven MindMatch-Decks (inkl. Paare) an die Spielseite. */
@RestController
@RequestMapping("/api/mindmatch")
public class MindMatchApiController {
private final MindMatchService mindMatchService;
public MindMatchApiController(MindMatchService mindMatchService) {
this.mindMatchService = mindMatchService;
}
@GetMapping("/decks")
public List<MindMatchDeckView> decks() {
return mindMatchService.activeDecks();
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class MindMatchController {
@GetMapping("/mindmatch")
public String showMindMatch() {
return "mindmatch"; // lädt templates/mindmatch.html
}
}
@@ -0,0 +1,172 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.Principal;
import java.util.UUID;
@Controller
public class ProfileController {
@Autowired
private UserService userService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/profiles/";
@GetMapping("/profile")
public String profile(Model model, Principal principal, @RequestParam(value = "success", required = false) Boolean success) {
String username = principal.getName();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
if (Boolean.TRUE.equals(success)) {
model.addAttribute("message", "Profil erfolgreich aktualisiert!");
}
return "profile";
}
@PostMapping("/profile/delete-picture")
public String deleteProfilePicture(Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
String profilePicture = existingUser.getProfilePicture();
if (profilePicture != null && !profilePicture.trim().isEmpty()) {
try {
Path picturePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", profilePicture);
System.out.println("Versuche zu löschen: " + picturePath.toAbsolutePath());
if (Files.exists(picturePath)) {
Files.delete(picturePath);
System.out.println("Datei erfolgreich gelöscht: " + picturePath.getFileName());
} else {
System.out.println("Datei existiert nicht: " + picturePath.toAbsolutePath());
}
} catch (IOException e) {
System.err.println("Fehler beim Löschen der Datei: " + e.getMessage());
}
} else {
System.out.println("Kein Profilbild gesetzt, nichts zu löschen.");
}
existingUser.setProfilePicture(null); // Wichtig: auch wenn Datei nicht existiert
userService.save(existingUser);
}
return "redirect:/profile?success=true";
}
@GetMapping("/profile/knowledge")
public String knowledgeCenter(Model model, Principal principal) {
String username = principal.getName();
User user = userService.findByUsername(username);
model.addAttribute("user", user);
return "knowledge";
}
@PostMapping("/profile/update")
public String updateProfile(@RequestParam("email") String email,
@RequestParam(value = "profilePicture", required = false) MultipartFile file,
Principal principal) {
String username = principal.getName();
User existingUser = userService.findByUsername(username);
if (existingUser != null) {
existingUser.setEmail(email); // Email aktualisieren
if (file != null && !file.isEmpty()) {
try {
// Altes Profilbild löschen (falls vorhanden)
if (existingUser.getProfilePicture() != null && !existingUser.getProfilePicture().isEmpty()) {
Path oldFilePath = Paths.get(System.getProperty("user.dir"), "uploads", "profiles", existingUser.getProfilePicture());
if (Files.exists(oldFilePath)) {
Files.delete(oldFilePath);
}
}
// Neues Profilbild speichern
String fileExtension = getFileExtension(file.getOriginalFilename());
String fileName = UUID.randomUUID() + fileExtension;
Path uploadPath = Paths.get(UPLOAD_DIR);
Files.createDirectories(uploadPath);
Path filePath = uploadPath.resolve(fileName);
file.transferTo(filePath.toFile());
existingUser.setProfilePicture(fileName);
} catch (IOException e) {
e.printStackTrace();
}
}
userService.save(existingUser);
}
return "redirect:/profile?success=true";
}
@PostMapping("/profile/change-password")
public String changePassword(@RequestParam("oldPassword") String oldPassword,
@RequestParam("newPassword") String newPassword,
Principal principal,
Model model) {
String username = principal.getName();
User user = userService.findByUsername(username);
if (user == null) {
model.addAttribute("error", "Benutzer nicht gefunden.");
return "profile";
}
if (!passwordEncoder.matches(oldPassword, user.getPassword())) {
model.addAttribute("error", "Das aktuelle Passwort ist falsch.");
return "profile";
}
user.setPassword(passwordEncoder.encode(newPassword));
userService.save(user);
model.addAttribute("message", "Passwort erfolgreich geändert.");
return "profile";
}
@PostMapping("/profile/delete")
public String deleteProfile(Principal principal, Authentication authentication, HttpServletRequest request, HttpServletResponse response) {
String username = principal.getName();
userService.deleteByUsername(username);
new SecurityContextLogoutHandler().logout(request, response, authentication);
return "redirect:/?logout";
}
// Hilfsmethode: Dateiendung extrahieren
private String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf(".") == -1) {
return "";
}
return filename.substring(filename.lastIndexOf("."));
}
}
@@ -0,0 +1,47 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.GameProgress;
import com.example.mindflux.dto.ProgressData;
import com.example.mindflux.service.ProgressService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.security.Principal;
import java.util.List;
@Controller
public class ProgressController {
@Autowired
private ProgressService progressService;
@GetMapping("/progress") // <-- hier geändert!
public String showProgress(Model model, Principal principal) {
String username = principal.getName(); // aktueller eingeloggter Benutzer
ProgressData progress = progressService.getOverallProgress(username);
List<GameProgress> games = progressService.getGameProgress(username);
if (progress == null) {
// Falls keine Fortschrittsdaten vorhanden sind, Dummy-Daten setzen
model.addAttribute("correctAnswers", 0);
model.addAttribute("currentXp", 0);
model.addAttribute("xpForNextLevel", 1000);
model.addAttribute("userLevel", 1);
model.addAttribute("xpPercentage", 0);
} else {
model.addAttribute("correctAnswers", progress.getCorrectAnswers());
model.addAttribute("currentXp", progress.getCurrentXp());
model.addAttribute("xpForNextLevel", progress.getXpForNextLevel());
model.addAttribute("userLevel", progress.getUserLevel());
model.addAttribute("xpPercentage", progress.getProgressPercent());
}
model.addAttribute("gameProgress", games != null ? games : List.of()); // leere Liste, falls null
return "progress"; // ruft progress.html auf
}
}
@@ -0,0 +1,44 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
public class RegisterController {
@Autowired
private UserService userService;
@Autowired
private PasswordEncoder passwordEncoder;
@GetMapping("/register")
public String showRegistrationForm(Model model) {
model.addAttribute("user", new User());
return "register";
}
@PostMapping("/register")
public String processRegistration(@ModelAttribute("user") User user, Model model) {
// Benutzername klein schreiben, um Doppelungen zu vermeiden
user.setUsername(user.getUsername().toLowerCase());
// Prüfen, ob Benutzername schon existiert
if (userService.findByUsername(user.getUsername()) != null) {
model.addAttribute("error", "Benutzername bereits vergeben.");
return "register";
}
// Passwort verschlüsseln
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setRole("USER"); // Standardrolle
userService.save(user);
return "redirect:/?registerSuccess"; // zurück zum Login mit Erfolgsmeldung
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SettingsController {
@GetMapping("/profile/settings")
public String showSettingsPage() {
return "settings"; // lädt templates/settings.html
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SozialesController {
@GetMapping("/soziales")
public String soziales() {
return "soziales"; // -> templates/soziales.html
}
}
@@ -0,0 +1,51 @@
package com.example.mindflux.controller;
import com.example.mindflux.dto.*;
import com.example.mindflux.service.TechtacularService;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.HashSet;
import java.util.Set;
/** Spieler-API für TechTacular: Board laden, Antwort prüfen, Runde abschließen. */
@RestController
@RequestMapping("/api/techtacular")
public class TechtacularApiController {
private final TechtacularService techtacularService;
public TechtacularApiController(TechtacularService techtacularService) {
this.techtacularService = techtacularService;
}
/** exclude = komma-separierte Frage-IDs der Vorrunde, die möglichst nicht erneut kommen sollen. */
@GetMapping("/board")
public TechtacularBoardView board(@RequestParam(required = false) String exclude) {
return techtacularService.buildBoard(parseIds(exclude));
}
private Set<Long> parseIds(String csv) {
Set<Long> ids = new HashSet<>();
if (csv != null && !csv.isBlank()) {
for (String part : csv.split(",")) {
try {
ids.add(Long.parseLong(part.trim()));
} catch (NumberFormatException ignored) {
// ungültige IDs einfach überspringen
}
}
}
return ids;
}
@PostMapping("/answer")
public AnswerResult answer(@RequestBody AnswerRequest request) {
return techtacularService.checkAnswer(request.getQuestionId(), request.getAnswerId());
}
@PostMapping("/finish")
public TechtacularResult finish(@RequestBody FinishRequest request, Principal principal) {
return techtacularService.finish(principal.getName(), request);
}
}
@@ -0,0 +1,13 @@
package com.example.mindflux.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class TechtacularController {
@GetMapping("/techtacular")
public String showTechtacular() {
return "techtacular"; // lädt templates/techtacular.html
}
}
@@ -0,0 +1,67 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
// 📥 User erstellen
@PostMapping
public User createUser(@RequestBody User user) {
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
// 📋 Alle User anzeigen
@GetMapping
public List<User> getAllUsers() {
return userRepository.findAll();
}
// 📍 Einzelnen User anzeigen
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
Optional<User> user = userRepository.findById(id);
return user.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
// ✏️ User aktualisieren
@PutMapping("/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User updatedUser) {
return userRepository.findById(id).map(user -> {
user.setUsername(updatedUser.getUsername());
if (updatedUser.getPassword() != null && !updatedUser.getPassword().isBlank()) {
user.setPassword(passwordEncoder.encode(updatedUser.getPassword()));
}
user.setEmail(updatedUser.getEmail());
user.setRole(updatedUser.getRole());
return ResponseEntity.ok(userRepository.save(user));
}).orElseGet(() -> ResponseEntity.notFound().build());
}
// 🗑️ User löschen
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
if (userRepository.existsById(id)) {
userRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
return ResponseEntity.notFound().build();
}
}
@@ -0,0 +1,31 @@
package com.example.mindflux.controller;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/users")
public class UserViewController {
@Autowired
private UserRepository userRepository;
// Formular anzeigen
@GetMapping("/form")
public String showForm(Model model) {
model.addAttribute("user", new User());
return "user-form";
}
// Benutzer speichern
@PostMapping("/save")
public String saveUser(@ModelAttribute User user) {
userRepository.save(user);
return "user-success"; // statt redirect
}
}
@@ -0,0 +1,24 @@
package com.example.mindflux.dto;
/** Eine Zeile in der TechTacular-Kategorienübersicht (ohne alle Fragen zu laden). */
public class AdminCategoryRow {
private final Long id;
private final String slug;
private final String title;
private final boolean active;
private final long questionCount;
public AdminCategoryRow(Long id, String slug, String title, boolean active, long questionCount) {
this.id = id;
this.slug = slug;
this.title = title;
this.active = active;
this.questionCount = questionCount;
}
public Long getId() { return id; }
public String getSlug() { return slug; }
public String getTitle() { return title; }
public boolean isActive() { return active; }
public long getQuestionCount() { return questionCount; }
}
@@ -0,0 +1,13 @@
package com.example.mindflux.dto;
/** Eine vom Spieler gewählte Antwort. */
public class AnswerRequest {
private Long questionId;
private Long answerId;
public Long getQuestionId() { return questionId; }
public void setQuestionId(Long questionId) { this.questionId = questionId; }
public Long getAnswerId() { return answerId; }
public void setAnswerId(Long answerId) { this.answerId = answerId; }
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Serverseitige Bewertung einer einzelnen Antwort (instant feedback). */
public class AnswerResult {
private final boolean correct;
private final Long correctAnswerId;
private final String explanation;
private final int points;
public AnswerResult(boolean correct, Long correctAnswerId, String explanation, int points) {
this.correct = correct;
this.correctAnswerId = correctAnswerId;
this.explanation = explanation;
this.points = points;
}
public boolean isCorrect() { return correct; }
public Long getCorrectAnswerId() { return correctAnswerId; }
public String getExplanation() { return explanation; }
public int getPoints() { return points; }
}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
import java.util.List;
/** Abschluss einer TechTacular-Runde: alle gewählten Antworten + Dauer. Score wird serverseitig recomputed. */
public class FinishRequest {
private int durationSeconds;
private List<AnswerRequest> answers;
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
public List<AnswerRequest> getAnswers() { return answers; }
public void setAnswers(List<AnswerRequest> answers) { this.answers = answers; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
public class GameProgress {
private String name;
private int progressPercent;
private int highscore;
private int plays;
private LocalDate lastPlayed;
// Getter
public String getName() { return name; }
public int getProgressPercent() { return progressPercent; }
public int getHighscore() { return highscore; }
public int getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
// Setter
public void setName(String name) { this.name = name; }
public void setProgressPercent(int progressPercent) { this.progressPercent = progressPercent; }
public void setHighscore(int highscore) { this.highscore = highscore; }
public void setPlays(int plays) { this.plays = plays; }
public void setLastPlayed(LocalDate lastPlayed) { this.lastPlayed = lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.time.LocalDate;
/** Zusammengefasste Statistik eines Spielers für ein Spiel. */
public class GameStats {
private final int highscore;
private final long plays;
private final LocalDate lastPlayed;
public GameStats(int highscore, long plays, LocalDate lastPlayed) {
this.highscore = highscore;
this.plays = plays;
this.lastPlayed = lastPlayed;
}
public int getHighscore() { return highscore; }
public long getPlays() { return plays; }
public LocalDate getLastPlayed() { return lastPlayed; }
}
@@ -0,0 +1,20 @@
package com.example.mindflux.dto;
import java.util.List;
/** Ein aktives Deck mit seinen Paaren fürs Spiel-API. */
public class MindMatchDeckView {
private final String slug;
private final String title;
private final List<MindMatchPairView> pairs;
public MindMatchDeckView(String slug, String title, List<MindMatchPairView> pairs) {
this.slug = slug;
this.title = title;
this.pairs = pairs;
}
public String getSlug() { return slug; }
public String getTitle() { return title; }
public List<MindMatchPairView> getPairs() { return pairs; }
}
@@ -0,0 +1,15 @@
package com.example.mindflux.dto;
/** Ein Paar fürs Spiel-API. Feld heißt "match" (passend zur bisherigen JS-Logik). */
public class MindMatchPairView {
private final String term;
private final String match;
public MindMatchPairView(String term, String match) {
this.term = term;
this.match = match;
}
public String getTerm() { return term; }
public String getMatch() { return match; }
}
@@ -0,0 +1,49 @@
package com.example.mindflux.dto;
public class ProgressData {
private int correctAnswers;
private int currentXp;
private int xpForNextLevel;
private int userLevel;
private int progressPercent;
public int getCorrectAnswers() {
return correctAnswers;
}
public void setCorrectAnswers(int correctAnswers) {
this.correctAnswers = correctAnswers;
}
public int getCurrentXp() {
return currentXp;
}
public void setCurrentXp(int currentXp) {
this.currentXp = currentXp;
}
public int getXpForNextLevel() {
return xpForNextLevel;
}
public void setXpForNextLevel(int xpForNextLevel) {
this.xpForNextLevel = xpForNextLevel;
}
public int getUserLevel() {
return userLevel;
}
public void setUserLevel(int userLevel) {
this.userLevel = userLevel;
}
public int getProgressPercent() {
return progressPercent;
}
public void setProgressPercent(int progressPercent) {
this.progressPercent = progressPercent;
}
}
@@ -0,0 +1,21 @@
package com.example.mindflux.dto;
/** Antwort nach dem Speichern eines Ergebnisses. */
public class ScoreResult {
private final int score;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public ScoreResult(int score, int highscore, boolean newHighscore, long plays) {
this.score = score;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,25 @@
package com.example.mindflux.dto;
/**
* Was der Client nach einem abgeschlossenen Spiel sendet. Der Score wird
* bewusst NICHT mitgeschickt, sondern serverseitig aus diesen Rohwerten
* berechnet, damit ein manipulierter Client keinen Fantasie-Score speichern kann.
*/
public class ScoreSubmission {
private String deck;
private int pairs;
private int moves;
private int durationSeconds;
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getPairs() { return pairs; }
public void setPairs(int pairs) { this.pairs = pairs; }
public int getMoves() { return moves; }
public void setMoves(int moves) { this.moves = moves; }
public int getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(int durationSeconds) { this.durationSeconds = durationSeconds; }
}
@@ -0,0 +1,66 @@
package com.example.mindflux.dto;
import java.util.List;
/**
* Das Jeopardy-Board fürs Frontend: Spalten (Kategorien) mit Kacheln (Fragen).
* Bewusst OHNE Information darüber, welche Antwort korrekt ist – geprüft wird serverseitig.
*/
public class TechtacularBoardView {
private final List<Category> categories;
private final int maxScore;
public TechtacularBoardView(List<Category> categories, int maxScore) {
this.categories = categories;
this.maxScore = maxScore;
}
public List<Category> getCategories() { return categories; }
public int getMaxScore() { return maxScore; }
public static class Category {
private final String title;
private final List<Tile> tiles;
public Category(String title, List<Tile> tiles) {
this.title = title;
this.tiles = tiles;
}
public String getTitle() { return title; }
public List<Tile> getTiles() { return tiles; }
}
public static class Tile {
private final Long questionId;
private final int points;
private final String text;
private final List<Option> answers;
public Tile(Long questionId, int points, String text, List<Option> answers) {
this.questionId = questionId;
this.points = points;
this.text = text;
this.answers = answers;
}
public Long getQuestionId() { return questionId; }
public int getPoints() { return points; }
public String getText() { return text; }
public List<Option> getAnswers() { return answers; }
}
public static class Option {
private final Long id;
private final String text;
public Option(Long id, String text) {
this.id = id;
this.text = text;
}
public Long getId() { return id; }
public String getText() { return text; }
}
}
@@ -0,0 +1,31 @@
package com.example.mindflux.dto;
/** Ergebnis einer abgeschlossenen TechTacular-Runde. */
public class TechtacularResult {
private final int score;
private final int maxScore;
private final int correctCount;
private final int totalCount;
private final int highscore;
private final boolean newHighscore;
private final long plays;
public TechtacularResult(int score, int maxScore, int correctCount, int totalCount,
int highscore, boolean newHighscore, long plays) {
this.score = score;
this.maxScore = maxScore;
this.correctCount = correctCount;
this.totalCount = totalCount;
this.highscore = highscore;
this.newHighscore = newHighscore;
this.plays = plays;
}
public int getScore() { return score; }
public int getMaxScore() { return maxScore; }
public int getCorrectCount() { return correctCount; }
public int getTotalCount() { return totalCount; }
public int getHighscore() { return highscore; }
public boolean isNewHighscore() { return newHighscore; }
public long getPlays() { return plays; }
}
@@ -0,0 +1,51 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "achievements")
public class Achievement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String title;
private String description;
private String icon; // Dateiname des Icons (z.B. "star.png")
private LocalDateTime dateAchieved;
// 🔥 Neuer Konstruktor für schnelle Dummy-Erstellung (ohne username/dateAchieved)
public Achievement(String title, String description, String icon, boolean unlocked) {
this.title = title;
this.description = description;
this.icon = icon;
this.dateAchieved = unlocked ? LocalDateTime.now() : null;
}
// ⚡ Standard-Konstruktor (wichtig für JPA!)
public Achievement() {
}
// Getter und Setter
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getIcon() { return icon; }
public void setIcon(String icon) { this.icon = icon; }
public LocalDateTime getDateAchieved() { return dateAchieved; }
public void setDateAchieved(LocalDateTime dateAchieved) { this.dateAchieved = dateAchieved; }
}
@@ -0,0 +1,75 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* Ein einzelnes Spielergebnis. Bewusst generisch gehalten, damit alle
* Mindflux-Spiele (MindMatch, TechTacular, ...) dieselbe Tabelle nutzen können.
* Welches Spiel das Ergebnis erzeugt hat, steht in {@link #game}.
*/
@Entity
@Table(name = "game_scores")
public class GameScore {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
/** Spiel-Kennung, z.B. "mindmatch" (entspricht der Route im Dashboard). */
private String game;
/** Optionales Deck / Level innerhalb des Spiels, z.B. "netzwerk". */
private String deck;
private int score;
/** Anzahl der Zug-Versuche (kann je nach Spiel null sein). */
private Integer moves;
@Column(name = "duration_seconds")
private Integer durationSeconds;
@Column(name = "played_at")
private LocalDateTime playedAt;
public GameScore() {
}
public GameScore(String username, String game, String deck, int score,
Integer moves, Integer durationSeconds, LocalDateTime playedAt) {
this.username = username;
this.game = game;
this.deck = deck;
this.score = score;
this.moves = moves;
this.durationSeconds = durationSeconds;
this.playedAt = playedAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getGame() { return game; }
public void setGame(String game) { this.game = game; }
public String getDeck() { return deck; }
public void setDeck(String deck) { this.deck = deck; }
public int getScore() { return score; }
public void setScore(int score) { this.score = score; }
public Integer getMoves() { return moves; }
public void setMoves(Integer moves) { this.moves = moves; }
public Integer getDurationSeconds() { return durationSeconds; }
public void setDurationSeconds(Integer durationSeconds) { this.durationSeconds = durationSeconds; }
public LocalDateTime getPlayedAt() { return playedAt; }
public void setPlayedAt(LocalDateTime playedAt) { this.playedAt = playedAt; }
}
@@ -0,0 +1,63 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Ein MindMatch-Deck (Kategorie) mit seinen Begriffspaaren. Von Admins pflegbar. */
@Entity
@Table(name = "mindmatch_decks")
public class MindMatchDeck {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "deck", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("id ASC")
private List<MindMatchPair> pairs = new ArrayList<>();
public MindMatchDeck() {
}
public MindMatchDeck(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
/** Fügt ein Paar hinzu und hält die Rückreferenz konsistent. */
public void addPair(MindMatchPair pair) {
pair.setDeck(this);
this.pairs.add(pair);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<MindMatchPair> getPairs() { return pairs; }
public void setPairs(List<MindMatchPair> pairs) { this.pairs = pairs; }
}
@@ -0,0 +1,43 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Ein Begriffspaar innerhalb eines {@link MindMatchDeck}: Begriff + zugehörige Bedeutung. */
@Entity
@Table(name = "mindmatch_pairs")
public class MindMatchPair {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deck_id")
private MindMatchDeck deck;
private String term;
/** Spalte heißt "match_text", da MATCH in SQL ein reserviertes Wort ist. */
@Column(name = "match_text")
private String matchText;
public MindMatchPair() {
}
public MindMatchPair(String term, String matchText) {
this.term = term;
this.matchText = matchText;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public MindMatchDeck getDeck() { return deck; }
public void setDeck(MindMatchDeck deck) { this.deck = deck; }
public String getTerm() { return term; }
public void setTerm(String term) { this.term = term; }
public String getMatchText() { return matchText; }
public void setMatchText(String matchText) { this.matchText = matchText; }
}
@@ -0,0 +1,49 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
/** Eine Antwortmöglichkeit zu einer {@link TechtacularQuestion}. */
@Entity
@Table(name = "techtacular_answers")
public class TechtacularAnswer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id")
private TechtacularQuestion question;
@Column(name = "answer_text")
private String text;
private boolean correct;
@Column(name = "sort_order")
private int sortOrder;
public TechtacularAnswer() {
}
public TechtacularAnswer(String text, boolean correct, int sortOrder) {
this.text = text;
this.correct = correct;
this.sortOrder = sortOrder;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularQuestion getQuestion() { return question; }
public void setQuestion(TechtacularQuestion question) { this.question = question; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public boolean isCorrect() { return correct; }
public void setCorrect(boolean correct) { this.correct = correct; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
}
@@ -0,0 +1,62 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine TechTacular-Kategorie (Spalte im Jeopardy-Board) mit ihren Fragen. */
@Entity
@Table(name = "techtacular_categories")
public class TechtacularCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String slug;
private String title;
@Column(name = "sort_order")
private int sortOrder;
private boolean active = true;
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("points ASC, id ASC")
private List<TechtacularQuestion> questions = new ArrayList<>();
public TechtacularCategory() {
}
public TechtacularCategory(String slug, String title, int sortOrder, boolean active) {
this.slug = slug;
this.title = title;
this.sortOrder = sortOrder;
this.active = active;
}
public void addQuestion(TechtacularQuestion question) {
question.setCategory(this);
this.questions.add(question);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSlug() { return slug; }
public void setSlug(String slug) { this.slug = slug; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public int getSortOrder() { return sortOrder; }
public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public List<TechtacularQuestion> getQuestions() { return questions; }
public void setQuestions(List<TechtacularQuestion> questions) { this.questions = questions; }
}
@@ -0,0 +1,67 @@
package com.example.mindflux.model;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Eine Frage mit ihren Antwortmöglichkeiten (genau eine ist korrekt) und Punktwert. */
@Entity
@Table(name = "techtacular_questions")
public class TechtacularQuestion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private TechtacularCategory category;
@Column(name = "question_text")
private String text;
private String explanation;
private int points;
@OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC, id ASC")
private List<TechtacularAnswer> answers = new ArrayList<>();
public TechtacularQuestion() {
}
public TechtacularQuestion(String text, String explanation, int points) {
this.text = text;
this.explanation = explanation;
this.points = points;
}
public void addAnswer(TechtacularAnswer answer) {
answer.setQuestion(this);
this.answers.add(answer);
}
/** Liefert die als korrekt markierte Antwort (oder null). */
public TechtacularAnswer correctAnswer() {
return answers.stream().filter(TechtacularAnswer::isCorrect).findFirst().orElse(null);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public TechtacularCategory getCategory() { return category; }
public void setCategory(TechtacularCategory category) { this.category = category; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getExplanation() { return explanation; }
public void setExplanation(String explanation) { this.explanation = explanation; }
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public List<TechtacularAnswer> getAnswers() { return answers; }
public void setAnswers(List<TechtacularAnswer> answers) { this.answers = answers; }
}
@@ -0,0 +1,54 @@
package com.example.mindflux.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
private String email;
private String role;
@Column(name = "profile_picture")
private String profilePicture;
public User() {}
public User(String username, String password, String email, String role) {
this.username = username;
this.password = password;
this.email = email;
this.role = role;
}
// —————————————
// Getter / Setter
// —————————————
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String u) { this.username = u; }
public String getPassword() { return password; }
public void setPassword(String p) { this.password = p; }
public String getEmail() { return email; }
public void setEmail(String e) { this.email = e; }
public String getRole() { return role; }
public void setRole(String r) { this.role = r; }
public String getProfilePicture() { return profilePicture; }
public void setProfilePicture(String pictureUrl) { this.profilePicture = pictureUrl; }
}
@@ -0,0 +1,10 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.Achievement;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AchievementRepository extends JpaRepository<Achievement, Long> {
List<Achievement> findByUsername(String username);
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AdminUserRepository extends JpaRepository<User, Long> {
}
@@ -0,0 +1,18 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.GameScore;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface GameScoreRepository extends JpaRepository<GameScore, Long> {
/** Bestes Ergebnis eines Spielers in einem Spiel (Highscore). */
Optional<GameScore> findTopByUsernameAndGameOrderByScoreDesc(String username, String game);
/** Zuletzt gespieltes Ergebnis eines Spielers in einem Spiel. */
Optional<GameScore> findTopByUsernameAndGameOrderByPlayedAtDesc(String username, String game);
/** Wie oft ein Spieler ein Spiel abgeschlossen hat. */
long countByUsernameAndGame(String username, String game);
}
@@ -0,0 +1,18 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.MindMatchDeck;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MindMatchDeckRepository extends JpaRepository<MindMatchDeck, Long> {
List<MindMatchDeck> findAllByActiveTrueOrderBySortOrderAscIdAsc();
List<MindMatchDeck> findAllByOrderBySortOrderAscIdAsc();
Optional<MindMatchDeck> findBySlug(String slug);
boolean existsBySlug(String slug);
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.MindMatchPair;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MindMatchPairRepository extends JpaRepository<MindMatchPair, Long> {
}
@@ -0,0 +1,7 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularAnswer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TechtacularAnswerRepository extends JpaRepository<TechtacularAnswer, Long> {
}
@@ -0,0 +1,15 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TechtacularCategoryRepository extends JpaRepository<TechtacularCategory, Long> {
List<TechtacularCategory> findAllByActiveTrueOrderBySortOrderAscIdAsc();
List<TechtacularCategory> findAllByOrderBySortOrderAscIdAsc();
boolean existsBySlug(String slug);
}
@@ -0,0 +1,9 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.TechtacularQuestion;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TechtacularQuestionRepository extends JpaRepository<TechtacularQuestion, Long> {
long countByCategoryId(Long categoryId);
}
@@ -0,0 +1,14 @@
package com.example.mindflux.repository;
import com.example.mindflux.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
// Benutzer anhand des Benutzernamens suchen
Optional<User> findByUsername(String username);
// Benutzer anhand des Benutzernamens löschen
void deleteByUsername(String username);
}
@@ -0,0 +1,9 @@
package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import java.util.List;
public interface AchievementService {
List<Achievement> getAchievementsForUser(String username);
}
@@ -0,0 +1,20 @@
package com.example.mindflux.service;
import com.example.mindflux.model.Achievement;
import com.example.mindflux.repository.AchievementRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AchievementServiceImpl implements AchievementService {
@Autowired
private AchievementRepository achievementRepository;
@Override
public List<Achievement> getAchievementsForUser(String username) {
return achievementRepository.findByUsername(username);
}
}
@@ -0,0 +1,32 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.AdminUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class AdminUserService {
@Autowired
private AdminUserRepository userRepository;
public List<User> findAll() {
return userRepository.findAll();
}
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
public User save(User user) {
return userRepository.save(user);
}
public void deleteById(Long id) {
userRepository.deleteById(id);
}
}
@@ -0,0 +1,83 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ScoreResult;
import com.example.mindflux.dto.ScoreSubmission;
import com.example.mindflux.model.GameScore;
import com.example.mindflux.repository.GameScoreRepository;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
/**
* Speichert Spielergebnisse und liefert Statistiken. Der Score wird hier
* autoritativ berechnet – der Client schickt nur die Rohwerte.
*/
@Service
public class GameScoreService {
private final GameScoreRepository repository;
public GameScoreService(GameScoreRepository repository) {
this.repository = repository;
}
/**
* Punktezahl aus Genauigkeit und Tempo.
* <ul>
* <li>Genauigkeit: {@code 1000 * Paare / Züge} – 1000 Punkte bei perfektem Spiel
* (jeder Aufdeck-Versuch ein Treffer), weniger bei vielen Fehlversuchen.</li>
* <li>Zeitbonus: bis zu 500 Punkte, der nach 100 Sekunden auf 0 fällt.</li>
* </ul>
* Identisch in {@code mindmatch.js} gespiegelt, damit der Client sofort denselben
* Wert anzeigt.
*/
public static int calculateScore(int pairs, int moves, int durationSeconds) {
if (pairs <= 0) {
return 0;
}
int safeMoves = Math.max(moves, pairs); // niemals weniger Züge als Paare
int safeSeconds = Math.max(durationSeconds, 0);
int accuracyScore = Math.round(1000f * pairs / safeMoves);
int timeBonus = Math.max(0, 500 - safeSeconds * 5);
return accuracyScore + timeBonus;
}
/** Speichert ein abgeschlossenes MindMatch-Spiel (Score aus Rohwerten berechnet). */
public ScoreResult record(String username, String game, ScoreSubmission submission) {
int moves = Math.max(submission.getMoves(), submission.getPairs());
int seconds = Math.max(submission.getDurationSeconds(), 0);
int score = calculateScore(submission.getPairs(), moves, seconds);
return recordScore(username, game, submission.getDeck(), score, moves, seconds);
}
/** Speichert ein bereits berechnetes Ergebnis (z.B. TechTacular) und liefert den Highscore-Stand. */
public ScoreResult recordScore(String username, String game, String deck,
int score, Integer moves, Integer durationSeconds) {
GameScore previousBest = repository
.findTopByUsernameAndGameOrderByScoreDesc(username, game)
.orElse(null);
repository.save(new GameScore(
username, game, deck, score, moves, durationSeconds, LocalDateTime.now()));
boolean newHighscore = previousBest == null || score > previousBest.getScore();
int highscore = previousBest == null ? score : Math.max(previousBest.getScore(), score);
long plays = repository.countByUsernameAndGame(username, game);
return new ScoreResult(score, highscore, newHighscore, plays);
}
/** Aktuelle Statistik (Highscore, Anzahl Spiele, zuletzt gespielt) für ein Spiel. */
public GameStats getStats(String username, String game) {
int highscore = repository.findTopByUsernameAndGameOrderByScoreDesc(username, game)
.map(GameScore::getScore)
.orElse(0);
long plays = repository.countByUsernameAndGame(username, game);
var lastPlayed = repository.findTopByUsernameAndGameOrderByPlayedAtDesc(username, game)
.map(score -> score.getPlayedAt().toLocalDate())
.orElse(null);
return new GameStats(highscore, plays, lastPlayed);
}
}
@@ -0,0 +1,102 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.MindMatchDeckView;
import com.example.mindflux.dto.MindMatchPairView;
import com.example.mindflux.model.MindMatchDeck;
import com.example.mindflux.model.MindMatchPair;
import com.example.mindflux.repository.MindMatchDeckRepository;
import com.example.mindflux.repository.MindMatchPairRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class MindMatchService {
private final MindMatchDeckRepository deckRepository;
private final MindMatchPairRepository pairRepository;
public MindMatchService(MindMatchDeckRepository deckRepository, MindMatchPairRepository pairRepository) {
this.deckRepository = deckRepository;
this.pairRepository = pairRepository;
}
// ---------- Spieler ----------
/** Aktive Decks mit Paaren fürs Spiel (open-in-view=false -> hier initialisiert). */
@Transactional(readOnly = true)
public List<MindMatchDeckView> activeDecks() {
return deckRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc().stream()
.map(deck -> new MindMatchDeckView(
deck.getSlug(),
deck.getTitle(),
deck.getPairs().stream()
.map(pair -> new MindMatchPairView(pair.getTerm(), pair.getMatchText()))
.toList()))
.toList();
}
// ---------- Admin ----------
/** Alle Decks (auch inaktive) inkl. Paaren für die Verwaltung. */
@Transactional(readOnly = true)
public List<MindMatchDeck> allDecksWithPairs() {
List<MindMatchDeck> decks = deckRepository.findAllByOrderBySortOrderAscIdAsc();
decks.forEach(deck -> deck.getPairs().size()); // Lazy-Collection laden
return decks;
}
@Transactional
public void createDeck(String title) {
if (title == null || title.isBlank()) {
return;
}
String clean = title.trim();
int order = (int) deckRepository.count() + 1;
deckRepository.save(new MindMatchDeck(uniqueSlug(clean), clean, order, true));
}
@Transactional
public void addPair(Long deckId, String term, String match) {
if (term == null || term.isBlank() || match == null || match.isBlank()) {
return;
}
deckRepository.findById(deckId).ifPresent(deck -> {
deck.addPair(new MindMatchPair(term.trim(), match.trim()));
deckRepository.save(deck);
});
}
@Transactional
public void deletePair(Long pairId) {
pairRepository.deleteById(pairId);
}
@Transactional
public void deleteDeck(Long deckId) {
deckRepository.deleteById(deckId);
}
@Transactional
public void toggleDeck(Long deckId) {
deckRepository.findById(deckId).ifPresent(deck -> deck.setActive(!deck.isActive()));
}
/** Erzeugt aus dem Titel einen URL-tauglichen, eindeutigen Slug. */
private String uniqueSlug(String title) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "deck";
}
String slug = base;
int counter = 2;
while (deckRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
return slug;
}
}
@@ -0,0 +1,66 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.GameProgress;
import com.example.mindflux.dto.GameStats;
import com.example.mindflux.dto.ProgressData;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class ProgressService {
/** Spiel-Kennung -> Anzeigename, in Dashboard-Reihenfolge. */
private static final Map<String, String> GAMES = new LinkedHashMap<>();
static {
GAMES.put("mindmatch", "MindMatch");
GAMES.put("techtacular", "TechTacular");
GAMES.put("syntax-saga", "Syntax Saga");
GAMES.put("escape-room", "Code Escape Room");
GAMES.put("service-clash", "Service Clash");
GAMES.put("bug-hunter", "Bug Hunter");
GAMES.put("netzwerker", "Netzwerker");
}
private final GameScoreService gameScoreService;
public ProgressService(GameScoreService gameScoreService) {
this.gameScoreService = gameScoreService;
}
public ProgressData getOverallProgress(String username) {
// Dummy-Daten – später aus DB berechnen
ProgressData data = new ProgressData();
data.setCorrectAnswers(432);
data.setCurrentXp(2400);
data.setXpForNextLevel(3000);
data.setUserLevel(5);
data.setProgressPercent(78);
return data;
}
/** Echter Spiel-Fortschritt pro Spiel aus der Datenbank (game_scores). */
public List<GameProgress> getGameProgress(String username) {
List<GameProgress> result = new ArrayList<>();
for (Map.Entry<String, String> entry : GAMES.entrySet()) {
GameStats stats = gameScoreService.getStats(username, entry.getKey());
result.add(toGameProgress(entry.getValue(), stats));
}
return result;
}
private GameProgress toGameProgress(String displayName, GameStats stats) {
GameProgress game = new GameProgress();
game.setName(displayName);
game.setHighscore(stats.getHighscore());
game.setPlays((int) stats.getPlays());
game.setLastPlayed(stats.getLastPlayed());
// Fortschritt auf der Score-Skala (max ~1500 Punkte -> 100 %).
game.setProgressPercent(Math.min(100, Math.round(stats.getHighscore() / 15.0f)));
return game;
}
}
@@ -0,0 +1,227 @@
package com.example.mindflux.service;
import com.example.mindflux.dto.*;
import com.example.mindflux.model.TechtacularAnswer;
import com.example.mindflux.model.TechtacularCategory;
import com.example.mindflux.model.TechtacularQuestion;
import com.example.mindflux.repository.TechtacularCategoryRepository;
import com.example.mindflux.repository.TechtacularQuestionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class TechtacularService {
private static final String GAME = "techtacular";
private static final int BOARD_CATEGORIES = 5;
private static final int[] POINT_TIERS = {100, 200, 300, 400, 500};
private final TechtacularCategoryRepository categoryRepository;
private final TechtacularQuestionRepository questionRepository;
private final GameScoreService gameScoreService;
private final Random random = new Random();
public TechtacularService(TechtacularCategoryRepository categoryRepository,
TechtacularQuestionRepository questionRepository,
GameScoreService gameScoreService) {
this.categoryRepository = categoryRepository;
this.questionRepository = questionRepository;
this.gameScoreService = gameScoreService;
}
// ---------- Spieler ----------
/**
* Baut ein zufälliges Board: bis zu 5 Kategorien, je eine Frage pro Punktstufe (100–500).
* Fragen aus {@code exclude} (z.B. die Vorrunde) werden möglichst vermieden, damit jede
* Runde mit überwiegend neuen Fragen gefüllt wird.
*/
@Transactional(readOnly = true)
public TechtacularBoardView buildBoard(Set<Long> exclude) {
Set<Long> excluded = (exclude == null) ? Set.of() : exclude;
List<TechtacularCategory> usable = categoryRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()
.stream().filter(c -> !c.getQuestions().isEmpty()).collect(Collectors.toList());
Collections.shuffle(usable, random);
List<TechtacularCategory> chosen = usable.stream().limit(BOARD_CATEGORIES).toList();
List<TechtacularBoardView.Category> columns = new ArrayList<>();
int maxScore = 0;
for (TechtacularCategory category : chosen) {
Map<Integer, List<TechtacularQuestion>> byPoints = category.getQuestions().stream()
.collect(Collectors.groupingBy(TechtacularQuestion::getPoints));
List<TechtacularBoardView.Tile> tiles = new ArrayList<>();
for (int value : POINT_TIERS) {
List<TechtacularQuestion> pool = byPoints.get(value);
if (pool == null || pool.isEmpty()) {
continue;
}
// Bevorzugt Fragen, die nicht in der Vorrunde dran waren.
List<TechtacularQuestion> fresh = pool.stream()
.filter(q -> !excluded.contains(q.getId())).collect(Collectors.toList());
List<TechtacularQuestion> pick = fresh.isEmpty() ? pool : fresh;
TechtacularQuestion question = pick.get(random.nextInt(pick.size()));
List<TechtacularBoardView.Option> options = question.getAnswers().stream()
.map(a -> new TechtacularBoardView.Option(a.getId(), a.getText()))
.collect(Collectors.toList());
Collections.shuffle(options, random);
tiles.add(new TechtacularBoardView.Tile(question.getId(), value, question.getText(), options));
maxScore += value;
}
if (!tiles.isEmpty()) {
columns.add(new TechtacularBoardView.Category(category.getTitle(), tiles));
}
}
return new TechtacularBoardView(columns, maxScore);
}
/** Bewertet eine einzelne Antwort serverseitig (Frontend kennt die richtige Antwort vorher nicht). */
@Transactional(readOnly = true)
public AnswerResult checkAnswer(Long questionId, Long answerId) {
TechtacularQuestion question = questionRepository.findById(questionId).orElse(null);
if (question == null) {
return new AnswerResult(false, null, "", 0);
}
TechtacularAnswer correct = question.correctAnswer();
Long correctId = correct != null ? correct.getId() : null;
boolean isCorrect = correctId != null && correctId.equals(answerId);
return new AnswerResult(isCorrect, correctId, question.getExplanation(), isCorrect ? question.getPoints() : 0);
}
/** Schließt eine Runde ab: Score wird aus den tatsächlichen Antworten recomputed und gespeichert. */
@Transactional
public TechtacularResult finish(String username, FinishRequest request) {
int score = 0;
int maxScore = 0;
int correctCount = 0;
int total = 0;
if (request.getAnswers() != null) {
for (AnswerRequest answer : request.getAnswers()) {
if (answer.getQuestionId() == null) {
continue;
}
TechtacularQuestion question = questionRepository.findById(answer.getQuestionId()).orElse(null);
if (question == null) {
continue;
}
total++;
maxScore += question.getPoints();
TechtacularAnswer correct = question.correctAnswer();
if (correct != null && correct.getId().equals(answer.getAnswerId())) {
score += question.getPoints();
correctCount++;
}
}
}
int seconds = Math.max(request.getDurationSeconds(), 0);
ScoreResult saved = gameScoreService.recordScore(username, GAME, null, score, correctCount, seconds);
return new TechtacularResult(score, maxScore, correctCount, total,
saved.getHighscore(), saved.isNewHighscore(), saved.getPlays());
}
// ---------- Admin ----------
/** Leichte Übersicht: Kategorien mit Fragenanzahl (lädt nicht alle Fragen). */
@Transactional(readOnly = true)
public List<AdminCategoryRow> categoryRows() {
return categoryRepository.findAllByOrderBySortOrderAscIdAsc().stream()
.map(c -> new AdminCategoryRow(c.getId(), c.getSlug(), c.getTitle(), c.isActive(),
questionRepository.countByCategoryId(c.getId())))
.toList();
}
/** Eine Kategorie inkl. Fragen und Antworten (für die Detailseite). */
@Transactional(readOnly = true)
public TechtacularCategory categoryWithQuestions(Long id) {
TechtacularCategory category = categoryRepository.findById(id).orElse(null);
if (category != null) {
category.getQuestions().forEach(q -> q.getAnswers().size()); // lazy init
}
return category;
}
@Transactional
public void createCategory(String title) {
if (title == null || title.isBlank()) {
return;
}
String clean = title.trim();
int order = (int) categoryRepository.count() + 1;
categoryRepository.save(new TechtacularCategory(uniqueSlug(clean), clean, order, true));
}
@Transactional
public void addQuestion(Long categoryId, String text, String explanation, int points,
List<String> answers, int correctIndex) {
if (text == null || text.isBlank() || answers == null) {
return;
}
categoryRepository.findById(categoryId).ifPresent(category -> {
TechtacularQuestion question = new TechtacularQuestion(
text.trim(),
(explanation == null || explanation.isBlank()) ? null : explanation.trim(),
normalizePoints(points));
int sort = 0;
boolean hasCorrect = false;
for (int i = 0; i < answers.size(); i++) {
String answerText = answers.get(i);
if (answerText == null || answerText.isBlank()) {
continue;
}
boolean isCorrect = (i == correctIndex);
hasCorrect = hasCorrect || isCorrect;
question.addAnswer(new TechtacularAnswer(answerText.trim(), isCorrect, sort++));
}
// Nur sinnvolle Fragen speichern: mind. 2 Antworten und genau die markierte ist korrekt.
if (question.getAnswers().size() >= 2 && hasCorrect) {
category.addQuestion(question);
categoryRepository.save(category);
}
});
}
@Transactional
public void deleteQuestion(Long questionId) {
questionRepository.deleteById(questionId);
}
@Transactional
public void deleteCategory(Long categoryId) {
categoryRepository.deleteById(categoryId);
}
@Transactional
public void toggleCategory(Long categoryId) {
categoryRepository.findById(categoryId).ifPresent(c -> c.setActive(!c.isActive()));
}
private int normalizePoints(int points) {
int rounded = Math.round(points / 100f) * 100;
return Math.max(100, Math.min(500, rounded));
}
private String uniqueSlug(String title) {
String base = title.toLowerCase()
.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("(^-|-$)", "");
if (base.isBlank()) {
base = "kategorie";
}
String slug = base;
int counter = 2;
while (categoryRepository.existsBySlug(slug)) {
slug = base + "-" + counter++;
}
return slug;
}
}
@@ -0,0 +1,9 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
public interface UserService {
User findByUsername(String username);
void save(User user);
void deleteByUsername(String username); // <-- Nur die Methodensignatur, ohne {}
}
@@ -0,0 +1,28 @@
package com.example.mindflux.service;
import com.example.mindflux.model.User;
import com.example.mindflux.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User findByUsername(String username) {
return userRepository.findByUsername(username).orElse(null); // <-- Optional aufgelöst
}
@Override
public void save(User user) {
userRepository.save(user);
}
@Override
public void deleteByUsername(String username) {
userRepository.deleteByUsername(username);
}
}
@@ -1,19 +0,0 @@
package com.mindflux;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class HelloController {
@GetMapping("/")
public Map<String, String> hello() {
return Map.of(
"app", "mindflux",
"status", "running",
"message", "Hello from Spring Boot 🚀, automatic deploy is working. :-)"
);
}
}